app
Adom Bridge
Public Made by Adomby adom
Adom Bridge unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridge and its bridges are an operating system for AI, with managed Node and Python runtimes, and a human-onl
← 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
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> · 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.