949cd17b
docs: agent forwarding — opt-in, daemon-blind, latest wins
a73x 2026-08-21 08:33
Commit message
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 | ||