a73x

949cd17b

docs: agent forwarding — opt-in, daemon-blind, latest wins

a73x   2026-08-21 08:33

Commit message
docs: agent forwarding — opt-in, daemon-blind, latest wins

README gets the user-facing paragraph (the daemon owns the socket, so the
tmux staleness disease cannot be expressed; the signer is whoever typed
last; no offerer means a fast refusal, not a hang), plus where -A may be
spelled: the attach form only, since `mux wall`'s grammar reads a bare -A
as a hostname. CLAUDE.md gets the invariant. The design spec records phase
1 shipped; phase 2 (`muxweb -A`) stays future.

decisions.md carries the argument and the numbers, including what the
build changed about the design — the socket moved into a 0700
mux-agent-<pid>-<rand> directory for shellint's reasons, rather than
sitting at 0600 beside the control socket.

The gate: check, test, e2e (58 scenarios / 35 convergence points) and
agent all green. test/xversion.sh does not reach rc=0, and the entry says
so plainly: with old = the previous release, five identity probes fail by
construction — the already-filed "epoch identity pins refuse any post-M18
'old' side" — while all five real compat legs pass, and building that same
old side into both halves of the rig reproduces the five failures with
this branch nowhere in the picture. Against a genuinely pre-M18 old side
(v0.0.1-5) the rig is 9/10, and that one failure reproduces with main as
the new side too.

What the rig cannot say, because its legs never pass -A, was run by hand
against an old daemon: attach works, the session stays interactive,
SSH_AUTH_SOCK is empty and no mux-agent directory appears — with a no-A
control that behaves identically and a new-daemon control that prints the
socket path, so the empty answer is an assertion and not a script that
never ran.

