fix(cli): the terminal asks the daemon before it asks you for the user-action key (SD-11 follow-up) - #261
Merged
Merged
Conversation
…ey (SD-11)
`POST /agent/cancel`, `POST /interrupt`, `POST /agent/continuation/abandon`
and `POST /agent/continuation/recover` began with an unconditional
`is_user_action` check. A daemon started with no user-action key — every
`biorouter serve` daemon (SD-7), and a hand-run `biorouterd` — can never pass
it, so the browser's Stop button, mid-turn steering and Stop-and-Send answered
403 on every chat, public ones included (measured 2026-09-11 from the page).
On such a daemon the four routes now gate through `authorize_agent_control`,
the same call `/agent/stop` makes there: the reach rule, then no subagent's
chat. The refusal protected nothing — `/agent/stop` already cancels the same
turn for the same caller, `workspace_close { scope: "turn" }` does it for a
model, and `/reply` already puts the caller's text in front of the chat.
- A keyless steer is recorded unstamped, never `UserDirect`: nothing on that
daemon can establish that a person typed it, and `/reply` records the same
caller's message the same way.
- A daemon that holds a key (the desktop app's) is unchanged: proof only.
- A subagent's chat stays refused on a keyless daemon, and
`refuse_subagent_unless_user` now says the daemon, not the caller, is what
cannot prove a person acted (SD-8).
`tests/turn_control_no_user_key.rs` is its own binary because the digest is
a process-global OnceLock. All seven of its tests fail on the previous code;
one of them pins the premise (keyless `/agent/cancel` admits exactly what
keyless `/agent/stop` admits). The session_reach ordering census gains rows
for the three routes and new over-read controls in reply.rs, since
`interrupt` is no longer ungated.
Records the ruling the previous commit implements: on a daemon that holds
no user-action key, /agent/cancel, /interrupt and the two continuation
routes admit exactly the callers /agent/stop already admits there, a
subagent's chat excepted; a keyless steer is unstamped; a daemon that
holds a key is unchanged.
The record answers privacy-tiers §3.1's two questions in writing, and its
Q2 table records one finding rather than a guard: POST
/active_work/{id}/cancel cancels a subagent by registry id with no gate on
any daemon (the residual session_reach.rs already records).
- programmatic-session-access.md: the three routes honour the capability
header on a keyless daemon; the "different instrument" row now says the
proof applies on a daemon that holds a key; the troubleshooting entry no
longer claims a capable caller never sees a "no user-action key" refusal
(a subagent's session produces one).
- browser-access.md: Stop, steering and Stop-and-Send work in an ordinary
browser chat; a subagent's tab is read-only and does not yet say so.
- CLAUDE.md: an SD-11 bullet in "Browser access".
Numbered SD-11 because open PRs #226 and #229 both claim SD-9.
…s on it # Conflicts: # docs/deployment/serve-decisions.md
… follow-up) `session cancel`, `attach` and `send` demanded the user-action key from the terminal before they sent anything, so against a daemon that holds no key (`biorouter serve`, a hand-run `biorouterd agent`) they refused locally the stop and steer SD-11 now admits there, and asked every `serve` user for a key that does not exist. A terminal cannot tell the two kinds of daemon apart, but the daemon's answer can: on the turn-control routes a daemon that holds a key refuses an unproven request with an EMPTY 403, and one that holds none refuses only in its own words. So each command now sends its request without the proof (unless the key was supplied with --user-action-key-stdin), asks the person for the key only on that empty 403, and makes the request once more with it. A refusal that carries a sentence is printed, since no key would change it. - attach settles the key as it joins, with an empty steer: the gate answers before /interrupt reads the text, and empty text is refused before anything is touched. It cannot wait for the first real steer, because the stdin reader then holds stdin's lock and a hidden prompt would block on it, or race the reader and hand the key to the session as a message. - send asks the same question after a refused /reply, whose own 403 for a subagent is empty on either kind of daemon. - A wrong key, typed or supplied, is reported as wrong and never re-prompted. Nothing is relaxed: the daemon stays the boundary, every refusal read here is given before the route touches the turn, subagent sessions still need the proof, and the raw key still comes only from the terminal or stdin. It is now sent only when supplied or asked for.
…action key `biorouter session` now decides whether to ask a person for the user-action key from the shape of a turn-control refusal: an empty 403 means "this daemon holds a key", and a sentence means no key would help. Pin both halves from the daemon's side, so a change to either arm cannot silently stop the terminal prompting on the desktop's daemon, or make it prompt a `serve` user for a key that does not exist: - keyed (routes::reply integration_tests): an unproven stop or steer, empty text included, is a 403 with an EMPTY body, answered before the text is read; with the proof the empty steer is a 400; nothing touches the turn. - keyless (tests/turn_control_no_user_key.rs): the empty steer the terminal sends is a 400 where it may steer and otherwise a refusal carrying the daemon's sentence, never an empty 403, and it touches neither the turn nor the agent's queue.
… key Update workspace-control.md's user-action-key paragraph (it said attach and cancel prompt before steering, and that send needs no proof, while send prompted too), SD-11's CLI row and its "Not decided here" paragraph (now "The terminal, since"), the CLI command reference (which never mentioned the key or --user-action-key-stdin), the subagents page, and CLAUDE.md's SD-11 bullet with the refusal-shape invariant the CLI now depends on.
Measured against a real daemon holding a user-action key: hyper answers an empty 403 with `transfer-encoding: chunked` and a lone `0\r\n\r\n` terminator, and `post_json` handed the framing back as the body. The terminal read "0" as the daemon's refusal sentence, so `session cancel` printed "the daemon would not stop the turn: 0" and never asked for the key the daemon was waiting for — the whole point of that empty refusal. `post_json` and `daemon_json_request` now share one response parser, which dechunks as the latter already did. The fake daemon in the tests frames its empty 403 the way the real one does, since a `content-length: 0` fixture passed every test while the real thing was misread.
`KeyUse`, `KeyVerdict`, `key_verdict` and `refusal_sentence` have no reader outside `session_watch`, so they are private to it. The table in `the_person_is_asked_for_the_key_only_after_the_daemon_wants_it` becomes a named `Case` struct: its five-part tuple tripped `clippy::type_complexity`, and the field names say what each column is.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
…11, not the draft `the_terminals_empty_steer_is_answered_by_the_gate_and_touches_nothing` failed on the merged tree — `test (ubuntu-latest)` on #275, `turn_control_no_user_key.rs:625`, `9 passed; 1 failed`. Neither side is broken; the test encodes a ruling that changed under it, and only an integration binary could see it (CI runs `tests/*.rs` on ubuntu only, so #261 and #240 were both green apart). #261 was written when SD-11 read "Stop **and steering** work on a daemon with no key": `/interrupt` would pass the reach gate, so an empty steer reached the text check and answered **400** for a chat the gate admitted. SD-11 then settled narrower — `/interrupt` asks for the proof on BOTH kinds of daemon, because `/reply` is refused 409 by the BR-33 single-turn lock in exactly the state where a steer lands, so admitting it would add silent mid-turn injection that nothing else there can do. `reply::steer_refusal` therefore answers from the HEADERS, before the body is parsed and before any chat is resolved: every row is a 403 carrying `STEER_NO_KEY`. What the terminal needs is unchanged, and is what the test still asserts: never the EMPTY 403 that means "this daemon holds a key" (which would send a `serve` user to find one that does not exist), always a sentence it can print instead (`key_verdict` → `Refused`), and the question touches neither the turn nor the agent's queue — now trivially, since nothing is reached. The five rows are kept rather than collapsed, so a change that admits the steer for SOME chat fails here instead of passing quietly. Also sharpened `steer_gate_question`'s doc in the CLI: it listed all three answer shapes correctly but let a reader expect the 400 from a keyless daemon, where it is now unreachable.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SD-11 made a daemon that holds no user-action key admit
POST /agent/cancel,POST /interruptand
/agent/continuation/*for any caller the reach gate admits, a subagent's session excepted. Theterminal had not caught up:
session cancel,attachandsenddemanded the key before they sentanything, so against
biorouter servethey refused locally the very requests SD-11 admits — andasked every
serveuser for a key that does not exist. It is the browser's defect, one client over.What changes
A terminal cannot ask a daemon whether it holds a key, so it lets the daemon answer. Each command
sends its request without the proof — unless the person supplied it with
--user-action-key-stdin— and asks for the key only when the answer is the one refusal that means"this daemon holds a key": turn control's empty 403. Then it sends once more, with the key. A
refusal that carries a sentence, which is every refusal a keyless daemon gives on these routes, is
printed instead, because no key would change it.
attachasks as it joins, with an empty steer:/interruptjudges who may steer before itreads the text, and refuses empty text before it touches the turn or the agent's queue. It cannot
wait for the first real steer — by then the stdin reader holds stdin's lock for its whole loop, so
a hidden prompt would block on that lock, and without it the typed key would race the reader and
could be delivered to the session as a message. The key is also checked there, before anything
is typed, instead of at the first steer.
sendasks the same question after a refused/reply, whose own 403 for a subagent's sessionis empty on either kind of daemon and so cannot say which kind this is. On a daemon that holds a
key,
sendinto an ordinary chat now needs no key at all — which is what the docs always claimed.Nothing is relaxed. The daemon stays the boundary; every refusal read here is given before the
route touches the turn, so making the request again cannot deliver anything twice; a subagent's
session still needs the proof, and a keyless daemon still refuses it. The raw key still comes only
from the terminal or stdin — never argv, environment, config or logs — and is now sent only when the
person supplied it or a daemon asked for it. The local refusal it replaces was never a boundary,
only a guess about which daemon was on the other end.
What this does not change
watchandattach --read-onlystill never send the key, so a private chat is reached there theway the docs already describe — by running the terminal under a private model, which the capability
header states. And a daemon that gains a key while an attach is running cannot be answered with a
prompt (stdin is the steering channel by then), so that steer says the daemon was restarted with one
and to attach again, rather than asking.
A bug the runtime check caught
Driving a real daemon that holds a key found that hyper answers an empty 403 with
transfer-encoding: chunkedand a lone0\r\n\r\nterminator, andpost_jsonhanded that framingback as the body — so the terminal read
"0"as the daemon's sentence and printed the daemon wouldnot stop the turn: 0 instead of asking for the key.
post_jsonanddaemon_json_requestnow shareone response parser, which dechunks as the latter already did. The fake daemon in the tests frames
its empty 403 the way the real one does: a
content-length: 0fixture passed every test while thereal thing was misread.
Tests
crates/biorouter-cli/src/commands/session_watch.rs— the reading of a refusal, the flow overevery answer order (through the real
with_key_if_wanted, asrun_ladder's test is), and sixend-to-end flows over a socket against a stand-in daemon that records what went over the wire:
the first request carries no key, the person is asked once and only on the empty 403, a sentence
is printed rather than prompted for, a wrong key is reported as wrong, and an empty refusal reads
as empty however it is framed.
routes::reply's keyed tests (an unproven stop or steer, empty text included, is a 403 with anEMPTY body, answered before the text is read; with the proof the empty steer is a 400) and
tests/turn_control_no_user_key.rs(the empty steer is a 400 where the terminal may steer, andotherwise carries the daemon's sentence, never an empty 403, and touches neither the turn nor the
queue).
Both new suites were mutation-checked: dropping the "a supplied key is never followed by a prompt"
condition, reading every 403 as "wants the key", and disabling the dechunk each fail 5–6 tests.
Measured against real daemons
Two
biorouterdinstances on a throwaway root, no model needed (an unconfigured daemon starts anidle chat), with a
sub_agentrow for the subagent cases:session cancel <chat>session cancel <subagent>session send <chat>session send <subagent>attach <chat>attach <subagent>--read-only--user-action-key-stdin--read-onlyfor attachThe interactive prompt was driven through a real pty: the key is not echoed, and the raw key appears
nowhere on disk in the sandbox.
CI
test (ubuntu-latest)is red here on one test this branch does not touch —agent_drafter::bundle::tests::a_timed_out_esbuild_reaps_its_whole_process_groupinbiorouter-mcp(1634 passed, 1 failed). It is main's own failure, not this PR's: the samesingle test is the only failure on main's push run
34655094806 at
5181f544, thecommit this branch merged, and it failed the same way on 2026-09-03 when it landed.
git diff origin/main...HEAD -- crates/biorouter-mcpis empty. (The test races the process reaper: thegrandchild has exited but is a zombie, and
kill(pid, 0)returns 0 for a zombie. Filed separately.)Docs
docs/agent-loop/workspace-control.md(its user-action-key paragraph said attach and cancel promptbefore steering, and that
sendneeds no proof — whilesendprompted too), SD-11's CLI row and its"Not decided here" paragraph, now The terminal, since;
docs/cli/command-reference.md, whichnever mentioned the key or
--user-action-key-stdin; the subagents page; and CLAUDE.md's SD-11bullet, with the refusal-shape invariant the terminal now depends on.
🤖 Generated with Claude Code