Skip to content

fix(cli): the terminal asks the daemon before it asks you for the user-action key (SD-11 follow-up) - #261

Merged
Broccolito merged 11 commits into
mainfrom
claude/nice-villani-42c71b
Sep 12, 2026
Merged

fix(cli): the terminal asks the daemon before it asks you for the user-action key (SD-11 follow-up)#261
Broccolito merged 11 commits into
mainfrom
claude/nice-villani-42c71b

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #240 (SD-11), which is not merged. This branch is main with #240 merged into it,
so the diff carries #240's three commits until it lands. Merge #240 first. The work to review
is these five commits — everything else in the diff is #240 or a merge of main:

  • 46d5f7e9 fix(cli): ask the daemon before asking for the user-action key
  • 7cd22cf8 test(server): pin the refusal shapes the terminal reads
  • f6347f89 docs: the terminal asks the daemon before it asks you
  • ade905ea fix(cli): read a chunked empty refusal as empty, not as a body of "0"
  • fbef097f refactor(cli): keep the key-verdict types to the module that reads them

Targeting main rather than #240's branch, so that merging #240 cannot close this one.
main was merged in at d5ad76c3 to pick up the reach-sites census fix (#258/#259), without
which every branch off the older base is red on privacy_capability.

SD-11 made a daemon that holds no user-action key admit POST /agent/cancel, POST /interrupt
and /agent/continuation/* for any caller the reach gate admits, a subagent's session excepted. The
terminal had not caught up: session cancel, attach and send demanded the key before they sent
anything
, so against biorouter serve they refused locally the very requests SD-11 admits — and
asked every serve user 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.

  • attach asks as it joins, with an empty steer: /interrupt judges who may steer before it
    reads 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.
  • send asks the same question after a refused /reply, whose own 403 for a subagent's session
    is empty on either kind of daemon and so cannot say which kind this is. On a daemon that holds a
    key, send into an ordinary chat now needs no key at all — which is what the docs always claimed.
  • A wrong key, typed or piped, is reported as wrong and never answered with a second prompt.

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

watch and attach --read-only still never send the key, so a private chat is reached there the
way 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: chunked and a lone 0\r\n\r\n terminator, and post_json handed that framing
back as the body — so the terminal read "0" as the daemon's sentence and printed the daemon would
not stop the turn: 0
instead of asking for the key. 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: a content-length: 0 fixture passed every test while the
real thing was misread.

Tests

  • crates/biorouter-cli/src/commands/session_watch.rs — the reading of a refusal, the flow over
    every answer order (through the real with_key_if_wanted, as run_ladder's test is), and six
    end-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.
  • The daemon's side of the same contract, because it is what decides whether a person is asked:
    routes::reply's keyed 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) and
    tests/turn_control_no_user_key.rs (the empty steer is a 400 where the terminal may steer, and
    otherwise carries the daemon's sentence, never an empty 403, and touches neither the turn nor the
    queue).
cargo test -p biorouter-cli --lib session_watch          50 passed
cargo test -p biorouter-server --test turn_control_no_user_key    9 passed
cargo test -p biorouter-server --lib -- routes::reply routes::session_reach   97 passed

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 biorouterd instances on a throwaway root, no model needed (an unconfigured daemon starts an
idle chat), with a sub_agent row for the subagent cases:

daemon with no key daemon with a key
session cancel <chat> stops it, nothing asked asks, and stops it with the key
session cancel <subagent> the daemon's sentence, nothing asked admitted with the key
session send <chat> sends, nothing asked sends, nothing asked
session send <subagent> the daemon's sentence admitted with the key
attach <chat> attaches and steers asks (or says how to supply the key)
attach <subagent> the sentence, plus --read-only asks
wrong key it is not the key this daemon was started with
no terminal, no --user-action-key-stdin not reached says how to supply it; --read-only for attach

The 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_group in
biorouter-mcp (1634 passed, 1 failed). It is main's own failure, not this PR's: the same
single test is the only failure on main's push run
34655094806 at 5181f544, the
commit this branch merged, and it failed the same way on 2026-09-03 when it landed. git diff origin/main...HEAD -- crates/biorouter-mcp is empty. (The test races the process reaper: the
grandchild 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 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; docs/cli/command-reference.md, 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 terminal now depends on.

🤖 Generated with Claude Code

…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
Broccolito merged commit dd8a444 into main Sep 12, 2026
16 checks passed
@Broccolito
Broccolito deleted the claude/nice-villani-42c71b branch September 12, 2026 08:33
Broccolito added a commit that referenced this pull request Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant