a73x

mux web -A: the hub offers its own agent (phase 2, consent model decided)

open   by a73x

Phase 2 from the agent-forwarding design (decisions.md 2026-08-20): the hub
offers its own `$SSH_AUTH_SOCK`. The consent question is now ANSWERED, so
what is left is code.

**The trust model, decided.** muxweb is local to one machine and one person:
a laptop hub dialling several of that person's own VMs. Every viewer is the
owner, so "which human is driving" — the question `mux -A`'s latest-active
rule answers for the CLI — does not arise. Remote access, if it happens, is
tailscale plus an auth mechanism of its own, never an exposed port; that
design gets to revisit this and does not constrain it now.

**So `-A` is hub-wide and per-run.** One flag on the muxweb command line,
applying to every tile this run serves, argv-named and runtime-added alike,
and NEVER written to the wall file. Posture: `ssh -A` to everything the hub
shows, with `ssh -A`'s usual caveat — a compromised VM can use the keys
while you are connected to it. Per-host granularity is a later mode if that
ever bites.

This keeps the CLI's rule in spirit rather than breaking it. "Tiles hydrated
from the wall never inherit `-A`" exists so that history alone cannot hand a
stranger's host the keys. Under a per-run flag, consent is spoken this run,
explicitly, by the person starting the hub — nothing is restored from a
file. What the wall file holds stays free of key policy, which is also what
keeps `mux wall`'s grammar ("a bare -A is a hostname") true.

**The work:**

1. Hoist the answering half out of `wallview.zig`. `AgentLocal` and
   `storeLocal`/`findLocal`/`closeLocal`/`openAgentChan`/`deliverAgentData`/
   `dropLocals` (`wallview.zig:874-985`), the three frame arms (`:1423-1460`)
   and the reverse drain (`:1507-1550`) are exactly what webhub needs — but
   both modules are layer 4 and `build.zig` requires imports point strictly
   lower, so it cannot be imported where it sits. New layer-1 module taking
   a write-frame callback and an already-connected fd instead of
   `*client.Transport` and `client.connectAgent`; `wallview` moves onto it in
   the same change, so the existing agent e2e covers the move.
2. `webhub.pumpTile`'s poll set: the fixed two entries at `webhub.zig:763`
   become `2 + agent_chans_max` with a slot map, wallview's shape. Reverse
   drain goes AFTER the frame drain, for the reason `wallview.zig:1506`
   writes down.
3. Send the offer. In the CLI the client attaches; here the BROWSER sends
   the attach and the hub transits it, so the hub must watch the type byte
   go past and follow an attach with `agent_offer`, on every dial and
   redial. The hub already parses that byte, so no layering harm — but it is
   the first frame webhub reacts to rather than forwards, and the coupling
   should be named where it is written.
4. Preflight, with no terminal to print to. `mux -A` refuses up front via
   `agentReachable` (`mux_main.zig:117`), a real
   `SSH_AGENTC_REQUEST_IDENTITIES` round trip. The hub needs the same check —
   otherwise it is precisely the silent offerer that check exists to refuse —
   and its refusal has to reach the page as a control message.

**Ordering.** b316df53 (the unanswered-`agent_open` timeout) first: today a
hub that offers and cannot answer is hypothetical, and this issue makes it a
live path. Then 1 as a pure move, then 2-4 together.

Comments

a73x   2026-08-21 14:03

Review pass (fable subagent), re-verified here. One item above is
over-engineered and four pieces of plumbing are missing.

**Item 1 is simpler than written — no callback indirection, no layer-1
module.** The "both are layer 4" observation is right (`build.zig:266` and
`:276`, enforced at `:313`), but the conclusion does not follow: BOTH
modules already import `client` at layer 3 (`webhub.zig:14`,
`wallview.zig:95`), `connectAgent` lives there (`client.zig:733`), and
webhub's transport already IS a `client.Transport`. Hoisting the answering
half into `client.zig` needs no write-frame callback and no fd parameter —
the helpers move as they are. Layer 1 is a purity preference, not a forced
move.

**Missing: local agent-fd cleanup on redial and on tile exit.** wallview
drops its locals first thing in `redial` (`wallview.zig:1012`) and again on
pump exit (`:1162`), centralised deliberately. `webhub.redial`
(`webhub.zig:707`) replaces the transport in place and has no equivalent,
and neither does any of `pumpTile`'s exits — browser death, `removeTile`,
a failed re-dial. Without both, the hub leaks an agent fd per channel per
outage. Items 2-4 do not cover this.

**Missing decision: `drainBrowser` needs an agent-frame filter once the hub
offers.** Today a browser-sent `agent_data`/`agent_close` names no channel
of the hub's and is dropped by the daemon (`server.zig:2436`). Once the hub
OWNS channels, `drainBrowser` forwards them blind (`webhub.zig:688`) and
the ids are sequential — `nextAgentId` increments and only skips ids
currently live (`server.zig:1423`) — so a page could inject into or close
the hub's live agent channels, and unlike keystroke injection it leaves
nothing on screen. Localhost plus the Origin gate bounds who can try it;
the filter is three lines; it should be an explicit decision rather than an
omission.

**Say out loud that hydrated tiles offer.** POSTed tiles persist to the
wall file (`webhub.zig:287`, `:344`), so "argv and runtime-added tiles
alike" means the next `-A` run hydrates them and they offer — the letter of
what the CLI forbids (`decisions.md:4995`, `wallview.zig:116`). The
per-run-flag argument covers it, but the issue should state the
consequence rather than leave it to be discovered.

**Multiple browsers on one tile.** Tile registration is
first-fd-wins (`webhub.zig:368`), so `removeTile` wakes only the tracked
pump; a second browser's transport — and under `-A` its agent channels —
keeps answering agent dials for a tile the user has removed, until that
browser disconnects. Pre-existing (664a2360), but it acquires teeth here.

**Interaction with 2b62dbde.** The ported preflight is unaffected: it dials
the local unix socket, spawns no ssh, needs no tty. But a hub offering a
prompt-gated agent (FIDO touch, pinentry) has no prompt surface either —
the sign request simply hangs, the preflight passes anyway because
listing identities needs no touch, and per the timeout discussion in
b316df53 a SOUND bound must not kill it. So it is undetectable by
construction. The hub could later surface "request written, no reply for
Ns" from its own `deliverAgentData` bookkeeping without parsing a byte of
agent protocol — the same control-message-to-the-page surface item 4 needs
and 2b62dbde needs. One mechanism, three users.