5 Commits

Author SHA1 Message Date
3662363caf feat(sidecar): POST /permissions/inventory forwards the plugin's store read
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m40s
Protocol 13, the permission manager (PLAN_REDESIGNS section 1): one more
opaque forward. The body is {snapshotId?, page?}; paging is the plugin's
and the website's, and no page is kept here.

Refs RunicGateway/Module-Rust#21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-28 06:58:43 -05:00
4498baffe4 Merge pull request 'feat(egg): install every plugin file the release lists, helpers included (D182)' (#19) from feat/protocol-13-egg-helpers into edge
Reviewed-on: #19
2026-09-27 06:11:57 +00:00
fe7b2822c5 feat(egg): install every plugin file the release lists, helpers included (D182)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m58s
Rust-Plugins now releases a ZoneManager helper, RunicGatewayZones.cs, beside
the bridge (docs/modules/rust/PLAN_FIXES.md D181, D182), installed by default.
The egg copied only RunicGateway.cs out of the tarball.

It now takes every .cs the plugin manifest lists in `files`, checks each
against its own sha256 before anything is placed (the bridge's own checksum
was never checked by the egg before), and refuses a name that is not a plain
<Name>.cs. A release older than helpers lists only the bridge and installs
exactly what it did before. Exercised in an Alpine shell against both, a
tampered helper, a missing one, `../evil.cs` and `evil.dll`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:25:35 -05:00
0179ca52ec Merge pull request 'fix(sidecar): protocol 13 — a configuration write no longer waits for its reload (F9)' (#18) from fix/protocol-13-config-reload into edge
Reviewed-on: #18
2026-09-26 22:29:27 +00:00
522187ddd1 fix(sidecar): protocol 13 — a configuration write no longer waits for its reload (F9)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m48s
The plugin now answers config.write once the files are on disk and reports
the reload as a config.outcome event, which this process files and feeds by
its type like any other event. The reload window no longer has to fit
inside REPLY_TIMEOUT, so CONFIG_RELOAD_WINDOW and the test that asserted
the pairing are gone, and the 504 body stops pointing at a rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 17:08:37 -05:00
3 changed files with 75 additions and 47 deletions

View File

@@ -148,7 +148,25 @@ rg_install() {
mkdir -p /mnt/server/rust-link "${plugins}"
install -m 755 "${work}/rust-link-sidecar" /mnt/server/rust-link/rust-link-sidecar
install -m 755 "${work}/with-sidecar.sh" /mnt/server/rust-link/with-sidecar.sh
install -m 644 "${work}/runicgateway-rust-plugin/RunicGateway.cs" "${plugins}/RunicGateway.cs"
# Every .cs the release's manifest lists: the bridge, and the helpers shipped
# beside it (docs/modules/rust/PLAN_FIXES.md D182 - today RunicGatewayZones.cs,
# the ZoneManager helper), each checked against its own sha256 first. A name
# is written into the plugins directory, so only a plain <Name>.cs is taken.
local file sha
for file in $(jq -r '.files | keys[]' "${manifest}"); do
case "${file}" in
*[!A-Za-z0-9_.]* | .* | *..* | *[!s] ) echo "Runic Gateway: the plugin manifest lists ${file}, which is not a plugin file - refusing it"; return 1 ;;
esac
[ "${file%.cs}" != "${file}" ] || { echo "Runic Gateway: the plugin manifest lists ${file}, which is not a .cs file - refusing it"; return 1; }
[ -f "${work}/runicgateway-rust-plugin/${file}" ] || { echo "Runic Gateway: the plugin manifest lists ${file} but the tarball has none"; return 1; }
sha="$(jq -r --arg f "${file}" '.files[$f]' "${manifest}")"
echo "${sha} ${work}/runicgateway-rust-plugin/${file}" | sha256sum -c --quiet - \
|| { echo "Runic Gateway: ${file} does not match the plugin manifest's sha256 - refusing it"; return 1; }
done
[ -f "${work}/runicgateway-rust-plugin/RunicGateway.cs" ] || { echo "Runic Gateway: the plugin tarball has no RunicGateway.cs"; return 1; }
for file in $(jq -r '.files | keys[]' "${manifest}"); do
install -m 644 "${work}/runicgateway-rust-plugin/${file}" "${plugins}/${file}"
done
# What is installed, readable from the panel's file manager.
jq --arg framework "${FRAMEWORK}" --arg installed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ bundle, protocol, framework: $framework, installed: $installed,

View File

@@ -101,11 +101,9 @@ use tracing_subscriber::EnvFilter;
/// for the reload to announce itself, and **puts the old files back automatically** if it does
/// not.
///
/// Two things about that reach this process. The write is the only route here that causes a write
/// on the game host, and it is the only one whose reply routinely spends seconds rather than
/// milliseconds — the plugin holds the correlation open across a reload and, at worst, across a
/// rollback as well. `web::CONFIG_RELOAD_WINDOW` is that budget, mirrored from the plugin, and a
/// test asserts the pairing rather than trusting it.
/// The write is the only route here that causes a write on the game host. Since protocol 13 its
/// reply comes back as soon as the files are on disk, marked `pending`; how the reload went follows
/// later as a `config.outcome` event, which this process files and feeds like any other.
///
/// # Protocol 6 — first-party clans
///
@@ -165,10 +163,23 @@ use tracing_subscriber::EnvFilter;
/// keeps it, and positions are asked for while somebody is looking and never touch the database
/// (D111). Which layer a viewer may see is decided on the website, never here.
///
/// # Protocol 13 — the first player walk's fixes
///
/// Opened by the configuration save (the module's PLAN_FIXES.md F9, D177): `POST /config/write`
/// answers `pending` at once instead of holding the RPC across a reload, and the outcome arrives
/// as a `config.outcome` event. Nothing new to route — an event is filed and fed by its `type`,
/// whatever its kind — so what changes here is what no longer has to fit: the reload window that
/// had to sit inside [`rpc::REPLY_TIMEOUT`] is gone.
///
/// The permission manager's redesign (the module's PLAN_REDESIGNS.md §1, D160) adds one route,
/// `POST /permissions/inventory`: the plugin's whole store, in pages it sizes to the game link's
/// line cap. Forwarded like every other object; the pages are never kept here.
///
/// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the
/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs,
/// §16 the rewards, §17 the map, §18 the optional mods; this constant is one of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 12;
/// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes; this constant is one
/// of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 13;
fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) {

View File

@@ -95,6 +95,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// plugin will do with any of it. It puts `cmd` and `reqId` on the object and forwards it,
// exactly as it forwards a link code.
.route("/permissions/sync", post(perm_sync))
// Protocol 13 (D160): the whole store, owners included, in pages. The body is
// `{snapshotId?, page?}` and is forwarded as it is; paging is the plugin's and the
// website's business, never this process's, which keeps no copy of any page.
.route("/permissions/inventory", post(perm_inventory))
// Protocol 5 (R18): the plugin's own view of the game host's configuration tree. All
// three are correlated round trips and all three fail when the game is down, because
// "what is on that host's disk" has no stale answer worth giving — and, unlike a board,
@@ -102,9 +106,8 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// an operator edited over SSH and the website then overwrote.
.route("/config/files", get(config_files))
.route("/config/file", get(config_file))
// The only route on this sidecar that causes a WRITE on the game host, and the only one
// whose reply can take most of the RPC budget: the plugin holds it open across a reload
// and, at worst, across a rollback as well. See `CONFIG_RELOAD_WINDOW`.
// The only route on this sidecar that causes a WRITE on the game host. It answers once the
// files are on disk; the reload's outcome follows as a `config.outcome` event (protocol 13).
.route("/config/write", post(config_write))
// Protocol 8 (the module's PLAN.md §27): an event borrowing a value and giving it back.
// Three correlated round trips, and the sidecar knows nothing about any of them — not
@@ -436,6 +439,13 @@ async fn perm_sync(State(st): State<AppState>, Json(body): Json<Value>) -> Respo
respond(st.rpc.call(&st.game, command, &req_id).await)
}
/// One page of the plugin's permission inventory (protocol 13). Page 0 without a `snapshotId`
/// starts a fresh read; any other page names the snapshot page 0 answered with. A `perm.error`
/// (`busy`, `stale`, `too-large`) is an answer, and arrives as a `200` like the sync's.
async fn perm_inventory(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "perm.inventory", "an inventory request").await
}
#[derive(Debug, Deserialize)]
struct ConfigPathQuery {
path: String,
@@ -694,19 +704,14 @@ async fn config_file(State(st): State<AppState>, Query(q): Query<ConfigPathQuery
respond(st.rpc.call(&st.game, command, &req_id).await)
}
/// The window the plugin gives a reload to announce itself, mirrored here from
/// `ConfigReloadWindowSeconds` in `overlay/oxide/plugins/RunicGateway.cs`.
///
/// It is duplicated rather than negotiated because the two numbers are a *pairing*, like the
/// protocol version: the plugin owns the behaviour and this end owns the budget it has to fit in.
/// The test below is what keeps them honest — a worst-case write is two windows, and a
/// [`REPLY_TIMEOUT`](crate::rpc::REPLY_TIMEOUT) that does not cover both would abandon the caller
/// precisely when a rollback had just saved them, leaving the website to report a timeout over a
/// server that is perfectly healthy.
pub const CONFIG_RELOAD_WINDOW: Duration = Duration::from_secs(4);
/// Replace a set of configuration files and reload whatever owns them.
///
/// Since protocol 13 the reply does not wait for the reload. The plugin answers once the files are
/// on disk (`pending: true`, a `writeId`, and the `ceilingMs` it will wait), and reports the reload
/// as a `config.outcome` event when it settles. Protocol 12 held this RPC across the reload, which
/// forced the plugin's window under [`REPLY_TIMEOUT`](crate::rpc::REPLY_TIMEOUT) — and a cold
/// compile outlasted it and had a valid edit rolled back (the module's PLAN_FIXES.md F9).
///
/// Opaque body, exactly as `/permissions/sync`: `cmd` and `reqId` are written over whatever the
/// caller sent, and nothing else about the object is read here. What the website sends is whole
/// file *text* rather than a key and a value (D35), so there is nothing on this hop that could
@@ -750,16 +755,15 @@ async fn config_write(State(st): State<AppState>, Json(body): Json<Value>) -> Re
let result = st.rpc.call(&st.game, command, &req_id).await;
if matches!(result, Err(RpcError::Timeout)) {
// A bare `504` on a route that writes reads as "did my change land, and is the plugin
// still up?" — and unlike every other timeout on this sidecar, that question has a good
// answer. The plugin writes a whole set or restores a whole set, never half of either, so
// a re-read settles it; and a write that is still in flight is most likely inside the
// rollback this window pays for.
// A bare `504` on a route that writes reads as "did my change land?" — and unlike every
// other timeout on this sidecar, that question has a good answer. The plugin writes a
// whole set or none of it, and restores a whole set or none of it, so a re-read settles
// it. Since protocol 13 the reply no longer waits for a reload, so this is a slow host or
// a busy main thread, not a rollback in progress.
return (
StatusCode::GATEWAY_TIMEOUT,
Json(json!({
"error": "the plugin did not report within the write budget",
"reloadWindowSeconds": CONFIG_RELOAD_WINDOW.as_secs(),
"hint": "re-read the files: the plugin writes the whole set or restores it",
})),
)
@@ -1037,25 +1041,6 @@ mod tests {
assert_eq!(command["grants"], json!([]));
}
/// A command larger than the game link's own line cap is refused here, where the caller learns
/// why. Forwarded, it would be discarded by both ends without a word and present as a `504`.
/// The pairing in `CONFIG_RELOAD_WINDOW`'s own words, asserted rather than trusted.
///
/// The worst path through one configuration write is two windows — wait for the edited
/// plugin's reload, give up, restore the files, reload again — plus the write, the reads and
/// a line each way. If that does not fit inside the RPC timeout, the caller is abandoned at
/// exactly the moment the rollback saved it, and the website reports a timeout over a server
/// that is healthy and has the old config back.
#[test]
fn a_rollback_fits_inside_the_rpc_budget() {
let worst = CONFIG_RELOAD_WINDOW * 2;
assert!(
worst + Duration::from_secs(1) <= crate::rpc::REPLY_TIMEOUT,
"two reload windows ({worst:?}) plus a second of slack must fit in {:?}",
crate::rpc::REPLY_TIMEOUT
);
}
/// Protocol 8's routes cannot choose their own command or correlation id either, and they keep
/// everything else the caller sent, unread.
#[test]
@@ -1107,6 +1092,20 @@ mod tests {
assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none());
}
/// Protocol 13: an inventory page request keeps its paging fields and cannot become a sync.
#[test]
fn an_inventory_request_cannot_choose_its_own_command() {
let body =
json!({ "cmd": "perm.sync", "reqId": "theirs", "snapshotId": "abc123", "page": 2 });
let stamped = stamp(body, "perm.inventory", "r-7").expect("an object is stamped");
assert_eq!(stamped["cmd"], "perm.inventory");
assert_eq!(stamped["reqId"], "r-7");
assert_eq!(stamped["snapshotId"], "abc123");
assert_eq!(stamped["page"], 2);
assert!(stamp(json!([0]), "perm.inventory", "r-8").is_none());
}
/// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's
/// and the plugin's business, so it must arrive byte for byte as the website composed it.
#[test]