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 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)
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> · 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.