diff --git a/overlay.toml b/overlay.toml
index e80c61a..31b2da0 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,9 +26,9 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
-# Current: 4 — the transport, the read path, identity, and the permission mirror
-# (docs/rust-link/PROTOCOL.md §10).
-protocol = 4
+# Current: 5 — the transport, the read path, identity, the permission mirror, and
+# configuration from the site (docs/rust-link/PROTOCOL.md §11).
+protocol = 5
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index c32cdf7..55732b4 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.IO;
using System.Net.Sockets;
using System.Text;
using System.Threading;
@@ -56,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 4;
+ private const int ProtocolVersion = 5;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -234,7 +235,8 @@ namespace Oxide.Plugins
"CanUserLogin", "OnUserApproved",
"OnUserPermissionGranted", "OnUserPermissionRevoked",
"OnUserGroupAdded", "OnUserGroupRemoved",
- "OnGroupPermissionGranted", "OnGroupPermissionRevoked"
+ "OnGroupPermissionGranted", "OnGroupPermissionRevoked",
+ "OnPluginLoaded", "OnPluginUnloaded"
};
private void Init()
@@ -296,6 +298,13 @@ namespace Oxide.Plugins
_permJob = null;
StopPermTimer();
+ // A config write waiting on a reload dies here too, and its files stay as written.
+ // That is the honest outcome: the rollback exists to undo an edit that took a plugin
+ // down, and this plugin being unloaded is not evidence of either — it is usually THIS
+ // plugin being reloaded by somebody. The caller times out and re-reads.
+ StopConfigWatch();
+ _configJob = null;
+
_running = false;
_stopping.Set();
Wake.Set();
@@ -777,6 +786,22 @@ namespace Oxide.Plugins
HandlePermSync(cmd, reqId);
break;
+ case "config.list":
+ // Protocol 5 (R18). What settings files exist on this host, and which plugins
+ // are loaded to reload them. A description of the tree, never its contents.
+ HandleConfigList(reqId);
+ break;
+
+ case "config.read":
+ HandleConfigRead(cmd, reqId);
+ break;
+
+ case "config.write":
+ // The first command that writes to the game HOST's filesystem, which is the
+ // most powerful thing the website can do to a server.
+ HandleConfigWrite(cmd, reqId);
+ break;
+
default:
Puts($"unknown command '{verb}'");
break;
@@ -1675,8 +1700,7 @@ namespace Oxide.Plugins
private static long NowMs()
{
- return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc))
- .TotalMilliseconds;
+ return ToUnixMs(DateTime.UtcNow);
}
// ---- account linking ----
@@ -2867,6 +2891,45 @@ namespace Oxide.Plugins
EmitPermDrift("group-permission-revoked", null, permName, name);
}
+ // ---- the reload, watched ----
+ //
+ // These two are why an automatic rollback is possible at all (R18): a reload either
+ // produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin
+ // that is DOWN because of an edit the website just made.
+ //
+ // They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from
+ // either: a busy server loads and unloads plugins for reasons that have nothing to do with
+ // us, and a frame per event would be noise on the wire and a trigger nobody asked for.
+
+ private void OnPluginLoaded(Plugin plugin)
+ {
+ MarkHook("OnPluginLoaded");
+
+ ConfigWriteJob job = _configJob;
+
+ if (job == null || plugin == null)
+ return;
+
+ if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase))
+ ConfigReloadArrived();
+ }
+
+ private void OnPluginUnloaded(Plugin plugin)
+ {
+ MarkHook("OnPluginUnloaded");
+
+ ConfigWriteJob job = _configJob;
+
+ if (job == null || plugin == null)
+ return;
+
+ // Only ever used to word the failure. "It unloaded and did not come back" is a
+ // compile error in the edited file; "it never reloaded" is a framework that did not
+ // act on the request at all, and those send an operator to different places.
+ if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase))
+ job.Unloaded = true;
+ }
+
///
/// Main thread. Says that the store changed under us, and by how much it is worth
/// caring.
@@ -2899,6 +2962,1053 @@ namespace Oxide.Plugins
EmitFrame(frame);
}
+ // ---- protocol 5: plugin configuration ----
+ //
+ // R18. An admin edits a loaded plugin's settings on the website and the plugin reloads.
+ // Three request/reply verbs: `config.list` describes the tree, `config.read` returns one
+ // file, `config.write` replaces a set and reloads whatever owns it.
+ //
+ // Four properties live on this side because only the game host can hold them:
+ //
+ // * **The roots come from the framework, never from a literal.** `ConfigDirectory` is
+ // `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose
+ // operator moved it (`CARBON.md` §3). That amendment was proven the best way it could
+ // have been: this plugin's own config landed in both places, from this same file.
+ // * **`DataDirectory` is never walked.** It holds live state — kit cooldowns, zone
+ // definitions — and both frameworks' permission stores, which is R2's mirror one
+ // directory over. A settings editor that strayed there would be editing phase 7
+ // underneath itself.
+ // * **A reload is observable.** `OnPluginLoaded` is a real hook, so "did that work" has
+ // an answer rather than an assumption — and an answer is the whole reason an automatic
+ // rollback is possible at all.
+ // * **The site composes the bytes; this plugin writes them.** It receives whole file
+ // text, not a key and a value, because JavaScript cannot tell `1` from `1.0` and Oxide
+ // deserializes into typed C# classes (D35/D36). Everything this end does to a document
+ // is check that it parses.
+
+ /// How deep the walk goes before it stops trying to describe a tree.
+ private const int ConfigMaxDepth = 6;
+
+ /// How many files the walk will report.
+ private const int ConfigMaxFiles = 500;
+
+ ///
+ /// The largest file this plugin will read or write.
+ ///
+ ///
+ /// A file past it is reported and marked un-editable with the reason, never
+ /// omitted: an operator who cannot find a file they know exists goes looking for a bug
+ /// in the bridge, and one who can see why it was refused does not.
+ ///
+ ///
+ private const int ConfigMaxFileBytes = 256 * 1024;
+
+ /// Files one write may touch. A plugin can own several; ten of them is not a tree.
+ private const int ConfigMaxWriteFiles = 10;
+
+ ///
+ /// How long a reload has to produce OnPluginLoaded before the write is undone.
+ ///
+ ///
+ /// It is under half the sidecar's RPC timeout, and that is arithmetic rather than
+ /// taste. The worst path through one write is two of these windows — wait for the
+ /// edit's reload, give up, restore, wait for the restore's reload — with a caller
+ /// holding a socket for the whole of it (REPLY_TIMEOUT, 10s, rust-link
+ /// sidecar/src/rpc.rs). A window that does not leave room for both makes the
+ /// rollback report arrive after the only thing waiting to hear it has given up, which
+ /// is the one outcome worse than no rollback: the plugin is fine and nobody knows.
+ ///
+ ///
+ private const float ConfigReloadWindowSeconds = 4f;
+
+ /// How much of the newest log file is read back when a reload does not arrive.
+ private const int ConfigLogTailBytes = 8 * 1024;
+
+ /// One file, resolved and checked, on its way to disk.
+ private sealed class StagedConfigWrite
+ {
+ public string RelPath;
+ public string FullPath;
+ public string Text;
+
+ /// What was there before, held for the rollback that may never be needed.
+ public string Original;
+
+ public bool Written;
+ }
+
+ ///
+ /// A write that has landed and is waiting to hear whether the plugin came back.
+ ///
+ ///
+ /// At most one exists at a time. A second write arriving mid-window is refused rather
+ /// than queued: two overlapping rollbacks over intersecting file sets can restore a
+ /// file to a state that was never on disk.
+ ///
+ ///
+ private sealed class ConfigWriteJob
+ {
+ public string ReqId;
+ public string Reload;
+ public List Staged;
+ public Timer Watch;
+
+ /// Whether the target was seen leaving. It separates "never started" from "did not come back".
+ public bool Unloaded;
+ }
+
+ private ConfigWriteJob _configJob;
+
+ private string _lastConfigSummary = "none yet";
+
+ /// Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19).
+ private static bool? _carbon;
+
+ private void HandleConfigList(string reqId)
+ {
+ string root = ConfigRoot();
+
+ if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
+ {
+ EmitConfigError(reqId, "no-root", "this framework reports no configuration directory");
+ return;
+ }
+
+ var files = new List