a73x

23776a3b

docs: the answer clock, its two rejected anchors, and the hub-owned frames

a73x   2026-08-22 17:46

Commit message
docs: the answer clock, its two rejected anchors, and the hub-owned frames

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

docs/decisions.md
Old New
@@ -5270,3 +5270,69 @@ overridable so a slower box can relax them without deleting the target.
5270 `yes` cannot see any of this — one-character rows are nearly free to render, 5270 `yes` cannot see any of this — one-character rows are nearly free to render,
5271 which is why the regression this branch fixed was invisible to the first two 5271 which is why the regression this branch fixed was invisible to the first two
5272 legs (5ms vs 6ms) and obvious to the third (87ms vs 479ms). 5272 legs (5ms vs 6ms) and obvious to the third (87ms vs 479ms).
5273
5274 ## 2026-08-22 (an offer nobody can answer is hung up on)
5275
5276 The `-A` preflight (2026-08-21) fixed "offering is a declaration, not a
5277 capability" at one call site, client-side. The daemon still took every
5278 client's word for it, and what that produced was the exact failure the
5279 "refuse fast" rule exists to prevent. Measured, ssh against a unix socket
5280 that accepts and never replies vs. one that accepts and closes:
5281
5282 silent agent ssh-add -l blocked past an 8s timeout (exit 124)
5283 accept+close "communication with agent failed" in 2ms (exit 1)
5284
5285 So a bogus offerer that out-ranks a working `-A` client does not degrade
5286 forwarding, it wedges it — `git push` on the remote hangs instead of falling
5287 through to the next auth method — and eight such dials fill
5288 `agent_chans_max`, at which point the real offerer is refused too.
5289
5290 **The courteous path already existed, and is the reason the fix is small.**
5291 An honest client answers `agent_open` it cannot serve — no `SSH_AUTH_SOCK`,
5292 a stale path, a full local table — with `agent_close`, the daemon closes
5293 the fd, and ssh reads the refusal in microseconds. What remained was the
5294 peer that does not speak at all: a wedged client, a third-party one, or a
5295 browser tab that sent `agent_offer` through a hub that transits any type.
5296
5297 **The clock runs from the first forwarded request to the first reply, and
5298 never again.** Two anchors were rejected, each pinned by a test watched to
5299 fail under the mutation:
5300
5301 - Not from the open. The daemon announces `agent_open` the moment ssh
5302 connects, but until ssh writes a request the client has nothing to
5303 answer. A clock on the open hangs up on a working client for ssh's pause.
5304 - Not per request. One reply proves the peer speaks for an agent; a later
5305 SIGN may wait on a hardware token for as long as the human takes to
5306 touch it. The preflight's rule stands: the bound must never separate
5307 slow from refused.
5308
5309 5s, not the preflight's 500ms: that bound sat on a client-local socket, this
5310 one crosses the link. Still well short of the 8s wedge, and the close the
5311 daemon makes is the same `close(fd)` the courteous path ends in, so ssh
5312 sees no difference. Expiry also clears the client's `agent_offer` — without
5313 that the next dial routes to the same mute peer and every ssh pays the
5314 bound. A field on Server, not a const, so the unit tests set it to 150ms
5315 rather than wait; not env-overridable, because it is a judgement about the
5316 product rather than a gate calibrated to a box, and nothing in `make ci`
5317 depends on it.
5318
5319 **Deliberately unfixed: a peer that answers once and then goes mute.** It
5320 wedges every later SIGN. Clocking later requests is exactly the
5321 hardware-token case above, and the issue scopes this as robustness, not an
5322 escalation — a peer in a position to out-rank the real offerer has already
5323 passed the Origin gate and can inject keystrokes into every tile, which is
5324 strictly more. Leave it. Likewise a reattach restores the offer: `agent_offer`
5325 is re-sent after every attach by design, and the arm that lands it does not
5326 care whether the sweep took one away — a reattach may bring a working agent,
5327 and a mute one now costs one bound per attach rather than per dial. And only a
5328 REPLY proves: bytes the client sends before any request was forwarded leave
5329 the clock armed, or a mute peer could clear it by talking first.
5330
5331 **The hub keeps the three agent frames for itself.** `muxweb -A` (next, the
5332 issue after this one) offers the agent on the HUB's machine; the browser
5333 never holds a key, so every agent frame on the daemon leg is the hub's own.
5334 `parseFrameMessage` now refuses `agent_offer`/`agent_data`/`agent_close`
5335 from a tab as one named exception, which leaves "unknown types transit"
5336 exactly as wide as it was. Before `muxweb -A` this closes the silent-offerer
5337 hole at its source; after it, it is what stops a tab injecting `agent_data`
5338 into a channel the hub owns.