fix(serve): Stop and steering work on a daemon with no user-action key (SD-11) - #240
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.
One conflict, in docs/deployment/serve-decisions.md: main added SD-9 (the launch token works until the daemon stops, #226) where this branch added SD-11. Both kept, in numeric order. CLAUDE.md's "SD-1..SD-9" range now names SD-11 too. main changed none of reply.rs, agent.rs or session_reach.rs since this branch's base; its auth.rs change (#231, the throttle) leaves user_action_proof and its three verdicts untouched.
This was referenced Sep 11, 2026
Broccolito
added a commit
that referenced
this pull request
Sep 11, 2026
serve-decisions.md conflicted: main's #226 added SD-9 (the launch token works until the daemon stops) and made the same SD-1 route fix this branch made. SD-1 takes main's wording; main's SD-9 stays SD-9; this branch's record becomes SD-12, after it. SD-10 and SD-11 are claimed by open #237 and #240, so SD-12 duplicates no number on main or in any open PR. Every reference to this branch's record moves with it (code comments, the two OpenAPI 409 descriptions, the daemon's keyless warning, the docs and their anchors, CLAUDE.md). References to main's SD-9 are untouched. CLAUDE.md's '(SD-1..SD-9)' range is replaced by no number at all, since every record that lands would make it stale.
…aemon SD-11 admitted four turn-control routes to the reach gate on a daemon that holds no user-action key, on one argument: the same caller already stops that turn through `/agent/stop`, and already puts text in front of that chat's model through `/reply`. For `/agent/cancel` and the two continuation routes that holds — their keyless arm calls literally the function `/agent/stop` calls, and `/agent/stop` is strictly more destructive, since it cancels the turn *and* evicts the agent. For `POST /interrupt` it is false. `/reply` takes the BR-33 single-turn lock and answers 409 whenever a *different* turn is already running in that chat; `/interrupt` answers 409 when none is. The preconditions are disjoint, so in the exact state where a steer lands, the route said to dominate it is refused. What was admitted is therefore new: attacker-chosen text injected into a turn already in flight, without cancelling it, which the person watching sees as their own turn changing direction. Cancel-then-reply, the nearest thing a caller holding the daemon secret already has, kills the turn first and is visible. So `/interrupt` moves off `authorize_turn_control` onto its own `authorize_steer` and keeps the proof on both kinds of daemon, which is what `main` had. Its body returns to `main`'s with it: a steer is only ever `Proven`, so it is stamped `UserDirect` unconditionally again and `TurnControlAuthority` is gone. The keyless refusal is a 403 carrying the new `STEER_NO_KEY` and is deliberately NOT empty. `biorouter session attach` tells a daemon that wants the proof apart from one that cannot check it by whether a turn-control 403 has a body (`session_watch::key_verdict`, the CLI follow-up stacked on this branch): empty means "this daemon holds a key", and the terminal prompts for one. On a keyless daemon that prompt would ask a `biorouter serve` user for a credential that does not exist and then report it as the wrong key. The sentence keeps the empty 403 meaning exactly what that reading needs. The gate asks nothing about the chat, so the refusal is byte-for-byte identical for a public chat, a private one, a subagent's and an id that does not exist — which keeps a route no proof can satisfy from becoming a per-id oracle. It was not: before this change a public chat answered 409 and a private one 403. Fail-before, `--test turn_control_no_user_key`: 7 passed, 2 failed — `a_keyless_daemon_refuses_the_steer_it_admits_the_stop_for` measured **202** with the text on the running turn's queue, and `a_keyless_steer_refusal_says_the_same_thing_about_every_chat` measured the per-chat divergence. 9/9 after. The census follows: `/interrupt` comes off `session_reach`'s gated list, because it never reaches that gate on either daemon, and goes back to being one of the file's negative controls. `privacy_guard_wiring`'s two `what:` strings are description-only; no count moves.
`read_user_action_digest` has a 2-second bounded read of stdin, and a desktop
launcher slow enough to miss it leaves the *desktop* daemon keyless. That was
survivable while a keyless daemon simply refused every control that needed the
proof — Stop answered 403 and the user complained. With SD-11 admitting Stop,
Stop-and-Send and the continuation routes there, the same misconfiguration
degrades quietly to a weaker gate instead of visibly breaking, so it has to be
reported.
The read now returns `Result<[u8; 32], NoUserActionKey>` with four arms rather
than one undifferentiated `None`: `HandStarted` (stdin is a terminal),
`NoneOffered` (stdin closed empty — `biorouter serve`'s `Stdio::null()`, SD-7),
`TimedOut` (a writer held the pipe open and wrote nothing inside the bound) and
`Malformed` (not a 32-byte hex digest). The last two are launcher faults —
nothing this repo ships does either on purpose — so their warning says so and
says to restart, while the two expected ones deliberately do not, or the line
would cry wolf on every `serve` start. Every arm names both consequences: what
this daemon refuses, and what it now admits instead.
Classification is split out of the I/O into `classify_digest_line`, because four
arms that are never told apart are the defect this commit is fixing;
`keyless_report_tests` covers the mapping and asserts each warning names both
consequences.
⚠ The 2 s bound is UNCHANGED. Nothing measured says the desktop launcher misses
it, and what was missing was the report rather than the time.
On evidence: this one's fail-before is a measurement of the old code, not a red
test. The whole of what a keyless daemon said before this commit was
"no user-action key on stdin: this daemon will refuse every request that
raises a session's privacy capability, including one made by the person at
the keyboard"
which names neither turn control nor which of the four causes happened. A red
test would have to reference the enum this commit introduces, so it could only
fail to compile; the new tests are drift guards and are not offered as
fail-before evidence.
… Err
Two clippy failures from the two commits above, appended rather than squashed
because those two are pushed and PRs are stacked on this branch.
`authorize_steer` returned `Result<(), Response>` and tripped
`clippy::result_large_err`: a `Response` is 128 bytes, and there is no success
value to carry either. It is now `steer_refusal(&HeaderMap) -> Option<Response>`
— the refusal, if there is one — which reads better at the one call site and
matches the `if let … { return refusal }` shape the other three routes already
use. `authorize_turn_control` keeps its `Result` only because it is `async`, so
the lint sees a future rather than the `Result`; the doc comment says so, or the
next person will "tidy" the two into one shape and reintroduce this.
And a test of the keyless-startup classifier sliced a `String` by byte range
(`clippy::string_slice`); it builds the wrong-length hex with `repeat` instead,
which also makes the too-long case explicit rather than a concatenation.
`cargo clippy -p biorouter-server --all-targets -- -D warnings` clean,
`--test turn_control_no_user_key` 9/9, `--bins` 590/590, `cargo fmt --check`
clean.
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.
Summary
On every
biorouter servedaemon the browser's Stop button, mid-turn steering and Stop and send answered403with an empty body — on a host configured with a public model as well as a private one (measured 2026-09-11 from the page).POST /agent/cancel,POST /interrupt,POST /agent/continuation/abandonandPOST /agent/continuation/recovereach began with an unconditionalis_user_actioncheck, and a daemon started with a closed stdin (SD-7) holds no user-action key, so nothing can ever pass it.This PR records the ruling as SD-11 and implements it:
/agent/canceland the two/agent/continuation/*routes gate throughauthorize_agent_control— the same call/agent/stopalready makes there: the reach rule (every public chat; a private one only for a caller whose statedX-Caller-Providercovers it), then no subagent's chat. One shared call rather than a copy, so the two routes can't drift on who may stop a turn.POST /interruptis not one of them (the review's correction). It keeps the proof on both kinds of daemon via its ownauthorize_steer, and a keyless one refuses with a403carryingSTEER_NO_KEY— a sentence, never an empty body. The browser's steer control falls back to the ordinary send queue on any refusal (chatStreamStore.steer()), so a browser user's text is delayed, not lost.cancel_without_user_action_proof_cannot_stop_another_turn,interrupt_without_user_action_proof_cannot_forge_human_steering) pass untouched./replyand/agent/stopalready refuse it), andrefuse_subagent_unless_usernow names the daemon, not the caller, as what cannot prove a person acted (SD-8).Why admitting these is safe
The proof guards against a model holding the daemon secret (AR-11: recoverable) stopping another chat's turn or putting words in a person's mouth. On a keyless daemon that caller already has every one of those capabilities next door:
POST /agent/stop(trips the same turn, and evicts the agent)authorize_agent_control— the gate this PR reusesworkspace_close { scope: "turn" }refuse_unless_writable(tier only)POST /reply(non-subagent chats accept it unstamped)session_reachworkspace_close { scope: "turn" }refuse_unless_writable⚠ The third row is the whole argument for the steer, and it does not hold —
/replyisrefused
409in the exact state where a steer lands (see the banner). That row is why/interruptwas dropped from the ruling.So nothing that holds the secret gains a capability; the person gains Stop. The record answers privacy-tiers §3.1's Q1/Q2 in full, including one finding (not a new one):
POST /active_work/{id}/cancelcancels a subagent by registry id with no gate on any daemon — the residualsession_reach.rsalready records.The remaining three routes move together because Stop-and-Send mints a continuation lease that blocks every other turn in the chat until it is used or given up; admitting the cancel but not abandon/recover would wedge the chat on the first removed queued message or page reload. The tests measure that.
Tests — each fails before, and each piece of the fix is load-bearing
New keyless binary
crates/biorouter-server/tests/turn_control_no_user_key.rs(its own binary: the digest is a process-globalOnceLock, likeapproval_no_user_key.rs). Against unmodifiedmain, all 7 tests fail — the measured symptom exactly (/agent/cancel403,/interrupt403) — and the premise test shows the asymmetry directly:Mutation checks (each re-breaks one piece of the fix; the named test is the one that catches it):
/interruptback on the keyless arma_keyless_daemon_refuses_the_steer_it_admits_the_stop_for(measured 202, with the text on the running turn's queue)/interrupt's keyless refusal made an empty 403STEER_NO_KEY, which is what keepsbiorouter session attachfrom prompting for a key that does not exista_keyless_steer_refusal_says_the_same_thing_about_every_chat(before: public409, private403)a_subagents_turn_is_still_refused_…(child's turn cancelled, 200) anda_keyless_cancel_admits_exactly_the_callers_agent_stop_already_admits(stop 403 vs cancel 200)stop_and_send_settles_…— "the chat is wedged"a_keyless_daemon_hands_a_pending_continuation_back_to_its_window(take_over 403)a_subagents_turn_is_still_refused_and_the_refusal_says_whyEvery suite below ran locally with
BIOROUTER_DISABLE_KEYRING=true. (These were run before #239 landed, when CI still ran only--lib --bins; CI now runs the integration binaries too, soturn_control_no_user_keyis CI-enforced.)cargo test -p biorouter-server --testsbiorouterdbin 575 · all 19 integration binaries, incl.turn_control_no_user_key8,approval_no_user_key3,knowledge_tier_no_user_key2,privacy_ar15_is_retired7,privacy_toggle_config12,every_test_binary_is_sandboxed2 — 0 failedcargo test -p biorouter --lib privacy::cargo test -p biorouter --test privacy_toggle --test privacy_capability --test privacy_disclosure_toggle --test privacy_guard_wiringcargo test -p biorouter-mcp --lib knowledge::tier·--test privacy_toggle_export./scripts/clippy-lint.shtoo_many_linesbaseline okcargo fmt --check·scripts/check-openapi-schema.shAfter merging
main(60 commits, 2026-09-11): one conflict, inserve-decisions.md(main's new SD-9 beside this SD-11 — both kept, in order).mainchanged none ofreply.rs,agent.rsorsession_reach.rs; itsauth.rschange (#231's throttle) leavesuser_action_proofuntouched. Re-run on the merge:cargo test -p biorouter-server --tests(lib 600 · bin 588 · all 19 integration binaries,turn_control_no_user_key8/8),privacy_guard_wiring3/3,cargo clippy -p biorouter-server --all-targets -D warningsclean,cargo fmt --checkclean, and the regenerated contract is byte-identical to the merged one.CI on the merged head (refreshed 2026-09-11 after #258/#259 landed):
mainmerged again, no conflicts, SD-11 untouched. Re-verified on this head:cargo test -p biorouter-server --testsgreen across lib, bin and all integration binaries (turn_control_no_user_key8/8),privacy_capability4/4 (it was red onmainuntil #258 and #259 fixed the reach-sites census — neither is part of this PR),privacy_guard_wiring3/3,clippy -p biorouter-server --all-targets -D warningsandcargo fmt --checkclean, and the regenerated contract byte-identical.Two CI failures seen on earlier heads, neither from this PR and neither recurring here:
test (windows-latest)'ssession::builder::tests::close_ephemeral_store_removes_the_directory(abiorouter-clitest this PR does not touch, passed on the following run), andtest (ubuntu-latest)'sagent_drafter::bundle::tests::a_timed_out_esbuild_reaps_its_whole_process_group— a process-reaping race inbiorouter-mcpthat passes onmain. If that race fires again it kills the job before the integration step runs, so a red ubuntu check there is not evidence about this PR.The repo-grep guards touched here all pass:
session_reach's ordering census (with its new rows and controls),auth::all_five_raise_channels_call_the_guard,the_add_extension_route_still_refuses_a_public_session_outright, and the wiring census.End to end, in a browser against a real
biorouter serveBuilt from this branch:
biorouter servespawningbiorouterdwith a closed stdin (its log: "no user-action key on stdin"), host configured with the publiccodexprovider (gpt-5.5), driven by clicking the page's own controls:POST /interrupt202, and the model read the steer and followed itPOST /agent/cancel200 {"cancelled":true,"turn_id":"turn-2","settled":true}POST /agent/cancel(continuation) →POST /reply200 {"cancelled":true,…,"settled":true,"continuation_lease":"…"}→200, and the replacement turn answeredThe persisted steer row carries no provenance (
{"userVisible":true,"agentVisible":true,"pinned":false}), identical to an ordinary/replyrow. The private-chat path was not driven from a page — onmainaservehost cannot start a private chat until #229 — and is covered by the integration test instead.#261 compatibility — checked before implementing, and it is the reason the refusal has a body
#261 (stacked on this branch) makes the CLI
send turn-control requests without the proof first and prompt only when the daemon asks for it.
Its
key_verdict(code, body)reads a403with an empty body as "this daemon holds a key andwants the proof" → prompt, and a
403with a sentence as "no key would help" → print thedaemon's words. It reads
/interruptin three places, not one:settle_steering_key— attach's join-time probe, an empty-text/interruptused purely as agate question;
steer_refusal— the per-steer refusal reader during attach;send_to—/reply's key oracle: when/reply403s,sendasks/interruptwhether akey would change that.
A naive revert of
/interruptto bareis_user_actionwould have broken all three: on akeyless daemon the refusal would be an empty
403, so abiorouter serveuser would be promptedfor a user-action key that does not exist and then told it was the wrong one. That is why the
keyless arm answers
STEER_NO_KEYinstead — a sentenced403lands inKeyVerdict::Refused, thebranch #261 already implements for keyless daemons, and the person is shown the daemon's words.
Net: no conflict, and #261 needs no change. The empty
403still means, and only means, "adaemon that holds a key wants the proof". One knock-on worth naming: on a keyless daemon
send_to'soracle now always answers
Refused(STEER_NO_KEY)when/reply403s, sosend's message namessteering where the actual refusal was the subagent rule. It is non-prompting and non-fatal, and
#261 may want to sharpen that sentence; it is not a regression of its contract.
#260 is frontend-only (a subagent's tab loads read-only in a browser) and does not read
/interrupt's status.Loud about a keyless startup
read_user_action_digesthas a 2-second bounded read of stdin. A desktop launcher slow enough tomiss it left the desktop daemon keyless — which was survivable while a keyless daemon simply
refused every control needing the proof: Stop answered
403and the user complained. With Stop nowworking there, the same misconfiguration degrades quietly to a weaker gate.
So the read reports why it holds no key instead of one undifferentiated
None:HandStarted(stdin is a terminal),NoneOffered(stdin closed empty —serve'sStdio::null(),SD-7),
TimedOut(⚠ a writer held the pipe open and wrote nothing),Malformed(⚠ not a 32-bytehex digest). The last two are launcher faults; nothing Biorouter ships does either on purpose, so
their warning says so and says to restart. Every arm names both consequences — what this daemon
refuses, and what it now admits instead (SD-11) — and that mid-turn steering stays refused.
Classification is split into the pure
classify_digest_lineso the four arms are actually tested(
keyless_report_tests).The 2 s bound is unchanged: nothing measured says the desktop launcher misses it, and what was
missing was the report, not the time.
⚠ On evidence, plainly: this item's fail-before is a measurement of the old code, not a red test.
The defect is an absence of information — one
Nonefor four causes and a warning naming only theprivacy refusal — and a red test for it would have to reference the enum it introduces, so it could
only fail to compile. The new tests are drift guards and are not presented as fail-before evidence.
Also in this PR
session_reach.rs: the gated-list table and residual bullet now describe the keyless arm; the ordering census gains rows for/agent/cancel,/agent/continuation/abandonandauthorize_turn_controlonrecover./interruptis not on the list — it reaches neither gate on either daemon — so it stays one ofreply.rs's over-read controls, alongside two non-handlers on either side of the file's rows. Counts in the wiring census (privacy_guard_wiring.rs) are unchanged — the gate is reached throughauthorize_agent_control— and only its twowhat:description strings moved.programmatic-session-access.md: the three routes honour the capability header on a keyless daemon; 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 and Stop-and-send work in an ordinary browser chat; steering does not, and the row says what happens to the text instead (queued, sent when the turn ends); a subagent's tab is read-only.CLAUDE.md: an SD-11 bullet in "Browser access".openapi.json+types.gen.ts);check-openapi-schema.shpasses.Not in this PR
/replyrefuses there too). Flagged as a separate task.session cancel/attachsteering still demand a user-action key client-side, so against a keyless daemoncancelrefuses locally a request the daemon now admits. fix(cli): the terminal asks the daemon before it asks you for the user-action key (SD-11 follow-up) #261 is the follow-up.Notes for review
main):mainhas SD-1…SD-9, SD-9 being fix(serve): serve stops its daemon (F2); BIOROUTER_SERVE_UI validated like --web-dir (F8); serve doc fixes (F10) #226's launch token. Across every open PR, the only records added toserve-decisions.mdare fix(serve): Stop and steering work on a daemon with no user-action key (SD-11) #240 → SD-11, fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237 → SD-10, and fix(serve): a new chat starts on the host's configured private model (QA F1) #229 → SD-9 — which now collides withmain's SD-9 and needs renumbering (SD-12 is the first free number). QA-D F7: the WebSocket origin gates are a real same-origin test #233 adds no SD record and cites none. SD-11 is claimed once.mainthe browser's Stop now works on public chats; with fix(serve): a new chat starts on the host's configured private model (QA F1) #229 merged, the browser states the host's provider (X-Caller-Provider), so it also works on private chats — the private-chat path is covered bya_private_chat_is_stopped_or_steered_only_by_a_caller_whose_capability_covers_it.servebrowser on proof andX-Caller-Provider— which is what these routes inherit throughauthorize_agent_control. Expect textual conflicts for whichever lands second insession_reach.rs(gated-list table, ordering census),privacy_guard_wiring.rsprose,routes/agent.rs, and the shared docs (serve-decisions.md,programmatic-session-access.md,browser-access.md,CLAUDE.md).tests/*.rsoutside an exclusion table, andturn_control_no_user_keyis offline, so it will run there with no change.check-commits.ymlrejects them.🤖 Generated with Claude Code