← Commit history

docs: web-control sub-readme - there is ONE verb surface, not two; the four places the web path actually differs; and the one honest redundancy (send_files vs write_file_chunk)

John Lauer ·193d6a81d7 ·2mo ago ·parent 9ec7145
1 file changed +127−105
docs/web-control.md+127−105
@@ -1,107 +1,129 @@-# Talking to Adom Bridge from a web page--A page served from `https://wiki.adom.inc` can reach into the copy of Adom Bridge-running on the visitor's own machine, over Adom Bridge's loopback HTTP API at-`http://127.0.0.1:24680`. This is how the wiki can, for example, detect that Adom-Bridge and KiCad are both present before offering an "Open in KiCad" button.--## What a web page can and cannot do (read this first)--If any website could reach the loopback API, a malicious page could drive your-computer. So the web-reachable surface is deliberately tiny:--- **Allowed from the web, today:** `GET /health` and `GET /status`. Both read-only:-  liveness, version, which bridges are installed.-- **Refused from the web, today:** `POST /command` (the verb-running endpoint) is-  hard-refused for every web origin with HTTP 403 `web_forbidden`. No web page can-  change your machine.-- **Only `https://wiki.adom.inc` gets in at all.** Adom Bridge returns the CORS-  headers that let the browser read the response only when the request's `Origin`-  is exactly `https://wiki.adom.inc`. Any other site gets nothing.--Actually running a verb like `kicad_open_board` from a web button is a planned next-phase: it will require a native Adom Bridge consent dialog (you approve the specific-action in Adom Bridge's own window, which no web page can script) layered under a-per-user cloud-key handshake. Until that ships, a web page can *detect* that it-could, and is *walled off* from doing it. That wall is the feature.--## The security gate--Implemented in `src-tauri/src/direct_api.rs` (`web_gate` middleware). Every layer-must pass:--1. **Exact origin allowlist.** CORS headers only for `Origin: https://wiki.adom.inc`-   (never `*`). The browser sets `Origin`; page JS cannot forge it.-2. **Never `*.adom.cloud`, never a wildcard.** Any Adom user can be handed an-   `*.adom.cloud` subdomain, so allowlisting that would let a nefarious user's page-   drive machines. The allowlist is exactly one first-party origin.-3. **Loopback Host check** (anti-DNS-rebind): the `Host` header must be a loopback-   literal.-4. **Private Network Access preflight:** a public-to-loopback fetch triggers the PNA-   preflight, answered only for the allowlisted origin.-5. **Path scope:** a web origin may reach only `/health` and `/status`; everything-   else, including `/command`, is `web_forbidden`.--The fixed loopback port is `24680` (reserved, stable across reboots). Browsers treat-`http://127.0.0.1` as a trustworthy secure context, so an HTTPS wiki page may fetch-it with no mixed-content error.--## The interactive harness (and why it is not embedded live yet)--The natural demo is a small panel on this page with Detect / Read-status / Try-open--KiCad buttons that call the API and print the result. **The blocker is not the-security gate; it is the wiki renderer.** Verified live 2026-08-12 by driving the-real browser on a machine running Adom Bridge to the rendered page: the page's own-origin is correctly `https://wiki.adom.inc` (so the gate would allow it), but the-markdown renderer **strips raw HTML** from a README, and an ` ```html ` fenced block-renders as a **syntax-highlighted code block**, not a live iframe. Neither path-executes a `<script>`. So a live in-page harness needs a wiki-side "live HTML embed"-mechanism (a sandboxed iframe with `allow-scripts` AND a `wiki.adom.inc` origin, or-an equivalent widget block). That is a wiki-platform capability, filed to the wiki-thread rather than worked around here.--Below is the exact harness we would embed the moment such a block exists. It is-also runnable standalone: save it as an `.html` file and open it from any-`https://wiki.adom.inc`-served context.--```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-kicad">Try to open KiCad (should be blocked)</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 + "). Not running here, or this origin is not https://wiki.adom.inc."); });-    };-    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-kicad").onclick = function () {-      log.textContent = "POST " + AB + "/command {kicad_open_board} - expecting REFUSED ...";-      fetch(AB + "/command", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({app:"kicad", command:"kicad_open_board", args:{}}) })-        .then(function(r){ return r.json().then(function(j){ return {status:r.status, body:j}; }); })-        .then(function(res){ line((res.status===403||(res.body&&res.body.errorCode==="web_forbidden")) ? "REFUSED, as designed: HTTP " + res.status + " " + JSON.stringify(res.body) : "UNEXPECTED - NOT refused: " + JSON.stringify(res)); })-        .catch(function(e){ line("Browser blocked the POST before ab answered (" + e + ") - also a refusal for a non-allowlisted origin."); });-    };-  })();-  </script>-</div>+# Web Control: how a web page drives Adom Bridge++**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.++The rest of this page is the precise version of that, and the four places the two paths genuinely differ.++---++## The one surface++`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.**++So 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()          ──┘+```++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.++---++## 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.++### 2. Four verbs need the CLI (`cliRequired`)++```+pull_file      streaming binary frames+send_files     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.++**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++Both paths are gated; they are gated *differently*, because the trust question differs.++| 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 |++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.++### 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.++---++## So is anything redundant?++One thing, honestly, 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.++`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_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.++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.++Measured against the real 3.9 MB `s10b-ph-sm4-tb.step`:++```+single-shot write_file  ->  HTTP 413 "length limit exceeded"+write_file_chunk        ->  4 chunks, 0.9s, 3,921,735 bytes, sha256 verified+```++---++## Try it++The demo page ships inside ab and is the reference implementation for this whole document - including the chunked upload and the progress rendering:++```+http://127.0.0.1:24681 ``` -For the full ab-side reference, see the `adom-bridge-web-integration` skill.+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.