23776a3b
docs: the answer clock, its two rejected anchors, and the hub-owned frames
a73x 2026-08-22 17:46
Commit message
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. | ||