← Commit history

docs(web-control): restore the original page and ADD the architecture answer - one verb surface not two, the four real differences, the single honest redundancy (send_files vs write_file_chunk), and the chunked large-file transfer

John Lauer ·2a22645782 ·2mo ago ·parent 193d6a8
1 file changed +159−52
docs/web-control.md+159−52
@@ -1,59 +1,83 @@-# Web Control: how a web page drives Adom Bridge+# Talking to Adom Bridge from a web page -**Short answer to the question this document exists for: there is ONE verb surface, not two.** Web control is not a separate API with its own verbs. It is the *same* verbs, reached over a different transport, with a different consent rule. There is no "web-control verb list" to keep in sync with a "main verb list", because there is only one list.+A page served in a browser can reach the copy of Adom Bridge running on the same+machine, over its loopback HTTP API at `http://127.0.0.1:24680`. Detection is open;+running a verb asks the human for permission in Adom Bridge's own window. -The rest of this page is the precise version of that, and the four places the two paths genuinely differ.+## One verb surface, not two ----+**Web control is not a separate API with its own verbs.** It is the *same* verbs, over a+different transport, with a different consent rule. There is no "web-control verb list" to+keep in sync with a "main verb list", because there is only one list. -## The one surface+`src-tauri/src/direct_api.rs` states the commitment in its own header: -`src-tauri/src/direct_api.rs` says it in its own header, and it is the design commitment worth protecting:+> …dispatches through the same `commands::handle_command` function the WS path uses…+> **Every verb that works over WS works over the direct API too - no per-verb plumbing.** -> …dispatches through the same `commands::handle_command` function the WS path uses… **Every verb that works over WS works over the direct API too - no per-verb plumbing.**--So all three callers converge on one dispatcher:+All three callers converge on one dispatcher:  ```-  the AI, via the adom-bridge CLI  ──┐-  Hydrogen / a bridge, on loopback ──┼──►  commands::handle_command  ──►  the verb-  a web page, via fetch()          ──┘+  the AI, via the adom-bridge CLI  --++  Hydrogen / a bridge, on loopback --+--> commands::handle_command --> the verb+  a web page, via fetch()          --+ ``` -Concretely: **170 verbs** are documented in ab's own explorer (`GET /verbs`), and a web page can call **166** of them. The four it cannot are listed below, and the reason is transport, not policy.--**This means adding a verb for web control is almost always a mistake.** Write the verb once; it is automatically available to the CLI, to Hydrogen, to bridges, and to web pages. If you catch yourself writing `web_*` anything, stop.-----+Concretely: **170 verbs** are documented in ab's own explorer (`GET /verbs`), and a web page+can call **166** of them. The four it cannot are listed below, and the reason is transport,+not policy.++**So adding a verb "for web control" is almost always a mistake.** Write the verb once and it+is automatically available to the CLI, to Hydrogen, to bridges, and to web pages. If you catch+yourself writing `web_*` anything, stop.++## The model (v2.0.31)++- **Detection is open.** `GET /health` and `/status` answer any web origin+  (liveness, version, which bridges are installed). This is non-sensitive, and it+  has to be open because the wiki renders its readme inside a **sandboxed iframe**+  (`allow-scripts` WITHOUT `allow-same-origin`), so the iframe's `fetch()` carries+  `Origin: null`, not `https://wiki.adom.inc`. An origin allowlist can't gate that.+- **Running a verb asks for consent.** A `POST /command` from a browser is not+  refused and is not silent. Adom Bridge pops its **own** prompt in its **own**+  window naming the page and the verb, with **Allow once / Allow 1 hr / Deny**+  (no Bypass or 24 hr for a web caller). The human clicking is the authorization.+- **Consent is the gate, not origin.** Because a human approves each web action in+  a window no web page can script, the origin does not need to be trusted. That is+  why this works for an `Origin: null` iframe, and why the blast radius of even a+  compromised page is "a prompt the user can Deny," never a takeover.+- **Anti-spam.** A Deny mutes re-prompts from that page for 30s; the write path is+  over loopback only (anti-DNS-rebind on the `Host` header).++`Allow 1 hr` grants that page an hour of verbs without re-prompting; `Allow once`+runs just the one. The port `24680` is fixed and stable; browsers treat+`http://127.0.0.1` as a trustworthy secure context, so an HTTPS page reaches it+with no mixed-content error.  ## The four real differences  ### 1. Transport: JSON over HTTP, not the CLI's WebSocket -A web page has `fetch()` and nothing else. No SDK, no library, no binary channel:--```js-const r = await fetch("http://127.0.0.1:24680/command", {-  method: "POST",-  headers: { "Content-Type": "application/json" },-  body: JSON.stringify({ command: "desktop_list_windows", args: {} })-});-```--The CLI, by contrast, holds a WebSocket to the relay and can send **binary frames**. That single capability difference is the origin of every remaining item on this list.+A web page has `fetch()` and nothing else - no SDK, no library, no binary channel. The CLI+holds a WebSocket to the relay and can send **binary frames**. That single capability+difference is the origin of every remaining item on this list.  ### 2. Four verbs need the CLI (`cliRequired`)  ``` pull_file      streaming binary frames-send_files     binary streaming variant lives only on the relay+send_files     the binary streaming variant lives only on the relay hd_send_files  relayed to Hydrogen's /files/inject - relay-only transfer shell_execute  multi-minute approval polling, not one synchronous request ``` -These are refused up front with `errorCode: "cli_required"` and a pointer to the CLI, rather than failing obscurely. The live list is always at `GET /status.directApi.cliRequired` - read it there rather than trusting this page.+These are refused up front with `errorCode: "cli_required"` and a pointer to the CLI, rather+than failing obscurely. The live list is always at `GET /status.directApi.cliRequired` - read+it there rather than trusting this page. -**Note what is NOT on that list:** `run_script`, `write_file`, `registry_set`, `process_kill`, every `desktop_*` verb, every bridge verb. Web control is not a reduced "safe subset". It is the full surface minus four things that physically need a different transport.+Note what is **not** on that list: `run_script`, `write_file`, `registry_set`, `process_kill`,+every `desktop_*` verb, every bridge verb. Web control is not a reduced "safe subset" - it is+the full surface minus four things that physically need a different transport.  ### 3. Consent replaces the relay gate @@ -62,52 +86,73 @@ Both paths are gated; they are gated *differently*, because the trust question d | caller | gate | |---|---| | cloud AI over the relay | **Tier-2 gate** (`via_relay`): shell/write/registry/process verbs need a standing human grant |-| local CLI, Hydrogen, a bridge | **trusted by transport** - loopback + install consent, no gate |-| **a web page** | **per-origin consent**: the first state-changing call pops a human Allow once / Allow 1 hr / Deny |+| local CLI, Hydrogen, a bridge | **trusted by transport** - loopback plus install consent, no gate |+| **a web page** | **per-origin consent**: the first state-changing call pops Allow once / Allow 1 hr / Deny | -A web page is on loopback, so it is *not* `via_relay` and does not hit the Tier-2 gate - the **consent gate stands in for it**. Grants are keyed per origin and visible at `/status.webControl.grants`, in ab's window, and via `web_control_grants` / `web_control_revoke`.+A web page is on loopback, so it is *not* `via_relay` and does not hit the Tier-2 gate - the+consent gate stands in for it. Grants are keyed per origin and visible at+`/status.webControl.grants`, in ab's window, and via `web_control_grants` / `web_control_revoke`. -The built-in demo page is deliberately served on **port 24681** while the command API is on **24680**, so the demo is *cross-origin to ab and gets no special privilege* - it faces the identical consent prompt any other site would. Serving it same-origin would let its calls skip the gate, which is exactly the bypass to avoid.+The built-in demo page is deliberately served on **port 24681** while the command API is on+**24680**, so the demo is cross-origin to ab and gets **no special privilege** - it faces the+identical consent prompt any other site would. Serving it same-origin would let its calls skip+the gate, which is exactly the bypass to avoid.  ### 4. A cross-origin `GET /status` is projected down -Any page may detect ab (that is the point of an open `/health` and `/status`), but a cross-origin `/status` returns only `service`, `ok`, `version`, `schema`, and a small `webControl` block. The full document - machine name, install path containing the username, permission state, the list of every origin the user has approved - is **local callers only**. Fixed in 2.0.65 (ab#89); before that, any site could read all of it with no consent step.-----+Any page may detect ab - that is the point of an open `/health` and `/status` - but a+cross-origin `/status` returns only `service`, `ok`, `version`, `schema`, and a small+`webControl` block. The full document (machine name, install path containing the username,+permission state, the list of every origin the user has approved) is **local callers only**.+Fixed in 2.0.65 (ab#89); before that, any site could read all of it with no consent step.  ## So is anything redundant? -One thing, honestly, and it is worth naming rather than hiding:+One thing, and it is worth naming rather than hiding:  | verb | transport | reachable from a web page | |---|---|---| | `send_files` | relay WebSocket, 1 MiB binary frames | no (`cliRequired`) | | `write_file_chunk` | base64 chunks over `/command` JSON | **yes** | -Both move a large file onto the PC. That is one *capability* with two *transports*, and the duplication is forced by the browser: `send_files` streams binary frames over the relay WS, and a page posting JSON to loopback cannot speak that. Exposing `send_files` over HTTP would have meant reimplementing its transport anyway - the same work, wearing a name that promised something it could not deliver.+Both move a large file onto the PC. That is one *capability* with two *transports*, and the+duplication is forced by the browser: `send_files` streams binary frames over the relay WS,+and a page posting JSON to loopback cannot speak that. Exposing `send_files` over HTTP would+have meant reimplementing its transport anyway - the same work, wearing a name that promised+something it could not deliver. -`write_file_chunk` is **not a web-control verb**. It is on the one shared surface and the CLI, Hydrogen and bridges can all call it. It exists because the ~2.5 MB `/command` body cap is a property of HTTP, which the CLI also hits when it uses the direct API.+`write_file_chunk` is **not a web-control verb**. It is on the one shared surface, and the CLI,+Hydrogen and bridges can all call it. It exists because the ~2.5 MB `/command` body cap is a+property of HTTP, which the CLI also hits when it uses the direct API.  Everything else has exactly one implementation. ----- ## Large files: `write_file_chunk` (ab 2.0.72+, ab#90) -`write_file` carries the whole payload as base64 inside the JSON body. Base64 inflates ~33%, the body cap is ~2.5 MB, and a real `.step` 3D model is routinely 1-10 MB - so staging one returned `413 Payload Too Large` and the 3D body silently never arrived.+`write_file` carries the whole payload as base64 inside the JSON body. Base64 inflates ~33%,+the body cap is ~2.5 MB, and a real `.step` 3D model is routinely 1-10 MB - so staging one+returned `413 Payload Too Large` and the 3D body silently never arrived.  ``` write_file_chunk {path, dataB64, offset, final?, sha256?, createDirs?}   -> {path, offset, bytesWritten, size, nextOffset, final, sha256?, verified?} ``` -Loop: send ~1 MB of **raw** bytes per call, advance to the returned `nextOffset`, set `final:true` on the last chunk. Pass the source `sha256` on that final chunk and ab hashes the reassembly and verifies it for you.+Loop: send ~1 MB of **raw** bytes per call, advance to the returned `nextOffset`, set+`final:true` on the last chunk. Pass the source `sha256` on that final chunk and ab hashes the+reassembly and verifies it for you.  Three properties worth relying on: -- **Stateless.** No handle table, no session id, nothing to leak if a caller vanishes mid-transfer. Each call opens, seeks, writes, closes - which is also why a chunk is safely **retryable**: if you never saw the response, send the same offset again.-- **A gap is refused, never zero-filled.** `offset > size` returns `errorCode: "chunk_gap"` with an `expectedOffset` to resume from. Zero-filling a hole would produce a file of exactly the right length that is silently corrupt - the worst possible outcome for a model someone opens days later.-- **`offset: 0` truncates**, so a restarted transfer never appends onto the remains of a failed one.+- **Stateless.** No handle table, no session id, nothing to leak if a caller vanishes+  mid-transfer. Each call opens, seeks, writes, closes - which is also why a chunk is safely+  **retryable**: if you never saw the response, send the same offset again.+- **A gap is refused, never zero-filled.** `offset > size` returns `errorCode: "chunk_gap"`+  with an `expectedOffset` to resume from. Zero-filling a hole would produce a file of exactly+  the right length that is silently corrupt - the worst outcome for a model someone opens days+  later.+- **`offset: 0` truncates**, so a restarted transfer never appends onto the remains of a failed+  one.  Measured against the real 3.9 MB `s10b-ph-sm4-tb.step`: @@ -116,14 +161,76 @@ single-shot write_file  ->  HTTP 413 "length limit exceeded"⋯ 1 unchanged line ⋯ ``` ----+## The wiki render surface++The wiki renders a page's readme as HTML inside a sandboxed iframe served from+`https://wiki.adom.inc/readme/<owner>/app/<slug>` (verified live). Scripts run+there, so a harness like the one below works from a readme - the `POST` triggers+the Adom Bridge consent prompt, and on Allow the verb runs. (Detection buttons+work regardless; the write button is what pops the prompt.)++## The harness++Runnable standalone (save as `.html` and open it), and the shape we embed in a+readme once the page opts into HTML rendering. `bridge_list` is a harmless verb+that proves the consent-to-verb loop; swap it for `kicad_open_board` etc.++```html+<div id="ab-demo" style="font-family:system-ui,Segoe UI,sans-serif;border:1px solid #30363d;border-radius:10px;background:#0d1117;color:#e6edf3;padding:18px;max-width:760px">+  <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">+    <span id="ab-dot" style="width:12px;height:12px;border-radius:50%;background:#484f58;display:inline-block"></span>+    <strong>Adom Bridge web control demo</strong>+  </div>+  <div style="font-size:12px;color:#8b949e;margin-bottom:14px">This frame's origin: <code id="ab-origin">checking...</code> &middot; API: <code>http://127.0.0.1:24680</code></div>+  <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px">+    <button id="ab-detect">Detect Adom Bridge</button>+    <button id="ab-status">Read status</button>+    <button id="ab-run">Run a verb (asks your permission)</button>+  </div>+  <pre id="ab-log" style="background:#010409;border:1px solid #21262d;border-radius:6px;padding:12px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;margin:0">Ready. Click Detect Adom Bridge to begin.</pre>+  <script>+  (function () {+    var AB = "http://127.0.0.1:24680", log = document.getElementById("ab-log"), dot = document.getElementById("ab-dot");+    document.getElementById("ab-origin").textContent = location.origin || "(null: sandboxed frame)";+    function line(m){ log.textContent += "\n" + m; log.scrollTop = log.scrollHeight; }+    document.getElementById("ab-detect").onclick = function () {+      log.textContent = "GET " + AB + "/health ...";+      fetch(AB + "/health").then(function(r){return r.ok?r.json():Promise.reject("HTTP "+r.status);})+        .then(function(j){ dot.style.background = j.service==="adom-bridge" ? "#00b8b0" : "#e6c86c"; line("RUNNING: " + JSON.stringify(j)); })+        .catch(function(e){ dot.style.background = "#484f58"; line("Not reachable (" + e + ")."); });+    };+    document.getElementById("ab-status").onclick = function () {+      log.textContent = "GET " + AB + "/status ...";+      fetch(AB + "/status").then(function(r){return r.json();}).then(function(j){ line(JSON.stringify(j, null, 2)); }).catch(function(e){ line("failed: " + e); });+    };+    document.getElementById("ab-run").onclick = function () {+      log.textContent = "POST " + AB + "/command {bridge_list} - Adom Bridge will ask YOU to approve ...";+      fetch(AB + "/command", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({command:"bridge_list"}) })+        .then(function(r){ return r.json().then(function(j){ return {status:r.status, body:j}; }); })+        .then(function(res){ line(res.status===200 ? "APPROVED and ran: " + JSON.stringify(res.body).slice(0,300) : "Not approved (HTTP " + res.status + "): " + JSON.stringify(res.body)); })+        .catch(function(e){ line("blocked before ab answered: " + e); });+    };+  })();+  </script>+```++## The gate, in code++`src-tauri/src/direct_api.rs`: `web_gate` (middleware, CORS + read-allow + loopback+Host check) and `web_consent_gate` (the per-request human prompt + per-origin+grant). `resolve_web_approval` (a GUI-only Tauri command) is what the Allow/Deny+buttons call. Full ab-side reference: the `adom-bridge-web-integration` skill. -## Try it+## The full reference implementation -The demo page ships inside ab and is the reference implementation for this whole document - including the chunked upload and the progress rendering:+The harness above is the minimal shape. ab also **ships** a complete demo page that exercises+the whole surface - every verb group, live progress rendering, evidence thumbnails, a part+picker, and the chunked upload above:  ``` http://127.0.0.1:24681 ``` -Its address is also reported at `/status.webControl.demoUrl`. Source: `web-control-demo.html` on this page. Pushing that file to this page's `/files` **is** the deploy - every ab picks it up on its next refresh, with no ab release.+Its address is reported at `/status.webControl.demoUrl`. Source: `web-control-demo.html` on+this page. Pushing that file to this page's `/files` **is** the deploy - every ab picks it up+on its next refresh, with no ab release.