CLAUDE.md
Old New
@@ -66,6 +66,13 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`.
66 falls back to `pgid`/`settle` and there is no real exit code. Every `muxa` 66 falls back to `pgid`/`settle` and there is no real exit code. Every `muxa`
67 reply names the `mechanism` that answered it. 67 reply names the `mechanism` that answered it.
68 - **OSC 52 clipboard READ is refused deliberately.** Not a gap. 68 - **OSC 52 clipboard READ is refused deliberately.** Not a gap.
69 - **Agent forwarding is opt-in (`-A`) and the daemon pumps blind.** The
70 per-session agent socket always exists and overwrites the daemon's own
71 `SSH_AUTH_SOCK` — unset when there is no socket, never inherited. The
72 latest-active `-A` client answers. `muxa` and browsers never OFFER an
73 agent, though anything they run inside a session uses that session's
74 socket like any other process. Frames, never transport; both ends cap
75 a frame at `agent_data_max`.
69 - **The wall file is attach history, written on first state.** 76 - **The wall file is attach history, written on first state.**
70 `client.recordOnState` fires when the daemon's first state arrives and never 77 `client.recordOnState` fires when the daemon's first state arrives and never
71 on a successful dial, so a refused attach cannot strand a line — which is why 78 on a successful dial, so a refused attach cannot strand a line — which is why
README.md
Old New
@@ -140,6 +140,41 @@ If the link drops, the client reconnects and resumes by itself; a bare
140 `Ctrl-\` gives up waiting — while reconnecting there is no session to 140 `Ctrl-\` gives up waiting — while reconnecting there is no session to
141 command, so it is not a prefix there. 141 command, so it is not a prefix there.
142 142
143 **Your keys on the remote:** `mux -A HOST` forwards this client's ssh-agent
144 into the session, mirroring `ssh -A` — and mirroring its threat model, which
145 is why it is the same opt-in flag: while you are attached, root on the remote
146 can *use* your keys, though not read them. What differs is who owns the
147 socket. Every session is born with an agent socket the **daemon** owns, so
148 the `SSH_AUTH_SOCK` a shell was started with stays valid for that session's
149 whole life; the tmux disease — a socket that goes stale on every reconnect,
150 patched over with `update-environment` and symlinks — cannot happen here,
151 because the socket outlives the connections that answer for it. Who signs is
152 whoever typed last among the attached `-A` clients, decided afresh for each
153 connection the agent makes, so `git push` on the remote uses the keys of the
154 person driving. Detach and nothing dangles: with no `-A` client attached the
155 daemon accepts the dial and closes it, and ssh reports the agent refused it
156 straight away instead of hanging. The daemon never reads the traffic it
157 carries — it pumps bytes between the session and your machine's agent — so
158 policy stays in the agent, `ssh-add -c` confirmations included.
159
160 **What this changes for local sessions:** the daemon's socket *replaces*
161 whatever `SSH_AUTH_SOCK` muxd itself was started with, in every session it
162 spawns. Before agent forwarding, a locally auto-started muxd passed its own
163 inherited agent straight through, and `git push` inside a local session
164 worked with no flag at all; now it finds an agent that answers only while an
165 `-A` client is attached, and refuses otherwise. This is deliberate — a
166 session pointed at the daemon's agent reaches past the client watching it,
167 and every client would share one identity — but it does mean local sessions
168 need `mux -A --sock PATH` (or `mux -A` on the auto-started daemon) where
169 they previously needed nothing.
170
171 `-A` goes on the attach form (`mux -A HOST`, `mux -A quic://HOST`,
172 `mux -A --sock PATH`), not on `mux wall`, which refuses a flag where a
173 target belongs. Sibling tiles grown from an `-A` attach by chord (`Ctrl-\ c`,
174 `n`, `p`) inherit it — same daemon, same host, nothing newly exposed — while tiles
175 restored from the saved wall never do. `muxa` never forwards, under any
176 flag.
177
143 ## Quick start, remote over QUIC 178 ## Quick start, remote over QUIC
144 179
145 No ssh in the path; needs inbound UDP to the host. Authentication is a 180 No ssh in the path; needs inbound UDP to the host. Authentication is a
docs/decisions.md
Old New
@@ -4942,3 +4942,193 @@ mouse-mode `inline for` drift tests are untouched.
4942 `make test` and `make e2e` all green with the e2e pin still at 55 scenarios / 4942 `make test` and `make e2e` all green with the e2e pin still at 55 scenarios /
4943 35 convergence points. Recovery, if a whole-terminal client is ever wanted 4943 35 convergence points. Recovery, if a whole-terminal client is ever wanted
4944 again: git remembers it, at the commit this entry ships in. 4944 again: git remembers it, at the commit this entry ships in.
4945
4946 ## 2026-08-20 (ssh agent forwarding: the daemon owns the socket)
4947
4948 `mux HOST` uses ssh as a bootstrap only — after the QUIC upgrade there is no
4949 ssh process left, so `ssh -A` has nothing to forward through in steady state
4950 and `git push` on the remote fails. That was the last named blocker to
4951 replacing tmux as the daily driver. tmux has the dual disease: the socket
4952 exists but goes stale on every reconnect, which is what `update-environment`
4953 and the symlink hacks are patching over. Both failures have one root — the
4954 process that owns the agent socket dies with the connection.
4955
4956 **The decision: muxd owns a stable agent socket per session, and clients that
4957 opted in answer for it.** The socket outlives every connection, so the
4958 `SSH_AUTH_SOCK` a shell was born with is valid for that shell's whole life
4959 and the staleness disease cannot be expressed. Who answers changes with
4960 attachment, invisibly to the shell. Four consequences, each argued below: the
4961 bytes ride **frames, not transport**; routing is **latest-active offerer**;
4962 the daemon **pumps blind**; and it is **opt-in**, because the threat model is
4963 `ssh -A`'s threat model exactly.
4964
4965 **Frames, not transport** (`agent_offer` 0x0d, `agent_data` 0x0e,
4966 `agent_close` 0x0f client-side, `agent_open` 0x92 daemon-side). Only the
4967 daemon opens channels, so ids need no opener scoping; `agent_data` and
4968 `agent_close` are bidirectional. This keeps the invariant that `proxy.zig`
4969 and the QUIC modules carry opaque bytes — forwarding works over ssh, QUIC and
4970 `--sock` alike because none of them knows it happened. `agent_data` is a u32
4971 LE id followed by at most `agent_data_max` = 4096 opaque bytes; a daemon
4972 holds at most `max_agent_chans` = 8 live channels, a tile the same.
4973
4974 **The daemon pumps blind.** It parses the id prefix and nothing else — never
4975 the agent protocol. That is what keeps policy where policy belongs: `ssh-add
4976 -c` confirmations still fire on the machine holding the key, and mux has no
4977 opinion of its own to drift out of date. Agent-protocol filtering and
4978 key-confirmation prompts were considered and refused for the same reason.
4979
4980 **Latest-active offerer, decided once per connection.** `Server.agentAnswerer`
4981 ranks the session's `-A` clients by the same `activity` counter the grid
4982 follows (bumped by attach, input and resize), so the signer is whoever typed
4983 last — the person driving. It is called once per *accepted connection*, not
4984 per frame: an in-flight exchange that swapped identities under ssh would fail
4985 the signature rather than move it. With no offerer the daemon accepts the
4986 dial and closes it immediately, which ssh reports as a refusal instead of a
4987 hang.
4988
4989 **Opt-in, per attach, per tile.** `-A` is a flag on the attach form only —
4990 `mux wall`'s grammar reads a bare `-A` as a hostname, and the wall is a view,
4991 not an attach. Sibling tiles grown by chord (`Ctrl-\ c`/`n`/`p`) inherit the
4992 flag: same target, so nothing is exposed that the user has not already
4993 exposed to that host, and a chord-made tile has no command line to spell the
4994 flag on — not inheriting would end forwarding silently at the first session
4995 switch. Tiles hydrated from the wall file never inherit: the user never named
4996 them in this session, and a stranger's host must not be handed keys by
4997 history. `muxa` has no `-A` at all — agents do not wield the user's keys, the
4998 same posture as the OSC 52 clipboard-read refusal. `muxweb -A` (the hub
4999 offering its own `$SSH_AUTH_SOCK`) is phase 2, not built.
5000
5001 **What the build changed about the design.** The spec put
5002 `agent-<session>.sock` at 0600 directly beside the control socket. Shipped
5003 instead: a 0700 `mux-agent-<pid>-<rand>` directory holding
5004 `agent-<name>.sock` per session, for the reasons `shellint.install` already
5005 wrote down — that parent is a shared `/tmp` whenever `$XDG_RUNTIME_DIR` is
5006 unset, a pid alone is guessable, and an entry pre-created there by another
5007 user as a symlink would put this daemon's sockets somewhere it does not own.
5008 Failure to make the directory degrades to null and says so on stderr rather
5009 than failing the daemon: a session with no forwarded agent is a working
5010 session, and from inside the shell an absent `SSH_AUTH_SOCK` looks exactly
5011 like a client that never asked to forward one.
5012
5013 **Compatibility.** The offer is re-armed on *every* attach through the single
5014 `sendAttach` funnel in `wallview.zig`, because a redial lands on a fresh
5015 daemon-side slot that remembers no offer. The payload is empty and the frame
5016 type is new, so a daemon too old to know it skips it: a new client with `-A`
5017 against an old daemon attaches normally and forwards nothing.
5018
5019 That was witnessed, not argued — but **not by `test/xversion.sh`**, and the
5020 distinction is worth writing down. The rig's legs never pass `-A`, so what
5021 they witness is "a new client drives an old daemon" (both transports, green
5022 against the previous release) and not "a new client that *sends*
5023 `agent_offer` drives a daemon with no arm for it". The missing half was run
5024 directly, new client against a v0.0.1-11-era daemon, `ptyclient` on a real
5025 pty: attach works, the session is interactive afterwards (two typed markers
5026 echo back), `SSH_AUTH_SOCK` is empty, and no `mux-agent-*` directory is
5027 created. Bracketed both ways — the same script without `-A` behaves
5028 identically, so the flag changes nothing on that pairing, and the same script
5029 against a *new* daemon prints
5030 `AUTHSOCK=[…/mux-agent-<pid>-<rand>/agent-0.sock]`, which is what makes the
5031 empty answer an assertion rather than a script that never ran.
5032
5033 **Measurements.** The e2e suite goes 55 → 58 scenarios (35 convergence points
5034 unchanged), across three legs: a real `ssh-agent` and a generated key
5035 crossing the wire to a real `ssh-add -l`; a keyless session refusing; and the
5036 signer flipping to whoever typed last. The refusal leg is the one with a wall
5037 clock on it — attach, two 400 ms settles, the `agtsock` round trip, shell
5038 startup and the refusal measured **816–837 ms** across three grading runs,
5039 held under a 10 s ceiling (~12x headroom, matching the number the
5040 key-mismatch leg picked for the same job). The ceiling's honest band is
5041 narrow and the comment says so: a refusal that *hung* is caught by `waitexit`
5042 as an exit code, so what the clock owns is only a refusal that came back
5043 after seconds of retrying. Refusing, `ssh-add` returned **1** (`error
5044 fetching identities: communication with agent failed`) — the answer a socket
5045 that accepts and closes gives. Not pinned, openssh's internals; but the
5046 distinction is load-bearing, because a session with no `SSH_AUTH_SOCK` at all
5047 returns 2 (`Could not open a connection to your authentication agent`)
5048 instead, and for a while that 2 was what this leg was measuring: the positive
5049 leg's `exit` takes its daemon down with its last session, so the refusal
5050 leg's client was auto-starting an *installed* `muxd` off `$PATH` — v0.0.1-10,
5051 no agent code in it — and passing vacuously against it. The leg now starts
5052 its own daemon and pins `agtsock=present` before typing `ssh-add`, which is
5053 the needle only a daemon that bound an agent socket can answer, and the suite
5054 puts the build's own bin directory first on `$PATH` so no future accidental
5055 auto-start can grade a release again. Mutation testing on `agentAnswerer` caught both attempts
5056 to break routing: pinning to the first offerer, and deciding once per daemon.
5057
5058 `test/xversion.sh` itself does not reach rc=0 for this delivery, and the
5059 reason is the rig's, not the feature's — the limitation already filed as
5060 "xversion: epoch identity pins refuse any post-M18 'old' side". With
5061 old = the previous release, five identity probes fail by construction (they
5062 pin the old side by the *absence* of `sessions=`, `term_modes`, `term_title`
5063 and `term_event`) while all five real compat legs pass; building the same
5064 old side into both halves of the rig reproduces those five failures
5065 byte-for-byte with this branch nowhere in the picture. With a genuinely
5066 pre-M18 old side (v0.0.1-5, built from a `git archive` export) the rig goes
5067 9/10, and the one failure — new client to old daemon over a socket, "connection
5068 to muxd lost" — reproduces identically with `main` as the new side. So:
5069 no cross-version regression is attributable to agent forwarding, and the
5070 gate's own vintage pins are what stand between it and a green run.
5071
5072 **Known limitations, recorded rather than fixed.** A SIGKILLed daemon leaks
5073 its `mux-agent-*` directory — the same lifecycle as `mux-shellint-*`, and the
5074 same trade. The redial re-offer is structural (one funnel, so it cannot be
5075 forgotten) but has no dedicated e2e leg. The reverse pin — a channel already
5076 open staying with the client it opened on when activity flips — is asserted
5077 in unit tests only; end to end it would need a signing operation slow enough
5078 to flip activity underneath it. And the `-A`-against-an-old-daemon check
5079 above was run by hand: codifying it needs the rig to take client flags per
5080 leg, which is the same change the filed epoch-pin issue wants.
5081
5082 **The generalization we did not build.** Port forwarding (`-L`/`-R`) is the
5083 known next step and this frame vocabulary is its embryo: the same
5084 open/data/close shape with a target in the open payload, never an ssh side
5085 channel (steady state has no ssh, and pure `quic://` targets never had one).
5086 What it adds that agent traffic does not need is opener-scoped channel ids —
5087 a `-L` client opens channels, whereas today only the daemon does, and SSH's
5088 each-side-names-its-own model retrofits onto these frames without breaking
5089 them — plus per-channel flow control, so a bulk transfer cannot
5090 head-of-line-block a repaint. Nothing shipped here assumes their absence.
5091
5092 **Review round: what the review found and what it changed.** Five follow-ups
5093 and four nits, all taken. Two were behaviour, and both had the same shape —
5094 a rule that only one side of the wire was keeping.
5095
5096 `agent_data_max` had been a send-side buffer size and nothing more: the
5097 receive path checked no length, so a peer could frame 16 MiB (the generic
5098 `max_payload`) and both ends would hand the lot to a blocking `writeAllFd` —
5099 the daemon stalling every session, the client freezing the tile. The
5100 blocking write's own rationale, that agent traffic is small, is a property
5101 of the senders we ship rather than of anyone who might dial. `proto.agentDataOversize`
5102 is now checked at both ends and answered by closing the channel: dropping
5103 the frame would leave the agent stream short of bytes its far end is still
5104 waiting on, and a truncated signature request is worse than a hangup. This
5105 matters twice over because port forwarding inherits the receive path.
5106
5107 `SSH_AUTH_SOCK` was set only when there WAS a socket, so every failure path
5108 of `makeAgentDir`/`bindAgentSock` fell through to the daemon's own inherited
5109 agent — the exact state the arm's comment forbids, reached where nobody
5110 looks. `Pty.EnvPair.value` went optional so "no socket" can mean unset
5111 rather than empty (an empty value is still a socket to ssh, which then fails
5112 on it instead of falling through). The test gives the daemon an agent of its
5113 own first: without one it passes whatever the code does, which is the same
5114 vacuous-leg trap this branch already hit once in the e2e.
5115
5116 The rest were visibility and coverage. The per-tile consent gate had no test
5117 — the refusal leg's client has no agent, so the channel is refused at the
5118 dial whatever the gate says, and both gates could be deleted under a green
5119 suite; `openAgentChan` came out of the pump to get a seam a test can reach,
5120 and the test binds a real listening agent socket so `offered` is the only
5121 variable. Daemon-side refusals were invisible: eight long-lived channels can
5122 turn forwarding off for every session with nothing anywhere to say why, so
5123 both reasons are counted and a full table says so once per episode rather
5124 than once per dial. The channel cap now lives in `protocol.zig` beside
5125 `agent_data_max` — a client table bigger than the daemon's has slots nothing
5126 can fill, smaller refuses channels the daemon believes it opened. And
5127 `mux wall -A host` is a usage error instead of a tile for a host named `-A`.
5128
5129 The user-visible cost, which the README had not stated: a locally
5130 auto-started muxd used to pass its own inherited agent through, so `git push`
5131 in a local session worked with no flag. The daemon's socket now overwrites
5132 it, and a local session needs `-A` where it previously needed nothing. The
5133 overwrite is the point — otherwise every client shares one identity — but it
5134 is a regression for one workflow and is now documented as one.
docs/superpowers/specs/2026-08-20-agent-forwarding-design.md
Old New
@@ -1,6 +1,9 @@
1 # SSH agent forwarding: the daemon owns the socket, frames own the bytes 1 # SSH agent forwarding: the daemon owns the socket, frames own the bytes
2 2
3 Date: 2026-08-20. Status: design agreed in discussion; not yet commissioned. 3 Date: 2026-08-20. Status: **phase 1 (CLI) shipped 2026-08-20** — frames,
4 daemon socket and routing, client pump and `mux -A`, under nine tasks and
5 three e2e legs; see `docs/decisions.md` for what the build changed about
6 this design. Phase 2 (`muxweb -A`) remains future.
4 7
5 ## Problem 8 ## Problem
6 9