From 82145d3b4aa46dffd36a961609f9b05981b8437e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 22 Sep 2026 08:55:39 -0500 Subject: [PATCH 1/2] =?UTF-8?q?docs(modules):=20phase=207b=20as=20built=20?= =?UTF-8?q?=E2=80=94=20configuration=20from=20the=20site,=20and=20the=20un?= =?UTF-8?q?do=20that=20makes=20it=20safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROTOCOL.md §11 specifies protocol 5: the walk rooted at the framework's own config directory, the version a write must present back, the set-shaped write, the watched reload and the automatic restore. PLAN.md §21 is the phase as built — four org-lead decisions, the float trap and what avoiding it cost, and four defects a browser found that 179 green tests did not. PLAYER_WALK.md gains the configuration walk, because the acceptance line needs the sidecar and the game on one host and the rig cannot reach one yet. INTEGRATION.md gains the four things an operator should know before they edit a plugin's settings from a web page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- modules/rust/PLAN.md | 135 ++++++++++++++++++++++++- rust-link/INTEGRATION.md | 23 +++++ rust-link/PLAYER_WALK.md | 41 ++++++++ rust-link/PROTOCOL.md | 207 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 404 insertions(+), 2 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index a9b0115..056479b 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -985,7 +985,7 @@ Each phase ends with its findings written down, as every workstream here does. | 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged | | 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page | | 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one | -| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `/x.json` is found and reloads the right plugin | +| 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `/x.json` is found and reloads the right plugin | | 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app | | 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly | | 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else | @@ -3068,6 +3068,139 @@ identity), not a claim. --- +## 21. Phase 7b as built — configuration from the site, 2026-09-22 + +**The site's half is walked end to end against a real sidecar; the plugin's half is proven to +compile and load on a live Oxide rig, and the in-game leg is still blocked on the same firewall rule +phases 6 and 7 hit.** R18 is the second thing this module does *to* a game, and the first that +writes to the game host's filesystem. + +Four repositories touched: [`Rust-Plugins#5`][rp], [`Rust-Link#5`][rl], [`Module-Rust#9`][mr] (all +into `edge`) and this document. Core needed no change again — the second Rust phase running. + +### 21.0 The four decisions this phase needed + +| | Decision | +|---|---| +| **D35** | **The website composes the file's bytes; the plugin writes them.** The generated form sends pointers and literals, a span-splicing editor on the Node side produces the new text, and `config.write` carries whole file text. The alternative — sending key/value edits for Newtonsoft to apply, where C# genuinely can tell `1` from `1.0` — was considered and rejected: it gives the plugin authority over content, puts the decision in a place with no test for it, and still needs the text path for the raw tier. | +| **D36** | **A number travels as the literal an admin typed**, all the way from the browser to disk, and never becomes a JavaScript number. `2.50` stays `2.50`. | +| **D37** | **The raw-JSON tier shows real values, including credentials.** An admin can already read the file over SSH, and a masked raw tier would need the server to splice the originals back into whatever was submitted. The generated form still masks a credential-shaped field behind a *Show* control, and **the audit trail never records the values either way** — a log is read by more people, for longer, and usually by somebody who was not there. | +| **D38** | **The bridge's own config is editable, with three keys locked.** `Host` and `Port` carry the link the edit is travelling over; `ServerId` keys every row this site holds. All three render read-only with the reason; `QueueCap` and anything added later stay editable. Hiding our config entirely was rejected — it reverses D3 — and so was warn-only, because a typo in `Port` orphans the server with no path back. | + +### 21.1 The trap, and what it cost to avoid + +**JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` is `1` and `JSON.stringify` +writes `1`, while both frameworks deserialize into typed C# classes — so a read-modify-write +silently rewrites every whole-numbered float as an integer *on fields nobody touched*, and Newtonsoft +may coerce that or may throw. A throw at load is a plugin that does not come back, and R6/R17 make +four of them required. + +`server/configEdit.js` is the answer: a JSON reader that records the **source span** of every value, +and an `applyEdits` that splices literals into those spans from the end of the document backwards. +Nothing parses, mutates and re-serialises. Everything a save did not touch is byte-identical — +indentation, key order, and the `.0` on a float. + +Its suite asserts the failure as well as the fix: the same document through `JSON.parse` / +`JSON.stringify` loses `1.0`, `2.50` and `1e3`, and the test says so in the same breath as proving +the splice keeps them. **A walk on the live rig wrote `2.50` into `ZoneManager.json` and left +`500.0` and a `null` exactly as they were.** + +Two rules fall out and both are deliberate: a number's new value arrives as **text** an admin typed, +and the generated form is **type-preserving** — changing what a value *is* belongs in the form, +changing what *kind* of thing it is belongs in the raw tier. + +### 21.2 The rollback is the feature, and the window is arithmetic + +The plugin backs the set up, writes it, reloads the target through the framework, waits four seconds +for `OnPluginLoaded`, and — if it never arrives — **restores every file, reloads again, and reports +the failure with the tail of the newest log file.** Without that, R18 is a web form that takes a +required plugin off a production server one typo at a time. + +Four seconds is not taste. The worst path is two windows — wait, give up, restore, wait again — +while the caller holds a socket, so it has to fit inside the sidecar's 10s `REPLY_TIMEOUT`. The +sidecar mirrors the number as `web::CONFIG_RELOAD_WINDOW` and **a test asserts the inequality** +rather than trusting the comment, in the shape phase 3 established for `budgetMs`. + +Walked on the rig: a save whose reload never announced itself came back `200` with +`rolledBack: true`, the file on disk was the original, and the compiler line was on the screen. +**A rollback is a round trip that worked carrying bad news** — reporting it as a 5xx would throw +away the only diagnosis there is. + +### 21.3 The bridge will not reload itself + +Reloading this plugin would unload it, close the link the answer travels on, and leave a rollback +with nothing watching it: the one failure the whole mechanism exists to report would be the one it +could not. `reload-self` is refused at the plugin, the website leaves it out of the dropdown, and +our own settings apply on the next deliberate reload. + +That produced the phase's best defect (§21.5, #1). + +### 21.4 What the walk proved, and on what + +Two rigs, because the site half and the plugin half fail in different places. + +- **The site half** ran against the real sidecar and a stand-in plugin over a real directory of real + config files: the recursive walk (including a nested `Kits/kits.json`), a form save that reloaded, + a save that rolled back, a refusal, a write with no reload, a version conflict with the current + file handed back, and the locked keys. Five outcomes, all five in the audit trail. +- **The plugin half** ran on the **live Oxide rig**: 1,050 new lines of C# `compiled successfully in + 0ms`, the plugin loaded, and `rg.config` answered + `protocol=5 framework=oxide root=/home/container/oxide/config`. That is `Interface.Oxide. + ConfigDirectory` resolving and the runtime framework detection agreeing, on a real server, without + a line of conditional compilation. + +**The acceptance line is not met**: "an admin flips a ZoneManager setting from the website and it +takes effect" needs the plugin and the sidecar on one host, and the rig's plugin still cannot reach +a sidecar on the development machine — two `Block` rules for `rust-link-sidecar.exe` in Windows +Firewall, the same wall phases 6 and 7 stopped at. It is the same walk each time and it is written +down in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). + +### 21.5 Four defects a browser found that 179 green tests did not + +1. **Every save of the bridge's own config was refused**, with the page saying the opposite. The + reload target was seeded from the file's guessed plugin — `RunicGateway` — which is deliberately + *not* offered in the dropdown, and **a `