diff --git a/CLAUDE.md b/CLAUDE.md index d00981305..59e7c7c4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1187,7 +1187,7 @@ Test the gate where it is: the unit tests in `agents/agent.rs` prints a URL. The daemon serves the SPA **on its own origin**, so nothing is proxied. This replaced a standalone `biorouter-headless` binary and its Linux tarball, both deleted 2026-08-23; release assets went 11 → 10. Design and reasoning: -[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (SD-1..SD-9), +[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (SD-1..SD-9, SD-11), [`serve-architecture.md`](docs/deployment/serve-architecture.md), [`browser-access.md`](docs/deployment/browser-access.md). @@ -1211,6 +1211,20 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de operator-pinned-off extension and its ordinary path needs none. ⚠ Not a security change: nothing that was refused becomes permitted. The availability flag is sampled ONCE per roster and threaded, so a roster can never half-believe a person is reachable. +- **Stop answers to the reach gate on a keyless daemon; steering does not** (SD-11). + `/agent/cancel` and the two `/agent/continuation/*` routes take the proof on a daemon that + holds a key, and on one that holds none gate through `authorize_agent_control` — the *same + call* `/agent/stop` makes — via `reply.rs::authorize_turn_control`. Tightening that gate + tightens who may press Stop in a browser. ⚠ **`/interrupt` is NOT one of them.** It keeps the + proof on both kinds of daemon: the keyless arm's whole argument is that the caller already + reaches the same effect through `/agent/stop` and `/reply`, and `/reply` is refused `409` by + the BR-33 single-turn lock in the exact state where a steer lands — so admitting it would add + silent mid-turn injection into a turn already in flight, which nothing else there can do + (`reply.rs::steer_refusal`). Its keyless refusal carries `STEER_NO_KEY` and is **never an + empty 403**, because an empty turn-control 403 is how `biorouter session attach` recognises a + daemon that holds a key and asks the person for it. A subagent's tab stays refused throughout. + ⚠ Keyless behaviour can only be tested in its own binary (the digest is a process-global + `OnceLock`): `cargo test -p biorouter-server --test turn_control_no_user_key`. - **Proof of a person is checked at the resolution choke point, not at one route.** Every door that answers a parked decision — the HTTP route, an Agent Drafter app's WebSocket, ACP, the CLI prompt, the TUI modal, an ancestor agent's relay — passes a `DecisionAuthority` into diff --git a/crates/biorouter-server/src/commands/agent.rs b/crates/biorouter-server/src/commands/agent.rs index c2fa7dfbe..f3eb45b90 100644 --- a/crates/biorouter-server/src/commands/agent.rs +++ b/crates/biorouter-server/src/commands/agent.rs @@ -109,6 +109,89 @@ fn watch_parent(expected: u32) -> CancellationToken { CancellationToken::new() } +/// Why this daemon came up holding no user-action digest. +/// +/// Four causes, kept apart because they mean very different things and only one +/// [`read_user_action_digest`] returns is a *mistake*. Before SD-11 they were one +/// `None`, which was survivable while a keyless daemon simply refused every +/// control that needed the proof: the failure was loud at the first click. Now +/// three of the four turn-control routes fall back to the reach gate there +/// (`routes::reply::authorize_turn_control`), so a desktop launcher that misses +/// the 2 s window comes up **quietly** weaker than the one the user installed +/// rather than visibly broken. Naming the cause is what keeps that from being a +/// silent degradation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NoUserActionKey { + /// stdin is a terminal: a person started this daemon at a prompt + /// (`just run-server`, `biorouterd agent` by hand). Expected. + HandStarted, + /// stdin closed with nothing on it. `biorouter serve` spawns its daemon with + /// `Stdio::null()` precisely so that this happens (SD-7). Expected. + NoneOffered, + /// ⚠ A writer held the pipe open and put no digest on it inside the bound. + /// Nothing Biorouter ships does that on purpose, so this is the arm that + /// means a launcher is broken or the machine was too loaded to make the + /// window — the one case where a keyless daemon is an accident. + TimedOut, + /// ⚠ A line arrived and was not 32 bytes of hex. Also a launcher fault. + Malformed, +} + +impl NoUserActionKey { + /// One sentence saying what happened and, always, what it costs. + /// + /// Every arm names **both** consequences, because a reader who has just + /// learnt their daemon is keyless needs to know what that daemon now does + /// differently, not only what it refuses. `unit_tests` below asserts it of + /// each arm rather than leaving it to whoever edits one of them. + fn warning(self) -> String { + let cause = match self { + Self::HandStarted => { + "no user-action key: stdin is a terminal, so this daemon was started by hand" + } + Self::NoneOffered => { + "no user-action key: stdin closed without one, which is how `biorouter serve` \ + starts its daemon" + } + Self::TimedOut => { + "no user-action key: something held stdin open and wrote no digest within 2s. \ + If this is the desktop application's daemon, its launcher FAILED to hand the \ + key over and this daemon is weaker than the one you installed — restart it" + } + Self::Malformed => { + "no user-action key: the line on stdin was not a 32-byte hex digest. If this is \ + the desktop application's daemon, its launcher is broken — restart it" + } + }; + format!( + "{cause}. This daemon cannot verify that a request came from the person at the \ + keyboard, so it will refuse every request that raises a session's privacy \ + capability, including one made by that person; and Stop, Stop-and-Send and the \ + continuation routes answer to the reach gate instead of to the proof (serve \ + decision SD-11), which admits any caller holding the daemon secret to the chats \ + that gate admits. Mid-turn steering stays refused." + ) + } +} + +/// The digest, or why there is none, from the line stdin produced — `None` for a +/// read that did not finish inside the bound. +/// +/// Split out from the I/O so the mapping is testable: the whole point of the +/// four arms is that they are told apart, and a classification that lives inside +/// an `async fn` reading real stdin is one nothing can check. +fn classify_digest_line(line: Option) -> Result<[u8; 32], NoUserActionKey> { + let Some(line) = line else { + return Err(NoUserActionKey::TimedOut); + }; + let line = line.trim(); + if line.is_empty() { + return Err(NoUserActionKey::NoneOffered); + } + let bytes = hex::decode(line).map_err(|_| NoUserActionKey::Malformed)?; + <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| NoUserActionKey::Malformed) +} + /// Read the launcher's SHA-256 user-action digest off stdin, as one hex line /// (issue #56, DR-16). /// @@ -117,12 +200,16 @@ fn watch_parent(expected: u32) -> CancellationToken { /// by a child, and the raw key was never there to begin with. /// /// It must **never block a hand-started daemon**, so it is guarded twice. -async fn read_user_action_digest() -> Option<[u8; 32]> { +/// +/// ⚠ The 2 s bound is unchanged. It is not raised here because nothing measured +/// says the desktop launcher misses it; what changed is that missing it is now +/// *reported* rather than folded into the three expected ways of holding no key. +async fn read_user_action_digest() -> Result<[u8; 32], NoUserActionKey> { use std::io::IsTerminal; // (1) A terminal is a human at a prompt, not a launcher with a key. Reading // it would hang `just run-server` forever waiting for a line. if std::io::stdin().is_terminal() { - return None; + return Err(NoUserActionKey::HandStarted); } // (2) And a pipe whose writer never closes would hang just as hard, so the // read is bounded. 2s is far longer than a local `write` + `end`. @@ -144,12 +231,15 @@ async fn read_user_action_digest() -> Option<[u8; 32]> { // The receiver is gone on the timeout path; nothing to report to. let _ = tx.send(read); }); + // A timeout, a dropped sender and a failed `read_line` are all "no line + // arrived inside the bound", which is the one arm that means a launcher + // wrote nothing it promised. let line = tokio::time::timeout(std::time::Duration::from_secs(2), rx) .await - .ok()? - .ok()??; - let bytes = hex::decode(line.trim()).ok()?; - <[u8; 32]>::try_from(bytes.as_slice()).ok() + .ok() + .and_then(Result::ok) + .flatten(); + classify_digest_line(line) } pub async fn run(exit_with_parent: Option) -> Result<()> { @@ -209,13 +299,18 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { // tool that reads a caller-named path (`/proc/self/environ`) or, on macOS, // by `sysctl(KERN_PROCARGS2)`, which is not a path at all and which no // sandbox profile can gate. - let user_action_digest = read_user_action_digest().await; - if user_action_digest.is_none() { - tracing::warn!( - "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" - ); - } + let user_action_digest = match read_user_action_digest().await { + Ok(digest) => Some(digest), + Err(reason) => { + // ⚠ One WARN, and it names the SD-11 consequence as well as the + // privacy one. Before SD-11 a keyless desktop daemon announced + // itself at the first click — Stop answered 403 and the user + // complained. Now Stop works there, so the same misconfiguration is + // silent unless this line says so. + tracing::warn!("{}", reason.warning()); + None + } + }; // A tool whose approval can never be granted must not be offered. `serve` // spawns this daemon with `Stdio::null()`, so it holds no key and every // proof-backed approval refuses forever — the install and delete tools take @@ -344,6 +439,89 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { Ok(()) } +/// The keyless-startup report, on every platform (unlike the `unix`-only module +/// below). +#[cfg(test)] +mod keyless_report_tests { + use super::{classify_digest_line, NoUserActionKey}; + + /// The four causes are told apart. They were one `None` until SD-11 made a + /// keyless daemon behave differently rather than merely refuse more, at + /// which point a launcher that misses the window stops being visible. + #[test] + fn the_four_ways_of_holding_no_key_are_distinguishable() { + let digest = "a".repeat(64); + assert_eq!( + classify_digest_line(Some(format!("{digest}\n"))), + Ok([0xaa; 32]) + ); + // Nothing arrived inside the bound: a writer held the pipe open. The + // only arm that means something is wrong. + assert_eq!(classify_digest_line(None), Err(NoUserActionKey::TimedOut)); + // `Stdio::null()`, which is how `biorouter serve` starts its daemon: the + // read succeeds at EOF and yields nothing. + for empty in ["", "\n", " \n"] { + assert_eq!( + classify_digest_line(Some(empty.to_string())), + Err(NoUserActionKey::NoneOffered), + "{empty:?}" + ); + } + // Present and wrong, which a launcher fault also looks like: not hex at + // all, and hex of the wrong length in both directions. + let short = "a".repeat(62); + let long = "a".repeat(66); + for bad in ["not-hex", "abcd", short.as_str(), long.as_str()] { + assert_eq!( + classify_digest_line(Some(bad.to_string())), + Err(NoUserActionKey::Malformed), + "{bad:?}" + ); + } + } + + /// Every arm names BOTH consequences — what this daemon refuses, and what it + /// now admits instead (SD-11) — and the two launcher faults say they are + /// faults. A reader who has just learnt their daemon is keyless needs the + /// second half as much as the first. + #[test] + fn every_warning_names_what_a_keyless_daemon_does_differently() { + for reason in [ + NoUserActionKey::HandStarted, + NoUserActionKey::NoneOffered, + NoUserActionKey::TimedOut, + NoUserActionKey::Malformed, + ] { + let warning = reason.warning(); + assert!( + warning.contains("privacy capability"), + "{reason:?} does not name what it refuses: {warning}" + ); + assert!( + warning.contains("SD-11") && warning.contains("reach gate"), + "{reason:?} does not name what it admits instead: {warning}" + ); + assert!( + warning.contains("Mid-turn steering stays refused"), + "{reason:?} does not say steering is still refused: {warning}" + ); + } + // The two that mean a launcher is broken say so, and say what to do. + for fault in [NoUserActionKey::TimedOut, NoUserActionKey::Malformed] { + let warning = fault.warning(); + assert!( + warning.contains("restart it"), + "{fault:?} is a misconfiguration and must be actionable: {warning}" + ); + } + // …and the two expected ones do not, so the WARN cannot cry wolf on + // every `biorouter serve` start. + for expected in [NoUserActionKey::HandStarted, NoUserActionKey::NoneOffered] { + assert!(!expected.warning().contains("restart it"), "{expected:?}"); + } + } +} + /// Only [`until_orphaned`], never [`watch_parent`]: the latter arms a /// `process::exit`, which would take the test binary down with it. #[cfg(all(test, unix))] diff --git a/crates/biorouter-server/src/routes/agent.rs b/crates/biorouter-server/src/routes/agent.rs index e9f761dc9..dc78cb591 100644 --- a/crates/biorouter-server/src/routes/agent.rs +++ b/crates/biorouter-server/src/routes/agent.rs @@ -70,17 +70,40 @@ pub struct UpdateFromSessionRequest { const SUBAGENT_USER_ACTION_REQUIRED: &str = "Changing or resuming a subagent from its tab requires proof that the request came from the person at the keyboard."; +/// …and when the daemon holds no user-action key at all. +/// +/// A separate sentence, per Task 18A's open question 23 and SD-8: telling a +/// person at a `biorouter serve` page that their request "requires proof" sends +/// them hunting for a permission this daemon can never grant anyone. It names +/// the daemon as the reason, in the register `CROSS_AFFILIATION_GRANT_NO_KEY` +/// and `session_reach::SESSION_REACH_NO_KEY` already use. Since SD-11 this is +/// also what a keyless daemon answers a Stop aimed at a subagent's turn, because +/// that route gates through [`authorize_agent_control`] there. A *steer* at the +/// same turn is refused one step earlier, by `reply::steer_refusal`, which +/// never reads the row — so the two sentences differ, and both open by naming +/// this daemon rather than the caller. +const SUBAGENT_CONTROL_NO_KEY: &str = + "This daemon was started without a user-action key, so it cannot verify that a request came \ + from the person at the keyboard, and changing, resuming, stopping or steering a subagent from \ + its tab requires that proof. Nothing was changed. This control is unavailable on this \ + daemon; use the desktop app."; + fn refuse_subagent_unless_user( session: &Session, headers: &HeaderMap, ) -> Result<(), ErrorResponse> { - if session.session_type == SessionType::SubAgent && !is_user_action(headers) { - return Err(ErrorResponse { - message: SUBAGENT_USER_ACTION_REQUIRED.to_string(), - status: StatusCode::FORBIDDEN, - }); + if session.session_type != SessionType::SubAgent { + return Ok(()); } - Ok(()) + let message = match user_action_proof(headers) { + UserActionProof::Proven => return Ok(()), + UserActionProof::Unproven => SUBAGENT_USER_ACTION_REQUIRED, + UserActionProof::NoKeyInstalled => SUBAGENT_CONTROL_NO_KEY, + }; + Err(ErrorResponse { + message: message.to_string(), + status: StatusCode::FORBIDDEN, + }) } #[async_trait::async_trait] @@ -146,7 +169,16 @@ async fn read_update_session( /// Authorize an HTTP control-plane operation before it can touch an agent or a /// queued child handle. The daemon bearer proves only that the caller reached /// this process; it does not prove that a person chose to mutate a subagent. -async fn authorize_agent_control( +/// +/// ⚠ **Also the turn-control gate on a daemon with no user-action key** (SD-11): +/// `routes::reply`'s `authorize_turn_control` calls this for `/agent/cancel` and +/// the two continuation routes there, so that stopping a turn admits exactly the +/// callers `/agent/stop` admits. Tightening this therefore tightens those three +/// too, which is the point — but it is a change to who may press Stop in a +/// browser, and `tests/turn_control_no_user_key.rs` will say so. `/interrupt` is +/// NOT among them: `reply::steer_refusal` keeps the proof on every daemon, +/// because the dominance argument that admits a Stop does not reach a steer. +pub(crate) async fn authorize_agent_control( state: &AppState, session_id: &str, headers: &HeaderMap, diff --git a/crates/biorouter-server/src/routes/reply.rs b/crates/biorouter-server/src/routes/reply.rs index ed2823699..de22e9002 100644 --- a/crates/biorouter-server/src/routes/reply.rs +++ b/crates/biorouter-server/src/routes/reply.rs @@ -16,6 +16,10 @@ use biorouter::conversation::Conversation; use biorouter::privacy::SessionClassification; use biorouter::session::session_manager::ReplaceOutcome; use biorouter::session::SessionManager; +// Through the LIB path, as every route names the user-action digest: `src/routes/` +// is compiled into the `biorouterd` binary too, where `crate::auth` does not +// exist, and the digest must be the lib's one static. +use biorouter_server::auth::{user_action_proof, UserActionProof}; use bytes::Bytes; use futures::Stream; use rmcp::model::ServerNotification; @@ -1753,6 +1757,129 @@ pub struct InterruptAccepted { pub turn_id: String, } +/// The refusal `POST /interrupt` gives on a daemon that holds no user-action key. +/// +/// In the voice of `session_reach::SESSION_REACH_NO_KEY` and `routes::agent`'s +/// `SUBAGENT_CONTROL_NO_KEY`, and for their reason (SD-8): it is this daemon's +/// situation, not the caller's, and a person at a `biorouter serve` page must +/// not be sent hunting for a permission no one can be granted here. +/// +/// ⚠ **Never empty, and that is load-bearing.** `biorouter session attach` tells +/// a daemon that wants the proof apart from one that cannot check it by whether +/// a turn-control 403 carries a body (`session_watch::key_verdict`): an empty +/// body means "this daemon holds a key and wants the proof", and the terminal +/// prompts for one. A keyless daemon has no key to be typed, so its steer +/// refusal says so in words instead of being mistaken for a missing credential. +pub const STEER_NO_KEY: &str = + "This daemon was started without a user-action key, so it cannot verify that a request came \ + from the person at the keyboard, and steering a turn that is already running requires that \ + proof: it changes what the model is doing, in place, without the person watching it having \ + asked. Nothing was queued and the turn was not touched. Stop the turn and send the message \ + instead, or use the desktop app."; + +/// May this request stop or settle a turn in the chat it names? +/// +/// The gate of `POST /agent/cancel`, `POST /agent/continuation/abandon` and +/// `POST /agent/continuation/recover`, asked before any of them touches the +/// turn. **`POST /interrupt` is deliberately not one of them** — see +/// [`steer_refusal`], which says why the argument below does not reach it. +/// +/// * **A daemon that holds a user-action key** — the desktop application's — +/// takes the proof and nothing else, exactly as before; `Unproven` is the +/// empty 403 these routes have always answered. +/// * **A daemon that holds none** — the one `biorouter serve` starts (SD-7), or +/// a `biorouterd` started by hand — asks what `/agent/stop` asks there: +/// `routes::agent::authorize_agent_control`, the reach gate and then the +/// subagent rule. The same call, not a copy of it, so the two routes can never +/// disagree about who may stop a turn. +/// +/// ⚠ **Why the keyless arm no longer refuses** (serve decision SD-11). On such a +/// daemon the proof can only refuse everyone, the person at the browser +/// included: measured 2026-09-11, the Stop button answered 403 on every chat of +/// a `serve` host. And the refusal protected nothing. The proof is here so that +/// a model holding the daemon secret, which AR-11 found recoverable, cannot stop +/// another chat's turn; on a keyless daemon the same caller already stops that +/// turn through `/agent/stop` — literally this function's own keyless arm — or, +/// as a model, through `workspace_close { scope: "turn" }`. `/agent/stop` is +/// also strictly the more destructive of the two: it cancels the in-flight turn +/// **and** evicts the agent. Same authority, same scope, weaker effect, so +/// admitting these three gives no caller a capability it lacked. +/// +/// ⚠ **Why a daemon that holds a key is not relaxed with it.** There the proof +/// costs the person nothing — the renderer attaches it to every request — and it +/// is what licenses the `UserDirect` stamp a steer carries. +async fn authorize_turn_control( + state: &AppState, + session_id: &str, + headers: &HeaderMap, +) -> Result<(), axum::response::Response> { + match user_action_proof(headers) { + UserActionProof::Proven => Ok(()), + UserActionProof::Unproven => Err(StatusCode::FORBIDDEN.into_response()), + UserActionProof::NoKeyInstalled => { + crate::routes::agent::authorize_agent_control(state, session_id, headers) + .await + .map(|_| ()) + .map_err(IntoResponse::into_response) + } + } +} + +/// May this request put text into a turn that is **already running**? +/// +/// The gate of `POST /interrupt` alone, and the one place the four turn-control +/// routes part company. It asks for the user-action proof on every daemon, which +/// is what `main` asked before SD-11 and what this route keeps asking. +/// +/// ⚠ **Why the steer does not move with the Stop.** [`authorize_turn_control`]'s +/// keyless arm rests on a dominance argument: the caller already stops that turn +/// through `/agent/stop`, and already puts text in front of that chat's model +/// through `/reply`. The second half is false here. `/reply` takes the BR-33 +/// single-turn lock (`try_begin_turn_idempotent_with_continuation`) and answers +/// `409 CONFLICT` when a *different* turn is already running in that chat; +/// `/interrupt` answers `409` when none is. The two preconditions are disjoint, +/// so in the exact state where a steer lands, the route said to dominate it is +/// refused. What admitting it would add is therefore genuinely new, and worth +/// naming precisely: 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 only the daemon secret already has — kills the turn first, and is +/// therefore visible. This is a capability asymmetry, not a tier crossing: +/// `session_reach` still refuses a private chat to a public caller and +/// `refuse_subagent_unless_user` still refuses every subagent's chat. +/// +/// ⚠ **Why the keyless refusal is [`STEER_NO_KEY`] rather than an empty 403.** +/// `biorouter session attach` tells the two kinds of daemon apart by whether a +/// turn-control 403 carries a body (`session_watch::key_verdict`): an empty one +/// means "this daemon holds a key and wants the proof", and the terminal prompts +/// for it. A keyless daemon has no key to be typed, so refusing here with an +/// empty body would send a `biorouter serve` user hunting for a credential that +/// does not exist and then tell them it was the wrong one. The sentence keeps +/// the empty 403 meaning exactly what that reading needs it to mean. +/// +/// It takes no session id and asks nothing about the chat, so the refusal is +/// byte-for-byte the same for every chat — which keeps a route no proof can ever +/// satisfy from becoming a per-id oracle. +/// +/// Returns the refusal rather than a `Result<(), Response>`: there is no success +/// value to carry, and a `Response` is 128 bytes, which `clippy::result_large_err` +/// refuses in a synchronous function. [`authorize_turn_control`] keeps the +/// `Result` shape only because it is `async`, so the lint sees a future rather +/// than the `Result`. +fn steer_refusal(headers: &HeaderMap) -> Option { + match user_action_proof(headers) { + UserActionProof::Proven => None, + UserActionProof::Unproven => Some(StatusCode::FORBIDDEN.into_response()), + UserActionProof::NoKeyInstalled => Some( + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "message": STEER_NO_KEY })), + ) + .into_response(), + ), + } +} + /// Soft interrupt: queue a user message to be injected into the session's /// running turn at the next safe loop boundary, instead of cancelling the turn /// and re-sending the whole context. Returns 202 Accepted; the message surfaces @@ -1779,7 +1906,9 @@ pub struct InterruptAccepted { responses( (status = 202, description = "Message queued for injection into the running turn", body = InterruptAccepted), (status = 400, description = "Empty message text"), - (status = 403, description = "The request was not proven to come from the user"), + (status = 403, description = "The request was not proven to come from the user; on a daemon \ + that holds no user-action key, steering is unavailable and the \ + refusal says so (SD-11)"), (status = 409, description = "No turn is accepting interrupts for this session"), (status = 500, description = "Internal server error") ) @@ -1788,12 +1917,12 @@ pub async fn interrupt( State(state): State>, headers: HeaderMap, Json(req): Json, -) -> Result<(StatusCode, Json), StatusCode> { - if !biorouter_server::auth::is_user_action(&headers) { - return Err(StatusCode::FORBIDDEN); +) -> Result<(StatusCode, Json), axum::response::Response> { + if let Some(refusal) = steer_refusal(&headers) { + return Err(refusal); } if req.text.trim().is_empty() { - return Err(StatusCode::BAD_REQUEST); + return Err(StatusCode::BAD_REQUEST.into_response()); } if let Some(turn_id) = req.turn_id.clone() { let queued_message = crate::workspace::turn::stamp_user_direct_if_subagent( @@ -1817,18 +1946,22 @@ pub async fn interrupt( // Cheap early-out only: it avoids constructing an agent for an idle session. // It is no longer the guard — see `try_queue_soft_interrupt` below. if !state.is_turn_active(&req.session_id) { - return Err(StatusCode::CONFLICT); + return Err(StatusCode::CONFLICT.into_response()); } - // User-action authentication above is the authority for this attribution. - // Keep it independent of the session store: a live agent can legitimately - // outlast or race its durable row, but an accepted human steer must never - // lose its provenance because that auxiliary lookup failed. + // `steer_refusal` above is the authority for this attribution, and it + // refuses everything but `Proven` — which is why the stamp is unconditional + // here on every daemon. Keep it independent of the session store: a live agent can + // legitimately outlast or race its durable row, but an accepted human steer + // must never lose its provenance because that auxiliary lookup failed. let provenance = Some(biorouter::conversation::message::MessageProvenance { kind: biorouter::conversation::message::ProvenanceKind::UserDirect, from_session_id: None, from_session_name: None, }); - let agent = state.get_agent_for_route(req.session_id).await?; + let agent = state + .get_agent_for_route(req.session_id) + .await + .map_err(IntoResponse::into_response)?; match agent.try_queue_soft_interrupt(req.text, provenance) { Ok(turn_id) => Ok(( StatusCode::ACCEPTED, @@ -1838,7 +1971,7 @@ pub async fn interrupt( )), // #69: the turn the caller addressed has ended. Refusing is the honest // answer — queueing for whatever runs next is the bug this replaces. - Err(InterruptRefused::TurnEnded) => Err(StatusCode::CONFLICT), + Err(InterruptRefused::TurnEnded) => Err(StatusCode::CONFLICT.into_response()), } } @@ -2052,7 +2185,9 @@ const CANCEL_SETTLEMENT_TIMEOUT: Duration = Duration::from_secs(30); (status = 200, description = "Cancel processed; `cancelled` reports whether a turn was running", body = CancelTurnResponse), (status = 400, description = "Stop-and-Send requires an exact turn id and a valid stable continuation owner id"), (status = 401, description = "Unauthorized - invalid secret key"), - (status = 403, description = "The request was not proven to come from the user"), + (status = 403, description = "The request was not proven to come from the user; on a daemon \ + that holds no user-action key, the chat is out of the caller's \ + reach or is a subagent's (SD-11)"), (status = 409, description = "A different turn generation is active, another client owns the continuation, or its admission is still settling", body = CancelTurnConflict), (status = 504, description = "The cancelled turn did not release the session lock before the safety bound"), (status = 500, description = "Internal server error") @@ -2063,8 +2198,8 @@ pub async fn cancel_turn( headers: HeaderMap, Json(req): Json, ) -> axum::response::Response { - if !biorouter_server::auth::is_user_action(&headers) { - return StatusCode::FORBIDDEN.into_response(); + if let Err(refusal) = authorize_turn_control(&state, &req.session_id, &headers).await { + return refusal; } match cancel_turn_bounded(&state, &req, CANCEL_SETTLEMENT_TIMEOUT).await { Ok(response) => Json(response).into_response(), @@ -2079,7 +2214,9 @@ pub async fn cancel_turn( request_body = AbandonContinuationLeaseRequest, responses( (status = 200, description = "The continuation lease is abandoned or was already resolved", body = AbandonContinuationLeaseResponse), - (status = 403, description = "The request was not proven to come from the user"), + (status = 403, description = "The request was not proven to come from the user; on a daemon \ + that holds no user-action key, the chat is out of the caller's \ + reach or is a subagent's (SD-11)"), (status = 409, description = "The lease is invalid or belongs to another session", body = ContinuationLeaseErrorResponse) ) )] @@ -2088,8 +2225,8 @@ pub async fn abandon_continuation_lease( headers: HeaderMap, Json(req): Json, ) -> axum::response::Response { - if !biorouter_server::auth::is_user_action(&headers) { - return StatusCode::FORBIDDEN.into_response(); + if let Err(refusal) = authorize_turn_control(&state, &req.session_id, &headers).await { + return refusal; } match state.abandon_continuation_lease(&req.session_id, &req.continuation_lease) { Ok(resolution) => { @@ -2123,7 +2260,9 @@ fn valid_continuation_owner_id(owner_id: &str) -> bool { responses( (status = 200, description = "The exact pending continuation was taken over or its whole claim group was abandoned", body = RecoverContinuationResponse), (status = 400, description = "The continuation owner id is missing or invalid"), - (status = 403, description = "The session is out of reach or the request was not proven to come from the user"), + (status = 403, description = "The session is out of reach or the request was not proven to \ + come from the user; on a daemon that holds no user-action key, \ + the chat is out of the caller's reach or is a subagent's (SD-11)"), (status = 409, description = "The exact continuation generation was already resolved", body = ContinuationLeaseErrorResponse) ) )] @@ -2132,12 +2271,16 @@ pub async fn recover_continuation( headers: HeaderMap, Json(req): Json, ) -> axum::response::Response { - if !biorouter_server::auth::is_user_action(&headers) { - return StatusCode::FORBIDDEN.into_response(); + if let Err(refusal) = authorize_turn_control(&state, &req.session_id, &headers).await { + return refusal; } if !valid_continuation_owner_id(&req.continuation_owner_id) { return StatusCode::BAD_REQUEST.into_response(); } + // Asked again on a keyless daemon, where `authorize_turn_control` has already + // asked it: this is the route's own reach gate on EVERY daemon, and a second + // answer to the same question is the same answer. It stays where the + // ordering census (`session_reach::every_gated_route_resolves_…`) pins it. if let Err(refusal) = crate::routes::session_reach::session_reach( state.session_manager(), &req.session_id, diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index 09dbb875e..c2bc49889 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -23,8 +23,11 @@ //! it can name — that is not a privacy boundary and this gate is deliberately //! inert there; //! * it still reaches every session-addressing route NOT on -//! [the gated list](self#the-gated-list). `POST /interrupt` and `POST -//! /agent/cancel` now require user-action proof; `GET +//! [the gated list](self#the-gated-list). `POST /agent/cancel` requires +//! user-action proof on a daemon that holds a key and is on the list on one +//! that does not (SD-11); `POST /interrupt` requires the proof on **either** +//! kind and so is on neither (SD-11a — `routes::reply::steer_refusal` says +//! why the steer did not move with the Stop); `GET //! /sessions/{id}/extensions`, `GET /sessions/{id}/usage`, `PUT //! /sessions/{id}/name`, `PUT /sessions/{id}/user_workflow_values` and //! `DELETE /sessions/{id}` remain open, as do `GET /active_work` and `POST @@ -149,15 +152,17 @@ //! | `POST /agent/continuation/recover` | Resumes a parked continuation in the named session. Gates directly. | //! | `POST /agent/update_from_session` | Adopts another session's provider configuration. Gates directly. | //! | `POST /agent/update_provider` · `restart` · `stop` · `remove_extension` | Gate through [`authorize_agent_control`](../agent/fn.authorize_agent_control.html), which calls [`session_reach`] and then reads the row. | +//! | `POST /agent/cancel` · `/agent/continuation/abandon` | Stop and settle the named session's turn. **On a daemon that holds no user-action key only** (serve decision SD-11): there `routes::reply::authorize_turn_control` gates them through the same `authorize_agent_control` as the row above, so a Stop admits exactly the callers `/agent/stop` does. A daemon that holds a key asks them for the proof instead, which reaches every chat. `POST /interrupt` is NOT here: it asks for the proof on both kinds of daemon, so it never reaches this gate — see `routes::reply::steer_refusal`. | //! -//! ⚠ **Two spellings, one list.** The last row reaches the gate through a helper -//! rather than by naming it, which is why a scan for the literal `session_reach(` -//! reports those four as ungated and why the ordering test below uses two of them -//! as over-read controls. They are NOT exempt — measured live, each answers 403 -//! without the capability header and proceeds with it. A future sweep that greps -//! for the call must follow `authorize_agent_control` too, or it will "discover" -//! four holes that are not there and, worse, trust the same grep when it reports -//! a real one. +//! ⚠ **Two spellings, one list.** The last two rows reach the gate through a +//! helper rather than by naming it, which is why a scan for the literal +//! `session_reach(` reports those six as ungated and why the ordering test +//! below uses two of them as over-read controls. They are NOT exempt — measured +//! live, each of the first four answers 403 without the capability header and +//! proceeds with it, and `tests/turn_control_no_user_key.rs` measures the other +//! two on a keyless daemon. A future sweep that greps for the call must follow +//! `authorize_agent_control` too, or it will "discover" six holes that are not +//! there and, worse, trust the same grep when it reports a real one. //! //! # Why `X-User-Action` and not a new mechanism, for the proof half //! @@ -1063,11 +1068,13 @@ mod tests { /// unit test — `AppState::new()` opens the developer's REAL session /// database. Every route on the list is also driven over HTTP by /// [`super::bypass_tests`] except `POST /agent/add_extension` (whose admitted - /// arm mints a real agent) and `GET|POST /knowledge/active` (a middleware, which + /// arm mints a real agent), `GET|POST /knowledge/active` (a middleware, which /// a body scan cannot see and /// [`super::bypass_tests::the_knowledge_active_gate_is_actually_wired`] - /// drives instead); this is what holds the ORDERING, which no status code - /// can show. + /// drives instead) and the two SD-11 turn-control routes, which are gated + /// only on a daemon with no user-action key and so are driven by their own + /// keyless binary, `tests/turn_control_no_user_key.rs`; this is what holds + /// the ORDERING, which no status code can show. /// /// ⚠ **Every route added to the gated list gets a row here.** `/export`, /// `/events` and `/diagnostics` each shipped a gate that this table did not @@ -1095,6 +1102,31 @@ mod tests { "recover_continuation_for_owner(", "the pending continuation ownership state", ), + // SD-11: the turn-control routes. Their gate is a helper, because on + // a daemon that holds a key the answer is the proof and on one that + // holds none it is `authorize_agent_control` — the ordering is the + // same either way, and it is what is asserted here. + ( + reply_rs, + "pub async fn recover_continuation", + "authorize_turn_control(", + "recover_continuation_for_owner(", + "the pending continuation ownership state", + ), + ( + reply_rs, + "pub async fn cancel_turn(", + "authorize_turn_control(", + "cancel_turn_bounded(", + "the turn registry, whose answer says whether this chat is busy", + ), + ( + reply_rs, + "pub async fn abandon_continuation_lease", + "authorize_turn_control(", + "state.abandon_continuation_lease(", + "the continuation registry", + ), ( session_rs, "async fn get_session(", @@ -1166,15 +1198,20 @@ mod tests { // are controls for the EXTRACTOR, not exemptions from the gate, and the // comment here said otherwise until 2026-09-04. `interrupt` and // `get_session_extensions` are the genuinely ungated pair: `interrupt` - // requires the user's proof instead, and `get_session_extensions` is on - // the module header's open residual. + // requires the user's proof instead — on a keyless daemon too, which is + // the one way it differs from the Stop beside it (SD-11a, + // `reply::steer_refusal`) — and `get_session_extensions` is on the + // module header's open residual. Two more reply.rs controls sit on + // either side of the five rows that file contributes. // // BOTH sides in `agent.rs`: `agent_remove_extension` sits after the two // gated handlers' neighbourhood and `update_agent_provider` before it, // and a control on one side only passes against an extractor that // over-reads towards the other. for (src, control) in [ + (reply_rs, "fn attach_names_a_missing_turn("), (reply_rs, "pub async fn interrupt"), + (reply_rs, "pub fn routes("), (session_rs, "async fn get_session_extensions"), (agent_rs, "async fn agent_remove_extension"), (agent_rs, "async fn update_agent_provider"), @@ -1187,11 +1224,16 @@ mod tests { (status_rs, "async fn system_info("), (status_rs, "pub fn routes("), ] { - assert!( - !body_of(src, control).contains("session_reach("), - "the body scan is over-reading: {control} is not on the gated list and \ - reported the gate" - ); + // Both spellings the rows above use, so a control is a control for + // every row it could be over-reading into. + let body = body_of(src, control); + for gate in ["session_reach(", "authorize_turn_control("] { + assert!( + !body.contains(gate), + "the body scan is over-reading: {control} is not on the gated list and \ + reported the gate (`{gate}`)" + ); + } } } diff --git a/crates/biorouter-server/tests/turn_control_no_user_key.rs b/crates/biorouter-server/tests/turn_control_no_user_key.rs new file mode 100644 index 000000000..d9b026d07 --- /dev/null +++ b/crates/biorouter-server/tests/turn_control_no_user_key.rs @@ -0,0 +1,618 @@ +//! SD-11: on a daemon that holds no proof-of-user key — the one `biorouter +//! serve` starts (SD-7), or a `biorouterd` started by hand — stopping and +//! settling a turn admit exactly the callers `POST /agent/stop` already admits +//! there: a chat the caller can reach, and never a subagent's. +//! +//! Measured on 2026-09-11 from a browser page on a real `biorouter serve`: +//! `POST /agent/cancel` and `POST /interrupt` both answered 403 with an empty +//! body, capability header and all, because each began with an unconditional +//! `is_user_action` check that a keyless daemon can never pass. The browser's +//! Stop button and mid-turn steering could not work on any `serve` host, one +//! configured with a public model included. +//! +//! ⚠ **Three of those four moved; `POST /interrupt` did not** (SD-11a, the +//! security review's correction). The keyless arm rests on a dominance +//! argument — the same caller already stops that turn through `/agent/stop`, +//! and already puts text in front of that chat's model through `/reply` — and +//! for the steer that argument is false: `/reply` is refused `409` by the BR-33 +//! single-turn lock in the exact state where a steer is meaningful, so the +//! dominating route cannot reach it. `/interrupt` therefore still asks for the +//! proof on every daemon, and a keyless one refuses it **in words**, which is +//! what keeps `biorouter session attach` from asking for a key that does not +//! exist. Both halves are pinned below. +//! +//! ⚠ **Its own test binary on purpose**, for the reason `approval_no_user_key.rs` +//! gives: the installed digest is a process-global `OnceLock`, the lib's tests +//! install one, and inside that binary the keyless state is unreachable once the +//! first of them wins. Nothing here installs a digest — which is exactly how +//! `biorouter serve` starts its daemon. What a daemon that DOES hold a key does +//! with the same requests is pinned by the lib's own tests +//! (`routes::reply`'s `cancel_without_user_action_proof_cannot_stop_another_turn` +//! and `interrupt_without_user_action_proof_cannot_forge_human_steering`), and +//! this change leaves it alone. + +// Redirects this binary's Biorouter data/config/state dirs at a throwaway root +// before `main`, so nothing here can open the developer's real `sessions.db`. +#[path = "../src/test_sandbox.rs"] +mod test_sandbox; + +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{HeaderMap, Request, StatusCode}; +use axum::Router; +use biorouter::agents::{Drained, TurnId}; +use biorouter::model::ModelConfig; +use biorouter::privacy::SessionClassification; +use biorouter::session::session_manager::SessionType; +use biorouter_server::auth::{user_action_proof, UserActionProof}; +use biorouter_server::routes::reply::STEER_NO_KEY; +use biorouter_server::routes::session_reach::SESSION_REACH_NO_KEY; +use biorouter_server::state::AppState; +use serde_json::{json, Value}; +use serial_test::serial; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; + +/// Every test here stands on this: the daemon under test holds no key. +fn assert_the_daemon_is_keyless() { + assert_eq!( + user_action_proof(&HeaderMap::new()), + UserActionProof::NoKeyInstalled, + "something in this binary installed a user-action digest, so these tests would be \ + measuring a desktop daemon rather than a `biorouter serve` one" + ); + assert!( + biorouter::privacy::privacy_tiers_enabled(), + "privacy tiers are off, so the reach gate below would admit everything either way" + ); +} + +/// The chats these tests stop and steer. Every one is a real row, because the +/// keyless arm reads the row (reach, then the subagent rule) before it touches +/// the turn — unlike the lib's tests, which name ids that were never created. +#[derive(Clone, Copy, Debug)] +enum Chat { + /// An ordinary chat on a public model: what a browser on a `serve` host + /// configured with a public model opens. + Public, + /// An ordinary chat a private model's turn has ratcheted. + Private, + /// A delegated child's session. + Subagent, +} + +async fn seed(state: &Arc, chat: Chat) -> String { + let manager = state.session_manager(); + let session_type = match chat { + Chat::Subagent => SessionType::SubAgent, + Chat::Public | Chat::Private => SessionType::User, + }; + let session = manager + .create_session( + std::env::temp_dir(), + format!("SD-11 {chat:?} (test fixture)"), + session_type, + ) + .await + .unwrap(); + if matches!(chat, Chat::Private) { + // Raised the way a real chat gets there — a turn on a private provider — + // rather than by writing the column, so what is refused here is the + // state a user's own chat reaches. + manager + .update(&session.id) + .provider_name("versa_azure") + .model_config(ModelConfig::new("gpt-4o").unwrap()) + .raise_privacy(SessionClassification::Private, "turn:versa_azure") + .apply() + .await + .unwrap(); + } + session.id +} + +async fn discard(state: &Arc, session_id: &str) { + // The tests run serially, so every cached agent is this test's. + state.clear_cached_agents().await; + let _ = state.session_manager().delete_session(session_id).await; +} + +/// The headers a browser tab sends on a `serve` daemon: the page's shim answers +/// the user-action key with an empty string, and a caller that runs under a +/// private model states it in `X-Caller-Provider`, as `biorouter session` does +/// from a terminal. +fn post(uri: &str, body: Value, caller_provider: Option<&str>) -> Request { + let mut request = Request::builder() + .uri(uri) + .method("POST") + .header("content-type", "application/json") + .header("X-User-Action", ""); + if let Some(provider) = caller_provider { + request = request.header("X-Caller-Provider", provider); + } + request.body(Body::from(body.to_string())).unwrap() +} + +async fn send(app: Router, request: Request) -> (StatusCode, String) { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = tokio::time::timeout( + Duration::from_secs(60), + axum::body::to_bytes(response.into_body(), usize::MAX), + ) + .await + .expect("the response body did not finish within a minute") + .unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +fn reply_routes(state: &Arc) -> Router { + biorouter_server::routes::reply::routes(Arc::clone(state)) +} + +fn json_of(body: &str) -> Value { + serde_json::from_str(body).unwrap_or_else(|_| panic!("not JSON: {body}")) +} + +/// Begin a turn on `session_id` the way `/reply` does, holding its lock. +fn begin_turn( + state: &Arc, + session_id: &str, +) -> (biorouter_server::state::TurnGuard, CancellationToken) { + let token = CancellationToken::new(); + let guard = state + .try_begin_turn_idempotent(session_id, token.clone(), None) + .expect("the turn lock is free"); + (guard, token) +} + +/// A Stop, in the plain form a script sends. The browser's store +/// (`requestExactTurnSettlement` in `chatStreamStore.tsx`) also names the exact +/// generation and waits for it to settle; the Stop-and-Send test below sends +/// that fuller body. +fn stop_request(session_id: &str, caller_provider: Option<&str>) -> Request { + post( + "/agent/cancel", + json!({ "session_id": session_id }), + caller_provider, + ) +} + +fn steer_request(session_id: &str, text: &str, caller_provider: Option<&str>) -> Request { + post( + "/interrupt", + json!({ "session_id": session_id, "text": text, "turn_id": "browser-steer-1" }), + caller_provider, + ) +} + +/// The failure the QA run measured, as its own regression test: the browser's +/// Stop, on an ordinary public chat, on a daemon with no key. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_stops_a_turn_in_a_chat_the_caller_can_reach() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let id = seed(&state, Chat::Public).await; + let (guard, token) = begin_turn(&state, &id); + + let (status, body) = send(reply_routes(&state), stop_request(&id, None)).await; + + assert_eq!( + status, + StatusCode::OK, + "the browser's Stop was refused on a keyless daemon: {body}" + ); + let body = json_of(&body); + assert_eq!(body["cancelled"], json!(true), "{body}"); + assert_eq!(body["turn_id"], json!(guard.turn_id()), "{body}"); + assert!(token.is_cancelled(), "a 200 that did not trip the turn"); + + drop(guard); + discard(&state, &id).await; +} + +/// **The one of the four that did not move, and the shape of its refusal.** +/// +/// ⚠ **Why the steer is excluded.** SD-11's keyless arm is admitted on a +/// dominance argument: the caller already stops that turn through `/agent/stop` +/// and already puts text in front of that chat's model through `/reply`. The +/// second half does not hold here. `/reply` takes the BR-33 single-turn lock +/// (`try_begin_turn_idempotent_with_continuation`) and answers `409` for a +/// *different* turn while one is running; `/interrupt` answers `409` when none +/// is. The two preconditions are disjoint, so in the exact state where a steer +/// lands, the route said to dominate it is refused. What admitting it would add +/// is genuinely new: attacker-chosen text injected into a turn already in +/// flight, without cancelling it, indistinguishable in the transcript from what +/// the person watching typed. Cancel-then-reply — the nearest thing a caller +/// holding only the daemon secret already has — kills the turn first, and is +/// therefore visible. +/// +/// ⚠ **And why the refusal carries a sentence rather than being 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`): empty means "this daemon holds a key", and it +/// prompts for one. A keyless daemon has no key to be typed, so an empty refusal +/// here would send a `serve` user hunting for a credential that does not exist. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_refuses_the_steer_it_admits_the_stop_for() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let id = seed(&state, Chat::Public).await; + let (guard, token) = begin_turn(&state, &id); + // #69: acceptance is the agent loop's to give, so the agent must be in the + // state a running loop puts it in; the turn lock alone is not enough. + let agent = state.get_agent(id.clone()).await.unwrap(); + agent.open_for_turn(TurnId::new("keyless-agent-turn")); + + let (status, body) = send( + reply_routes(&state), + steer_request(&id, "actually, use R", None), + ) + .await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "a keyless daemon injected text into a running turn: {body}" + ); + assert!( + body.contains(STEER_NO_KEY), + "the steer refusal must say it is this daemon that cannot check a proof, and must not \ + be empty — an empty turn-control 403 is how `biorouter session attach` decides to \ + prompt for a user-action key: {body:?}" + ); + assert!( + !agent.has_soft_interrupts(), + "a refused steer reached the agent's queue" + ); + assert!(matches!(agent.close_and_drain(), Drained::Empty)); + + // …while the Stop the same caller aims at the same turn is admitted. This + // pairing is the finding: three of the four routes moved and this one did + // not, so the difference is asserted in one place rather than inferred from + // two tests that could drift apart. + let (status, body) = send(reply_routes(&state), stop_request(&id, None)).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(token.is_cancelled(), "a 200 that did not trip the turn"); + + drop(guard); + discard(&state, &id).await; +} + +/// The steer refusal is the SAME refusal for every chat, because the proof is +/// asked for before anything reads the row. +/// +/// Worth its own assertion rather than being left implicit: `session_reach` +/// takes care to answer a private chat and a nonexistent one identically so that +/// a refusal is not a per-id oracle, and a steer gate that refused three kinds of +/// chat in three different ways would rebuild exactly that oracle on a route +/// where no proof can ever be offered. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_steer_refusal_says_the_same_thing_about_every_chat() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let mut answers = Vec::new(); + for chat in [Chat::Public, Chat::Private, Chat::Subagent] { + let id = seed(&state, chat).await; + let (guard, token) = begin_turn(&state, &id); + for caller in [None, Some("versa_azure")] { + let (status, body) = send( + reply_routes(&state), + steer_request(&id, "pretend the user said this", caller), + ) + .await; + assert!(!token.is_cancelled(), "a refused steer reached the turn"); + answers.push((format!("{chat:?}/{caller:?}"), status, body)); + } + drop(guard); + discard(&state, &id).await; + } + // A chat that was never created, as the control: reach answers this one and + // a private chat identically, and so must the steer gate. + let (status, body) = send( + reply_routes(&state), + steer_request("no-such-session", "pretend the user said this", None), + ) + .await; + answers.push(("absent/None".to_string(), status, body)); + + let (_, first_status, first_body) = &answers[0]; + for (label, status, body) in &answers { + assert_eq!(status, first_status, "{label}: {body}"); + assert_eq!(body, first_body, "{label}"); + } + assert_eq!(*first_status, StatusCode::FORBIDDEN); + assert!(first_body.contains(STEER_NO_KEY), "{first_body}"); +} + +/// Stop-and-Send mints a continuation lease, and a live lease blocks every turn +/// that does not present it. So a keyless daemon that let the cancel through and +/// refused the abandon would wedge the chat the first time a user removed a +/// queued message — this is why the settle routes move with the cancel. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn stop_and_send_settles_and_its_lease_can_be_abandoned_on_a_keyless_daemon() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let id = seed(&state, Chat::Public).await; + let (guard, token) = begin_turn(&state, &id); + let turn_id = guard.turn_id().to_string(); + + let cancel = tokio::spawn(send( + reply_routes(&state), + post( + "/agent/cancel", + json!({ + "session_id": id, + "expected_turn_id": turn_id, + "wait_for_idle": true, + "continuation_pending": true, + "continuation_owner_id": "browser-window-a", + }), + None, + ), + )); + tokio::time::timeout(Duration::from_secs(10), async { + while !token.is_cancelled() && !cancel.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the Stop-and-Send request neither tripped the turn nor answered"); + // The turn unwinds: its guard retires, which is what settles the cancel. + drop(guard); + let (status, body) = cancel.await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "Stop-and-Send was refused on a keyless daemon: {body}" + ); + let body = json_of(&body); + assert_eq!(body["settled"], json!(true), "{body}"); + let lease = body["continuation_lease"] + .as_str() + .unwrap_or_else(|| panic!("Stop-and-Send returned no lease: {body}")) + .to_string(); + + assert!( + state + .try_begin_turn_idempotent(&id, CancellationToken::new(), None) + .is_err(), + "a live continuation lease must hold the chat for its replacement" + ); + + let (status, body) = send( + reply_routes(&state), + post( + "/agent/continuation/abandon", + json!({ "session_id": id, "continuation_lease": lease }), + None, + ), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "the lease a keyless cancel minted could not be abandoned, so the chat is wedged: {body}" + ); + assert_eq!(json_of(&body)["resolution"], json!("abandoned")); + let successor = state + .try_begin_turn_idempotent(&id, CancellationToken::new(), None) + .expect("an abandoned lease must release the chat"); + + drop(successor); + discard(&state, &id).await; +} + +/// A reload between the cancel and the replacement: the window asks for its +/// pending continuation back by its owner id, or gives the group up. Refusing +/// this on a keyless daemon strands the same live lease the abandon route +/// above releases. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_hands_a_pending_continuation_back_to_its_window() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let id = seed(&state, Chat::Public).await; + // A generation that has already retired mints its lease at once, with no + // settlement to wait for — the reload case, where the turn is long gone. + let (retired, _token) = begin_turn(&state, &id); + let retired_id = retired.turn_id().to_string(); + drop(retired); + let (status, body) = send( + reply_routes(&state), + post( + "/agent/cancel", + json!({ + "session_id": id, + "expected_turn_id": retired_id, + "wait_for_idle": true, + "continuation_pending": true, + "continuation_owner_id": "browser-window-b", + }), + None, + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(json_of(&body)["continuation_lease"].is_string(), "{body}"); + + let recover = |action: &str| { + post( + "/agent/continuation/recover", + json!({ + "session_id": id, + "superseded_turn_id": retired_id, + "continuation_owner_id": "browser-window-b", + "action": action, + }), + None, + ) + }; + let (status, body) = send(reply_routes(&state), recover("take_over")).await; + assert_eq!( + status, + StatusCode::OK, + "a reloaded window could not take its continuation back on a keyless daemon: {body}" + ); + let body = json_of(&body); + assert_eq!(body["resolution"], json!("taken_over"), "{body}"); + assert!(body["continuation_lease"].is_string(), "{body}"); + + let (status, body) = send(reply_routes(&state), recover("abandon")).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(json_of(&body)["resolution"], json!("abandoned")); + let successor = state + .try_begin_turn_idempotent(&id, CancellationToken::new(), None) + .expect("an abandoned group must release the chat"); + + drop(successor); + discard(&state, &id).await; +} + +/// Reach decides a private chat, exactly as it decides reading one: a caller +/// whose stated capability covers it is admitted, one that states nothing is +/// refused with the keyless daemon's own sentence, and the refused request +/// touches neither the turn nor the agent's queue. +/// +/// Stop only. The steer never reaches reach on this daemon — its own gate +/// refuses it first, identically for every chat, which is +/// `a_keyless_steer_refusal_says_the_same_thing_about_every_chat` above. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_private_chat_is_stopped_only_by_a_caller_whose_capability_covers_it() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let id = seed(&state, Chat::Private).await; + let (guard, token) = begin_turn(&state, &id); + let agent = state.get_agent(id.clone()).await.unwrap(); + agent.open_for_turn(TurnId::new("private-agent-turn")); + + for request in [ + stop_request(&id, None), + // A tier is not a provider: the daemon resolves the NAME against its own + // registry, and a spelled-out tier resolves Public. + stop_request(&id, Some("private")), + ] { + let (status, body) = send(reply_routes(&state), request).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert!( + body.contains(SESSION_REACH_NO_KEY), + "refused, but not by the reach gate's keyless sentence: {body}" + ); + } + assert!(!token.is_cancelled(), "a refused Stop reached the turn"); + assert!( + !agent.has_soft_interrupts(), + "a refused request reached the agent's queue" + ); + + let (status, body) = send(reply_routes(&state), stop_request(&id, Some("versa_azure"))).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(token.is_cancelled()); + + drop(guard); + discard(&state, &id).await; +} + +/// A subagent's tab stays the person's, on this daemon as on every other: the +/// parent is told when a human intervened in its child, and only the proof can +/// establish that one did. `/reply` and `/agent/stop` refuse the same caller +/// there already. The refusal names this daemon's situation rather than +/// telling a person at the keyboard to go and prove they are one (SD-8). +/// +/// The Stop is refused by the subagent rule inside `authorize_agent_control`; +/// the steer is refused one step earlier, by its own gate, which refuses every +/// chat on a keyless daemon. Both sentences open the same way, which is what +/// this asserts — a person is told about the daemon either way. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_subagents_turn_is_still_refused_and_the_refusal_says_why() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let id = seed(&state, Chat::Subagent).await; + let (guard, token) = begin_turn(&state, &id); + let agent = state.get_agent(id.clone()).await.unwrap(); + agent.open_for_turn(TurnId::new("child-agent-turn")); + + for request in [ + stop_request(&id, None), + steer_request(&id, "pretend the user said this", None), + stop_request(&id, Some("versa_azure")), + ] { + let (status, body) = send(reply_routes(&state), request).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert!( + body.contains("without a user-action key"), + "a keyless daemon's refusal must say it is this daemon, not the caller, that \ + cannot prove a person acted: {body}" + ); + } + assert!( + !token.is_cancelled(), + "a refused Stop reached a child's turn" + ); + assert!( + !agent.has_soft_interrupts(), + "a refused steer reached a child's queue" + ); + + drop(guard); + discard(&state, &id).await; +} + +/// **The premise SD-11 stands on, pinned.** On a keyless daemon `/agent/stop` +/// already cancels a running turn for exactly the callers turn control now +/// admits — reach, and never a subagent's chat — so admitting them to +/// `/agent/cancel` gives nothing holding the daemon secret a capability it did +/// not have. If `/agent/stop` is ever tightened on such a daemon, this test +/// fails, and the ruling has to be re-argued rather than silently outlived. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_cancel_admits_exactly_the_callers_agent_stop_already_admits() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + for (chat, caller, admitted) in [ + (Chat::Public, None, true), + (Chat::Private, None, false), + (Chat::Private, Some("versa_azure"), true), + (Chat::Subagent, None, false), + (Chat::Subagent, Some("versa_azure"), false), + ] { + let mut verdicts = Vec::new(); + for route in ["/agent/stop", "/agent/cancel"] { + let id = seed(&state, chat).await; + // `/agent/stop` evicts the agent, and answers 404 when there is none + // to evict; give it one so its answer is about the gate alone. + state.get_agent(id.clone()).await.unwrap(); + let (guard, token) = begin_turn(&state, &id); + let app = if route == "/agent/stop" { + biorouter_server::routes::agent::routes(Arc::clone(&state)) + } else { + reply_routes(&state) + }; + let (status, body) = send(app, post(route, json!({ "session_id": id }), caller)).await; + assert_eq!( + status.is_success(), + token.is_cancelled(), + "{route} on a {chat:?} chat answered {status} but the turn's cancellation \ + disagrees: {body}" + ); + verdicts.push((route, status, token.is_cancelled())); + drop(guard); + discard(&state, &id).await; + } + for (route, status, cancelled) in &verdicts { + assert_eq!( + *cancelled, admitted, + "{route} on a {chat:?} chat with caller {caller:?} answered {status}; expected \ + admitted={admitted}. All verdicts: {verdicts:?}" + ); + } + } +} diff --git a/crates/biorouter/tests/privacy_guard_wiring.rs b/crates/biorouter/tests/privacy_guard_wiring.rs index bb42dea5c..95cc00c81 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -322,7 +322,12 @@ const REGISTRY: &[Guard] = &[ kind: SiteKind::Guard, what: "`POST /agent/resume`, `POST /agent/update_from_session`, and `POST \ /agent/update_working_dir`, plus the shared `authorize_agent_control` \ - gate used by provider, extension, stop, and restart mutations", + gate used by provider, extension, stop, and restart mutations. On a \ + daemon that holds no user-action key that same gate is also the \ + turn-control gate of `/agent/cancel` and the two continuation routes \ + (SD-11), reached from `routes::reply`'s `authorize_turn_control` by \ + name — so SD-11 added no call here. `/interrupt` keeps the proof on \ + every daemon and reaches neither gate", }, Site { file: "crates/biorouter-server/src/routes/mod.rs", @@ -337,7 +342,11 @@ const REGISTRY: &[Guard] = &[ kind: SiteKind::Guard, what: "`POST /reply`, which runs an agent turn with tools inside the named \ session, plus the explicit continuation takeover and group-abandon \ - recovery mutation", + recovery mutation. `/agent/cancel` and `/agent/continuation/abandon` reach \ + the gate only on a keyless daemon, and through `authorize_agent_control` \ + in `routes/agent.rs` (SD-11), which is why they add nothing to this count; \ + `/interrupt` keeps the user-action proof on every daemon and reaches no \ + reach gate at all", }, Site { file: "crates/biorouter-server/src/routes/session.rs", diff --git a/docs/deployment/browser-access.md b/docs/deployment/browser-access.md index 44526d471..3576e0416 100644 --- a/docs/deployment/browser-access.md +++ b/docs/deployment/browser-access.md @@ -214,6 +214,9 @@ differs: |---|---| | Chat, sessions, history, extensions, skills, knowledge bases, workflows | Work as they do in the desktop application. | | Workspace control, several conversations at once, live app agents | Work — these are WebSocket-backed daemon routes, reached on the same origin. | +| Stopping a response, Stop and send | Work in an ordinary chat ([SD-11](serve-decisions.md#sd-11--stop-works-on-a-daemon-with-no-key-steering-does-not-and-a-subagents-tab-stays-the-persons)). | +| Steering a response while it runs | **Not available.** Injecting text into a turn that is already running needs proof that a person acted, which only the desktop application holds. What you type is queued instead and sent when the turn ends, so nothing is lost — it simply does not redirect the answer in flight. | +| A delegated subagent's own tab | **Read-only.** Sending to a subagent, steering it and stopping it from its tab need proof that a person acted, which only the desktop application holds. The tab does not yet say so before you try. | | Model and provider selection | **Not available.** See [The model is fixed before you start](#the-model-is-fixed-before-you-start). | | File and folder pickers | No native dialog. You type a path, and it is a path **on the machine running the daemon**, not on the machine holding the browser. | | Artifacts and diagnostics bundles | The artifact side panel works as usual. Opening an artifact outside the panel opens a new tab; a diagnostics bundle downloads as a file. | diff --git a/docs/deployment/programmatic-session-access.md b/docs/deployment/programmatic-session-access.md index 7d976e883..281f8954a 100644 --- a/docs/deployment/programmatic-session-access.md +++ b/docs/deployment/programmatic-session-access.md @@ -175,6 +175,7 @@ one of them resolves the target's tier **before** it touches the session, so a r | `POST /agent/update_working_dir` | Repoints the session at a directory. | | `POST /agent/add_extension` · `remove_extension` | Attaches or detaches tools. | | `GET`/`POST /knowledge/active` | Reads or repoints the session's knowledge bases. | +| `POST /agent/cancel` · `POST /agent/continuation/abandon` · `recover` | Stops or settles the session's running turn — **on a daemon that holds no user-action key only**, such as `biorouter serve` or a hand-run `biorouterd` ([SD-11](serve-decisions.md#sd-11--stop-works-on-a-daemon-with-no-key-steering-does-not-and-a-subagents-tab-stays-the-persons)). There they admit exactly the callers `POST /agent/stop` admits, a subagent's session excepted. `POST /interrupt` is **not** among them: it takes the proof on either kind of daemon, because injecting text into a turn already running is the one thing no other route on a keyless daemon can do. | ## What the header does *not* cover @@ -186,7 +187,8 @@ it would be wrong: | Route | What guards it instead | |---|---| -| `POST /interrupt`, `POST /agent/cancel`, `POST /agent/continuation/abandon` | `X-User-Action` — steering a turn is the user's decision, not a capability. | +| `POST /interrupt` | `X-User-Action` on **every** daemon — steering a turn that is already running is the user's decision, not a capability, and no other route on a keyless daemon reaches into a turn in flight. A keyless daemon's refusal says so in words rather than with an empty `403`. | +| `POST /agent/cancel`, `POST /agent/continuation/abandon` · `recover` | On a daemon that holds a user-action key — the desktop application's — `X-User-Action` and nothing else. A daemon that holds no key cannot check the proof at all, so these routes honour the header there instead (the table above). | | `POST /sessions/{id}/declassify`, `POST /sessions/{id}/diverge`, `POST /sessions/{id}/edit_message` | `X-User-Action` — these change or copy a classification, which no model may decide. | | `POST /agent/cross_affiliation_grant`, `POST /action-required/tool-confirmation` | `X-User-Action`, plus a decision-authority check on the resolving surface. | | `POST /knowledge/bases/{id}/ingest-conversation` | Its own Gate G: capability is derived from the model named in the request body, and every selected conversation is checked against it before a transcript is rendered. | @@ -233,8 +235,11 @@ not evidence the session exists. **`403` saying the daemon was started without a user-action key.** A different state: this daemon holds no key with which to verify a human, which is normal for `just run-server`, a hand-run `biorouterd agent`, and `biorouter serve`. The capability header is unaffected and still admits a -private chat on that daemon — a capable caller never reaches this message, because capability is -checked before proof. If you are seeing it, your caller is public. +private chat on that daemon — a capable caller never reaches the reach gate's version of this +message, because capability is checked before proof. If you are seeing it, your caller is public, +or the session is a delegated subagent's: changing, stopping or steering a subagent from its tab +needs proof that a person acted, whatever the capability, and that refusal says so in its own +words. **The header worked yesterday and now does not.** The tier came from the daemon's own registry, so a provider removed from `config.yaml`, or renamed, now resolves public. diff --git a/docs/deployment/serve-decisions.md b/docs/deployment/serve-decisions.md index 24b74e3a5..3921c5500 100644 --- a/docs/deployment/serve-decisions.md +++ b/docs/deployment/serve-decisions.md @@ -18,7 +18,8 @@ This page records the decisions that replaced that arrangement. They were taken several of them only make sense as a set: the reason a browser session cannot switch models (SD-1) is also the reason it needs no proof-of-user mechanism, which is the reason the daemon can be spawned with a closed stdin (SD-7) — and the reason every control that needs that proof -must say so before the user reaches for it (SD-8). Read [the architecture](serve-architecture.md) +must say so before the user reaches for it (SD-8), and the reason the controls that stop and settle +a turn answer to the reach gate there instead (SD-11). Read [the architecture](serve-architecture.md) for how the result is built, and [browser access](browser-access.md) for how to use it. Records are identified `SD-n` — *serve decision*. The numbering is stable; a superseded record @@ -284,6 +285,179 @@ behaviour, so changing it means revisiting this record, not making a quiet fix. --- +## SD-11 — Stop works on a daemon with no key; steering does not, and a subagent's tab stays the person's + +**Ruling.** On a daemon that holds no user-action key — the one `biorouter serve` starts (SD-7), or +a `biorouterd` started by hand — **three** of the four routes that control a running turn admit +exactly the callers `POST /agent/stop` already admits there: + +| Route | What it does | +|---|---| +| `POST /agent/cancel` | Stop, and the first half of Stop-and-Send. | +| `POST /agent/continuation/abandon` | Gives up a Stop-and-Send replacement. | +| `POST /agent/continuation/recover` | Takes a Stop-and-Send replacement back after a reload, or gives it up. | + +That gate is `authorize_agent_control` in `routes/agent.rs`, called by name rather than written +again: the reach rule — every public chat, and a private one only for a caller whose stated +capability covers it — and then no subagent's chat. + +**`POST /interrupt` — mid-turn steering — is excluded, and refuses in words.** It asks for the +user-action proof on **both** kinds of daemon, so on a keyless one it is unavailable to everybody, +the person at the browser included. Its refusal is a `403` carrying +`reply.rs::STEER_NO_KEY`, a sentence naming this daemon as what cannot check a proof rather than +telling a person to go and prove they are one (SD-8) — and never an empty body, because an empty +turn-control `403` is how `biorouter session attach` recognises a daemon that *does* hold a key and +decides to ask the person for it. *Why* is the section below. + +Two things hold beside all four: + +- A daemon that holds a key — the desktop application's — is unchanged. These routes take the proof + there and nothing else, and a steer admitted there is stamped `UserDirect`, exactly as before. +- A subagent's chat is refused on a keyless daemon, as `/reply` and `/agent/stop` already refuse it + there, and the refusal now names the daemon, not the caller, as what cannot prove a person acted + (SD-8). + +**Why.** The four routes asked for proof of a person before anything else, and on a daemon that +holds no key that proof can only refuse everyone, the person at the browser included. Measured on +2026-09-11 against a real `biorouter serve`, from the page, with the daemon secret and the host's +provider stated: `POST /agent/cancel` and `POST /interrupt` both answered `403` with an empty body. +The Stop button could stop nothing on any `serve` host, including one configured with a public +model. `crates/biorouter-server/tests/turn_control_no_user_key.rs` reproduces it on the code this +record changed: on a keyless daemon and an ordinary public chat, `/agent/stop` answered `200` and +cancelled the turn while `/agent/cancel` answered `403`. Stop is the control that ends a runaway +turn, and a chat that cannot be stopped from the only interface it has is less safe, not more. + +And the refusal protected nothing. The proof is on these routes so that a model holding the daemon +secret, which AR-11 found recoverable, cannot stop another chat's turn or put words in a person's +mouth. On a keyless daemon that caller already does each of those next door: + +| What the refusal withheld | Where the same caller already does it on a keyless daemon | Guarded by | +|---|---|---| +| Cancel the running turn of a chat it can reach | `POST /agent/stop`, which trips the same turn and evicts its agent besides | `authorize_agent_control` — the gate this record adopts | +| The same, as a model holding no secret at all | `workspace_close { scope: "turn" }` | `refuse_unless_writable`: the tier, and nothing else | +| Put text in front of the chat's model as the user, **before or after a turn** | `POST /reply`, which a non-subagent chat accepts, unstamped, from any caller that reaches it | `session_reach` | +| Give up a chat's pending Stop-and-Send | `workspace_close { scope: "turn" }`, which abandons every pending continuation of the chat it names | `refuse_unless_writable` | + +So admitting a caller to those three gives nothing that holds the secret a capability it lacked. +The person gains the Stop button and Stop-and-Send, which the desktop application has always had. + +⚠ **The third row of that table is the whole argument for the steer, and it does not hold.** +`POST /reply` takes the BR-33 single-turn lock and answers `409 CONFLICT` whenever a *different* +turn is already running in that chat; `POST /interrupt` is meaningful only while one is, and +answers `409` when none is. The two preconditions are **disjoint**: in the exact state where a +steer lands, the route said to dominate it is refused. So admitting the steer would hand a caller +holding only the daemon secret something genuinely 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. The nearest thing that caller already has is cancel-then-reply, and that is +**visible**: the turn dies first. It is not a tier crossing — `session_reach` still refuses a +private chat to a public caller, and the subagent rule still refuses every child's chat — but it is +a capability asymmetry, and the dominance argument is the only thing this record had to offer for +it. Hence the exclusion. (Found in the security review of this change, before it merged; +`reply.rs::steer_refusal` carries the same reasoning at the code.) + +**Why the other three routes move together.** Stop-and-Send cancels with a continuation, and the +continuation mints a lease that holds the chat for its replacement: until the lease is used or +given up, every other turn in that chat is refused. A daemon that let the cancel through and +refused the abandon or the recover would wedge the chat the first time a person removed a queued +message or reloaded the page. The test binary above measures both. + +**What a browser user loses, and what happens instead.** On a keyless daemon the browser's steer +is refused, and the renderer's `steer()` already treats any refusal as "fall back to an ordinary +send" (`chatStreamStore.tsx`): the text is queued and delivered when the turn ends, rather than +injected into it or lost. That is the cost of the exclusion, and it is a delay rather than a +capability the person no longer has. + +**Why not stamp the keyless steer `UserDirect` and admit it?** This was the shape the record took +before the review. `UserDirect` is a claim that a person typed the text, and the subagent machinery +acts on it, so the steer would have had to arrive unstamped — which is what `/reply` gives the same +caller's message. But the stamp was never the problem: the *injection into a running turn* is, and +an unstamped steer still redirects the model mid-flight and still appears in the transcript beside +the person's own messages. + +**Why a subagent's tab is not included.** That tab is where `UserDirect` means something, and +`/reply` and `/agent/stop` already refuse an unproven caller there, on every daemon. Admitting turn +control alone would give that tab a Stop that works beside a composer that cannot send, and the +rule for the tab is a decision about the whole tab rather than one of its buttons. + +**Why not on every daemon.** On a daemon that holds a key the proof costs the person nothing — the +renderer attaches it to every request — and it still refuses a caller that cannot present it. +Relaxing it there buys the person nothing, and would admit an unstamped steer where the desktop has +always stamped one. + +**Who can do this, and what else reaches the same place.** The two questions every privacy control +answers in writing ([privacy tiers §3.1](../security/privacy-tiers.md#31-the-review-checklist--two-questions-every-control-answers-in-writing)): + +- *Who can initiate it.* On a keyless daemon, anything holding the daemon secret: the person in the + browser and, indistinguishably, a model running in a chat on that daemon that has recovered the + secret. Both reach the chats the reach gate admits them to, less every subagent's. On a daemon + that holds a key, only a request carrying the proof. +- *What else reaches the same place:* + + | Other entry point to the same capability | Reachable by | Guarded by | Where that guard is called | + |---|---|---|---| + | `POST /agent/stop` — cancels the running turn | user; any holder of the daemon secret | `authorize_agent_control`: reach, then the subagent rule | `routes/agent.rs::stop_agent` | + | `workspace_close { scope: "turn" \| "agent" }` — cancels the turn, abandons its pending continuations | model | `refuse_unless_writable` (the tier) | `agents/workspace_extension.rs::handle_close` | + | `POST /active_work/{id}/cancel` — cancels a running subagent or background job by registry id | user; any holder of the daemon secret | **nothing**: the id is not a session id, so the reach gate cannot be applied | `routes/active_work.rs::cancel_active_work`; an open residual in `session_reach.rs` | + | `POST /reply` — puts the caller's text in front of the chat's model | user; any holder of the daemon secret | `session_reach`, then the subagent rule | `routes/reply.rs::reply` | + | `workspace_send_prompt { mode: "steer" \| "turn" }` — the same, from another chat | model | `refuse_unless_writable`; the text arrives framed as `AgentInjection`, and a private-to-public write raises a first-crossing approval | `agents/workspace_extension.rs::handle_send_prompt` | + | `biorouter session attach` and `session cancel` → these routes | user at a terminal | the proof, which the CLI demands before it sends anything | `commands/session_watch.rs::build_user_action_post_request` | + + The third row is a finding rather than a guard: a subagent's work can be cancelled on any daemon + by a caller that holds the secret and reads the id from `GET /active_work`. It is the residual + `session_reach.rs` already records, and this record does not widen it. + +**Displaced alternatives.** + +- *Keep the refusal, and explain it before the click (SD-8).* Rejected. SD-8 is for a control whose + absence is safe to explain; this one is how a person ends a turn they did not want. A Stop button + that says "unavailable here" is honest and still leaves the turn running. +- *Point the browser's Stop at `/agent/stop`.* Rejected. It cancels whatever turn is running rather + than the generation the person saw, so a late click can kill a successor; it does not wait for the + turn to settle and has no Stop-and-Send; and it evicts the agent. It would give the browser a + blunter Stop than the desktop's, to route around a refusal that guards nothing. +- *Relax the routes on every daemon.* Rejected; see *Why not on every daemon*. +- *Admit `/interrupt` too, with the steer arriving unstamped.* Rejected in review; see the ⚠ + paragraph under *Why* and *Why not stamp the keyless steer `UserDirect` and admit it?*. +- *Admit `/interrupt` and refuse it with an empty `403` like the others.* Rejected. `biorouter + session attach` reads an empty turn-control `403` as "this daemon holds a key" and prompts the + person for one; on a keyless daemon that prompt asks for a credential that does not exist and + then reports it as the wrong key. The refusal carries `STEER_NO_KEY` instead. +- *Admit plain Stop and refuse Stop-and-Send.* Rejected. The queue's "Stop and send" and a typed + "stop" in a busy composer both cancel with a continuation, so this would leave half of the Stop + controls refused, and a lease a person can mint must be a lease they can give up. + +**Consequence to accept.** On a keyless daemon, a model running in a chat on that daemon that has +recovered the daemon secret can stop or settle a turn in any chat the reach gate admits it to — a +public chat always, and a private one by stating a private provider, which the header does not +authenticate (`session_reach.rs` records as much). It could already stop those turns through +`/agent/stop` or `workspace_close`, and put text in front of those chats through `/reply`; what it +gains over that is the exact generation semantics and the Stop-and-Send lease, not a new reach. It +is recorded rather than closed: on a daemon that cannot tell a person from a model, closing it +means a Stop button nobody can press. **Steering a running turn is the line this stops at**, for +the reason above. + +**A daemon that is keyless by accident says so at startup.** Before this record, a desktop daemon +that came up without its key announced itself at the first click: Stop answered `403` and the user +complained. Now Stop works there, so the same misconfiguration is silent — a *weaker* daemon rather +than a visibly broken one. `read_user_action_digest` therefore reports **why** it holds no key +rather than returning one undifferentiated "none": stdin was a terminal, stdin closed with nothing +on it (`serve`'s `Stdio::null()`), a writer held the pipe open and wrote nothing inside the 2 s +bound, or the line was not a 32-byte hex digest. The last two are launcher faults and 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. The bound is +unchanged: nothing measured says the desktop launcher misses it, and what was missing was the +report, not the time. + +**Not decided here.** A subagent's tab in a browser still offers a composer, a steer and a Stop +that all refuse; SD-8 requires them to say so before the click, and they do not yet. An ordinary +browser chat's steer control is likewise still offered on a keyless daemon and still refuses — its +text falls back to the send queue, so nothing is lost, but SD-8's rule would have it say so first. +The CLI's `session cancel` and `attach` steering still demand a user-action key from the terminal +before they send anything, so against a keyless daemon they refuse locally a Stop the daemon would +now admit. + +--- + ## Related documentation - [Architecture of the serving path](serve-architecture.md) — how the decisions above are built. diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index cc6888530..0d20fa9c1 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -308,7 +308,7 @@ "description": "Unauthorized - invalid secret key" }, "403": { - "description": "The request was not proven to come from the user" + "description": "The request was not proven to come from the user; on a daemon that holds no user-action key, the chat is out of the caller's reach or is a subagent's (SD-11)" }, "409": { "description": "A different turn generation is active, another client owns the continuation, or its admission is still settling", @@ -357,7 +357,7 @@ } }, "403": { - "description": "The request was not proven to come from the user" + "description": "The request was not proven to come from the user; on a daemon that holds no user-action key, the chat is out of the caller's reach or is a subagent's (SD-11)" }, "409": { "description": "The lease is invalid or belongs to another session", @@ -403,7 +403,7 @@ "description": "The continuation owner id is missing or invalid" }, "403": { - "description": "The session is out of reach or the request was not proven to come from the user" + "description": "The session is out of reach or the request was not proven to come from the user; on a daemon that holds no user-action key, the chat is out of the caller's reach or is a subagent's (SD-11)" }, "409": { "description": "The exact continuation generation was already resolved", @@ -1864,7 +1864,7 @@ "description": "Empty message text" }, "403": { - "description": "The request was not proven to come from the user" + "description": "The request was not proven to come from the user; on a daemon that holds no user-action key, steering is unavailable and the refusal says so (SD-11)" }, "409": { "description": "No turn is accepting interrupts for this session" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index b35605218..1f8b84b25 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -4420,7 +4420,7 @@ export type CancelTurnErrors = { */ 401: unknown; /** - * The request was not proven to come from the user + * The request was not proven to come from the user; on a daemon that holds no user-action key, the chat is out of the caller's reach or is a subagent's (SD-11) */ 403: unknown; /** @@ -4457,7 +4457,7 @@ export type AbandonContinuationLeaseData = { export type AbandonContinuationLeaseErrors = { /** - * The request was not proven to come from the user + * The request was not proven to come from the user; on a daemon that holds no user-action key, the chat is out of the caller's reach or is a subagent's (SD-11) */ 403: unknown; /** @@ -4490,7 +4490,7 @@ export type RecoverContinuationErrors = { */ 400: unknown; /** - * The session is out of reach or the request was not proven to come from the user + * The session is out of reach or the request was not proven to come from the user; on a daemon that holds no user-action key, the chat is out of the caller's reach or is a subagent's (SD-11) */ 403: unknown; /** @@ -5652,7 +5652,7 @@ export type InterruptErrors = { */ 400: unknown; /** - * The request was not proven to come from the user + * The request was not proven to come from the user; on a daemon that holds no user-action key, steering is unavailable and the refusal says so (SD-11) */ 403: unknown; /**