master
John Lauer web-control: document wiki_fetch (signed-in reads, and why the origin lock parses instead of prefix-matching) 220cb9d 2mo ago

Talking to Adom Bridge from a web page

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.

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.

src-tauri/src/direct_api.rs states the commitment in its own header:

…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.

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.

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

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.

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

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, 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: advance to the returned nextOffset each call and 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.

Chunk size depends on your transport, because base64 inflates ~33%. Measured, not assumed:

caller chunk of RAW bytes why
a web page / the direct API (HTTP) ~1 MB body cap ~2.5 MB
the adom-bridge CLI on Windows 16 KB or less the payload rides in ARGV, and Windows caps a command line at 32,767 chars
a container shipping a big file use send_files streams over the relay, no such limit

The CLI ceiling is sharp and it fails before ab is reached: 16 KB raw (~22,000 argv chars) succeeds and verifies; 24 KB (~32,987 chars) does not spawn at all, returning WinError 206. Credit to the fusion-bridge thread for the general warning - they hit the same class from the container side with a 245 KB STEP failing errno 7 (E2BIG) in argv, which is what prompted measuring this properly.

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 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

Private wiki pages: wiki_fetch (ab 2.0.74+)

A web page can already read anything public on the wiki - it just fetches it. What it cannot do is read a page that is private to the user, because the browser holds no Adom session for wiki.adom.inc. ab does. wiki_fetch exposes that one capability:

wiki_fetch {url}
  -> {httpStatus, url, contentType, size, base64}

This is not hypothetical. Three of the ten parts in the demo's picker were private, so the browser got a 404 and the card reported a mystery "blocked" - the page had no way to tell "this file does not exist" from "this file is not yours to read anonymously".

Fetch anonymously first, and fall back to this only when that fails. The demo does exactly that. Anonymous is the default so the user's credential is spent on the reads that genuinely need it, rather than on every read by habit.

The origin lock is the entire security story. Without it this verb is a general credentialed proxy: any approved page could have ab attach the user's bearer token to a request at an attacker's host. So the URL is parsed, and the host must be exactly wiki.adom.inc over https.

Parsing rather than prefix-matching is deliberate and load-bearing:

https://[email protected]/steal

That string begins with https://wiki.adom.inc, yet its host is evil.com. A startsWith guard hands the user's token straight to the attacker; Url::parse resolves the userinfo correctly and refuses it. wiki_fetch_url_tests covers that case along with subdomain lookalikes (wiki.adom.inc.evil.com), plain http, and file://.

The token itself never leaves ab - the caller receives bytes, never the credential. Web callers reach this through the ordinary consent gate, so a human approves it with the URL named in the prompt.

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.

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

The full reference implementation

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