diff --git a/CLAUDE.md b/CLAUDE.md index 194e52d7a..c72ca232e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -293,6 +293,26 @@ what did not" section first**; the rest of that document is the design, not the - **Knowledge bases ratchet too.** A base takes the tier of the most sensitive session that wrote to it (four write choke points), is refused to a public caller at the read choke points, and a refusal names what it refused. `biorouter-mcp/src/knowledge/tier*.rs`. +- **Holding the daemon secret does not make a caller the user.** That was the premise behind + leaving the `/knowledge/*` read routes, `GET /sessions` and `DELETE /sessions/{id}` ungated. A + public chat's shell recovers the secret with `ps eww`, and QA used it to read a private base and + delete a private chat (H2/M1/M2/F0, 2026-09-10). Every HTTP route that names a chat or a + knowledge base now asks `routes::session_reach`'s one decision: a private target needs the + user-action proof or a stated private capability. The rules that follow: + - A route that names one chat calls `session_reach`, and refuses with its exact plain text. + - Listings filter through `HttpCaller::lists_session`. + - Running work is the same rule reached through the row's chat. `GET /active_work` filters + through `HttpCaller::lists_work`, and its cancel asks `work_reach` before anything stops. A + row that names no chat is treated as a private chat's, so a registrant that knows its chat must + set `ActiveWorkItem::session_id`. The shell's rows take it from the `_meta` session id. + - Every `/knowledge/bases/{id}` route sits in `knowledge::router`'s `base_routes`, behind + `gate_knowledge_base`. Put any new `{id}` route there. + - ⚠ **The renderer must send `userActionHeaders()` on every such call.** A missing proof is not an + error: private rows silently vanish, and the Knowledge view's prune effects then read them as + deleted. + - A `biorouter serve` browser gets its operator's tier on listings and knowledge bases only + (SD-10). + - The wiring census (`crates/biorouter/tests/privacy_guard_wiring.rs`) counts every call site. - **Affiliation is a third axis** (DR-26, plan Phase 6): tier asks *how sensitive*, affiliation asks *whose*. HIPAA compliance does not transfer between institutions, so a UCSF model reaching another institution's private connector is warned/refused even though both endpoints are Private. @@ -1209,7 +1229,10 @@ Test the gate where it is: the unit tests in `agents/agent.rs` (`subagents_enabled_injects_the_workspace_extension_with_the_spawn_tool_only`, `an_explicit_workspace_entry_still_hides_the_spawn_tool_when_delegation_is_off`, `subagents_disabled_injects_nothing`), via -`cargo test -p biorouter --lib -- subagent` (102 tests). +`cargo test -p biorouter --lib -- subagent` (**198 tests, measured 2026-09-12** — this +line said 102 for long enough that a "pre + N" assertion against it would have read a +shortfall of ninety-six as a pass; re-measure rather than trusting the figure, which +moved 197 → 198 between this line being written and the branch carrying it landing). ### Browser access (`biorouter serve`) @@ -1217,7 +1240,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, SD-11), +[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (the `SD-n` records), [`serve-architecture.md`](docs/deployment/serve-architecture.md), [`browser-access.md`](docs/deployment/browser-access.md). @@ -1230,6 +1253,39 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de **agent**, so every surface that writes a capability key asks `isBrowserSurface()` (`ui/desktop/src/utils/surface.ts`) and explains *before* the user can reach the 409. Do not "fix" browser mode by weakening the refusal. +- **A new chat starts on the configured model without a proof, and nothing else does** (SD-12). + Until it, a `serve` daemon with a private provider configured refused EVERY `/agent/start` + (the 2026-09-10 QA's F1): the new-chat bind asked for a proof a keyless daemon cannot check. + Four pieces, each load-bearing — measured by removing it: on a keyless daemon + `new_chat_bind_decision` (`routes/agent.rs`) lets the configured default bind, while a keyed + daemon still refuses a proof-less private first bind; `raise_baseline` makes a keyless + daemon's `/agent/update_provider` measure every move onto a private model from Public, or the + exemption would carry sideways to a private model nobody configured; the browser states the + host's model as `X-Caller-Provider` (`userActionHeaders()` on `isBrowserSurface()`), without + which a chat's first reply ratcheted it private and its next request 403'd; and + `biorouter_server::launch` **pins the exemption to the configuration the daemon was launched + with**. Tests: + `cargo test -p biorouter-server --test new_chat_no_user_key` (its own binary: the digest is a + process-global `OnceLock`). + ⚠ **The exemption's first justification was FALSE and the fix is that pin.** It rested on + *"`/agent/start` binds `BIOROUTER_PROVIDER`, a key only a proven person may write"*. The HTTP + doors are shut, but `config.yaml` is not an HTTP resource: DR-14's filesystem deny is DEFERRED, + the agent holds `developer__shell`, and `Config`'s cache is keyed on a `FileStamp` it re-`stat`s + per read — so a model with a shell on a keyless daemon configured PUBLIC could write a private + provider into the file and get a 200 with Private capability where `main` answers 409 (measured + 2026-09-12). The pinned set is `privacy::CAPABILITY_CONFIG_KEYS` verbatim plus `BIOROUTER_MODEL`; + pinning the provider NAME alone is not enough, because flipping `OLLAMA_HOST` to loopback moves + `ollama`'s tier with the name untouched. ⚠ And `NoKeyInstalled` is **not** the same thing as + "this is `serve`" — a desktop spawn satisfies it when `userActionKey` is undefined or the + daemon's bounded 2s stdin read times out. That case is a repairable fault, so the desktop + launcher declares its intent in `BIOROUTER_USER_ACTION_EXPECTED` and such a daemon keeps + `main`'s refusal plus a startup `ERROR`. ⚠ `BIOROUTER_MODEL` is in neither capability-key list + by decision, not oversight: no `tier()` implementation reads the model name (all five checked), + so it is an integrity key and its row lives in `NOT_CAPABILITY_CONFIG_KEYS`. ⚠ **Still unreachable in a browser, and out of SD-12's scope:** + `/agent/cancel` and `/interrupt` require the proof unconditionally, so Stop and mid-turn + steering cannot work on a keyless daemon. ⚠ `privacy_ar15_is_retired.rs`'s closure scan took + the FIRST `TierRaiseNeedsUser` in `routes/agent.rs`, which from `eb594ded` was the new-chat gate + and not AR-15's — it now starts at `update_agent_provider`. - **A control that can never work here says so, before it is touched** (SD-8). The same `Stdio::null()` that closes SD-1 means NO approval carrying `requires_user_proof` can ever be granted on a `serve` daemon — for anyone, always. So `confirm_tool_action` answers a @@ -1250,11 +1306,18 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de 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::authorize_steer`). Its keyless refusal carries `STEER_NO_KEY` and is **never an + (`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`. + ⚠ **The CLI reads the refusal's shape, not its status.** `biorouter session cancel` / `attach` + / `send` cannot ask a daemon whether it holds a key, so they send without the proof and ask the + person for the key only on turn control's **empty** 403 — the keyed `Unproven` arm; every + keyless refusal carries a sentence and is printed instead (`key_verdict` in + `commands/session_watch.rs`). A sentence added to `Unproven`, or an empty keyless refusal, + breaks the terminal silently — one never prompts on the desktop's daemon, the other prompts a + `serve` user for a key that does not exist. Pinned from both sides. - **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/Cargo.lock b/Cargo.lock index e7e890ea9..8ef44c3f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,6 +1172,7 @@ dependencies = [ "tikv-jemalloc-ctl", "tikv-jemallocator", "tokio", + "tokio-tungstenite", "tokio-util", "tower-http 0.5.2", "tracing", diff --git a/crates/biorouter-cli/Cargo.toml b/crates/biorouter-cli/Cargo.toml index bf20acac4..0472a6da0 100644 --- a/crates/biorouter-cli/Cargo.toml +++ b/crates/biorouter-cli/Cargo.toml @@ -99,6 +99,9 @@ serial_test = { workspace = true } # The workspace's process-wide environment lock, so env-mutating tests here # exclude every other one rather than only the `#[serial]` ones. env-lock = { workspace = true } +# A WebSocket client, so `commands::web`'s tests can drive the page's socket the +# way the page does. The version axum's `ws` feature already locks. +tokio-tungstenite = "0.28.0" # Issue #56 DR-20 / Task 55. `biorouter session declassify ` raises the OS # authentication prompt, so its TESTS would type a real password on every run # without a stand-in. diff --git a/crates/biorouter-cli/src/cli.rs b/crates/biorouter-cli/src/cli.rs index 8649acf2b..6af98414f 100644 --- a/crates/biorouter-cli/src/cli.rs +++ b/crates/biorouter-cli/src/cli.rs @@ -532,6 +532,24 @@ fn parse_key_val(s: &str) -> Result<(String, String), String> { } } +/// ⚠ **An empty token is not a token, and `--auth-token ""` used to be accepted +/// as one.** Passing it made `validate_network_auth` see `Some(_)` and let +/// `--host 0.0.0.0` through, while `commands::web`'s middleware would then admit +/// anyone who sent `Authorization: Bearer ` with nothing after it — so the one +/// check whose entire job is to insist on protection was satisfied by its +/// absence. Refused here, at parse time, so the mistake cannot reach a bind; a +/// whitespace-only value is refused for the same reason. +pub(crate) fn parse_auth_token(s: &str) -> Result { + if s.trim().is_empty() { + return Err( + "an empty --auth-token is not a token; omit the flag to run without one (loopback \ + binds only), or pass a real secret" + .to_string(), + ); + } + Ok(s.to_string()) +} + #[derive(Subcommand)] enum SessionCommand { #[command(about = "List all available sessions")] @@ -668,7 +686,7 @@ enum SessionCommand { no_wait: bool, #[arg( long, - help = "Read the daemon's raw user-action key from the first line of stdin instead of prompting on the controlling terminal" + help = "For a daemon started with a user-action key: read the raw key from the first line of stdin, instead of being asked for it on the terminal once the daemon wants it" )] user_action_key_stdin: bool, }, @@ -699,7 +717,7 @@ enum SessionCommand { read_only: bool, #[arg( long, - help = "Read the daemon's raw user-action key from the first line of stdin instead of prompting on the controlling terminal" + help = "For a daemon started with a user-action key: read the raw key from the first line of stdin, instead of being asked for it on the terminal once the daemon wants it" )] user_action_key_stdin: bool, }, @@ -709,7 +727,7 @@ enum SessionCommand { session_id: String, #[arg( long, - help = "Read the daemon's raw user-action key from the first line of stdin instead of prompting on the controlling terminal" + help = "For a daemon started with a user-action key: read the raw key from the first line of stdin, instead of being asked for it on the terminal once the daemon wants it" )] user_action_key_stdin: bool, }, @@ -1616,7 +1634,11 @@ enum Command { port: u16, /// Use this access token instead of a freshly generated one - #[arg(long, help = "Use this access token instead of generating one")] + #[arg( + long, + help = "Use this access token instead of generating one. Takes precedence over \ + BIOROUTER_BROWSER_TOKEN, which is read when this is not given." + )] token: Option, /// Serve without an access token @@ -1672,7 +1694,11 @@ enum Command { open: bool, /// Authentication token for both Basic Auth (password) and Bearer token - #[arg(long, help = "Authentication token to secure the web interface")] + #[arg( + long, + value_parser = parse_auth_token, + help = "Authentication token to secure the web interface" + )] auth_token: Option, /// Allow running without authentication when exposed on the network (unsafe) diff --git a/crates/biorouter-cli/src/commands/apps.rs b/crates/biorouter-cli/src/commands/apps.rs index 87085371d..5734d9634 100644 --- a/crates/biorouter-cli/src/commands/apps.rs +++ b/crates/biorouter-cli/src/commands/apps.rs @@ -12,8 +12,26 @@ //! * `open` — ensure a daemon is up and open `http://:/apps//` //! in the default browser. //! * `serve` — ensure a daemon is up, print the URL, and stay in the foreground -//! until Ctrl-C (when it started the daemon) or return immediately with a note -//! (when it reused a running one). +//! until it is stopped (when it started the daemon) or return immediately with +//! a note (when it reused a running one). +//! +//! ## Which of the two owns the daemon's lifetime +//! +//! `open` is done the moment the browser has the URL, so a daemon it started is +//! left running deliberately — closing it would close the page that was just +//! opened. `serve` is the opposite: it stays in the foreground *because* it owns +//! the daemon, so every way out of it must take the daemon with it. That is the +//! same guarantee `biorouter serve` makes, through the same two layers and the +//! same code ([`crate::commands::serve::stop_daemon`] and `--exit-with-parent`): +//! a signal handler installed BEFORE the spawn, every exit path routed through a +//! bounded stop-then-kill, and on Unix a daemon that stops itself when its +//! launcher is gone. +//! +//! ⚠ It handled `ctrl_c` alone, and its Ctrl-C arm killed the child while +//! SIGTERM did not run at all — the default action ended this process on the +//! spot and left the daemon holding the port, the app and the daemon's secret. +//! `apps open` must keep passing [`Supervision::Detached`]: tying its daemon to +//! this process would kill the daemon as `open` returned. //! //! **Daemon management is deliberately minimal.** The CLI has no pre-existing //! biorouterd-supervision helper, so `open`/`serve` first health-check the @@ -35,6 +53,8 @@ use console::{style, Color}; use serde::Serialize; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use crate::commands::serve::{stop_daemon, StopSignals}; + const ACCENT: Color = Color::Color256(137); /// One row of the `apps list` output. Populated straight from `manifest.json` @@ -271,15 +291,36 @@ enum Daemon { Started(tokio::process::Child), } +/// Whether a daemon this command starts is tied to this process's lifetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Supervision { + /// `apps open`: the daemon outlives this command, which is the point — the + /// page it just opened needs it. + Detached, + /// `apps serve`: the daemon is this process's to stop, however this process + /// ends. On Unix it is also told to stop itself if this process is gone, + /// which covers the endings that run no code here at all. + TiedToThisProcess, +} + /// Ensure a daemon is reachable on the configured port, spawning one if needed. -async fn ensure_daemon(port: u16) -> Result { +async fn ensure_daemon(port: u16, supervision: Supervision) -> Result { if daemon_ok(DAEMON_HOST, port).await { return Ok(Daemon::Reused); } let bin = biorouterd_path(); - let mut child = tokio::process::Command::new(&bin) - .arg("agent") + let mut command = tokio::process::Command::new(&bin); + command.arg("agent"); + // ⚠ `Detached` must pass nothing: the flag makes the daemon stop when this + // process is gone, and `apps open` is gone as soon as it has opened the page. + #[cfg(unix)] + if supervision == Supervision::TiedToThisProcess { + command + .arg("--exit-with-parent") + .arg(std::process::id().to_string()); + } + let mut child = command .env("BIOROUTER_PORT", port.to_string()) // Issue #56 DR-16: `biorouterd agent` now reads one line off stdin at // startup (the launcher's user-action digest). `Command` INHERITS fd 0, @@ -292,6 +333,10 @@ async fn ensure_daemon(port: u16) -> Result { .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) + // A backstop for a panic unwinding out of `serve` before its stop runs. + // Every ordinary path goes through `stop_daemon`, which asks first. + // Harmless for `Detached`, whose child is deliberately leaked below. + .kill_on_drop(supervision == Supervision::TiedToThisProcess) .spawn() .map_err(|e| { anyhow!( @@ -302,7 +347,11 @@ async fn ensure_daemon(port: u16) -> Result { // Poll for readiness, but bail early if the child dies (e.g. the port is // already taken by a non-Biorouter process). - let deadline = tokio::time::Instant::now() + Duration::from_secs(25); + // + // The same minute `biorouter serve` allows. It was 25s, which a debug daemon + // on a loaded machine can miss — and missing it kills a daemon that was + // about to answer and reports a failure that is only a slow start. + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); loop { if let Ok(Some(status)) = child.try_wait() { bail!( @@ -315,7 +364,7 @@ async fn ensure_daemon(port: u16) -> Result { } if tokio::time::Instant::now() >= deadline { let _ = child.start_kill(); - bail!("biorouterd did not become ready on port {port} within 25s"); + bail!("biorouterd did not become ready on port {port} within 60s"); } tokio::time::sleep(Duration::from_millis(300)).await; } @@ -344,7 +393,7 @@ fn app_url(port: u16, id: &str) -> String { pub async fn handle_apps_open(id: String) -> Result<()> { require_app(&id)?; let port = configured_port(); - let daemon = ensure_daemon(port).await?; + let daemon = ensure_daemon(port, Supervision::Detached).await?; let url = app_url(port, &id); match daemon { @@ -375,14 +424,20 @@ pub async fn handle_apps_open(id: String) -> Result<()> { pub async fn handle_apps_serve(id: String) -> Result<()> { require_app(&id)?; let port = configured_port(); - let daemon = ensure_daemon(port).await?; + + // ⚠ BEFORE the spawn, and before anything that can park. A handler replaces + // the default action — which for SIGTERM is to end this process on the spot + // and leave the daemon behind — so from here on a signal waits to be read, + // including one that lands during the readiness wait inside `ensure_daemon`. + let mut stop = StopSignals::install()?; + let daemon = ensure_daemon(port, Supervision::TiedToThisProcess).await?; let url = app_url(port, &id); match daemon { Daemon::Reused => { // Reusing an external daemon: print the URL and a note, then exit 0. // We do not own its lifecycle, so there is nothing to keep in the - // foreground. + // foreground — and nothing of ours to stop. println!(" {} {}", style("→").fg(ACCENT), style(&url).bold()); println!( " {} reusing the daemon already running on port {port}; it keeps running after this command.", @@ -396,7 +451,8 @@ pub async fn handle_apps_serve(id: String) -> Result<()> { " {} serving on port {port}. Press Ctrl-C to stop.", style("✓").green() ); - // Stay in the foreground until the daemon exits or Ctrl-C. + // Stay in the foreground until the daemon exits or we are asked to + // stop — and then stop it, whichever of the two happened. tokio::select! { status = child.wait() => { match status { @@ -404,12 +460,13 @@ pub async fn handle_apps_serve(id: String) -> Result<()> { Err(e) => eprintln!(" {} waiting on biorouterd failed: {e}", style("!").yellow()), } } - _ = tokio::signal::ctrl_c() => { + _ = stop.recv() => { println!("\n {} stopping biorouterd…", style("·").dim()); - let _ = child.start_kill(); - let _ = child.wait().await; } } + // Asks, waits out the grace, then kills and reaps. A daemon that has + // already exited is only reaped, so this is safe on both arms. + stop_daemon(&mut child, &mut stop).await; Ok(()) } } diff --git a/crates/biorouter-cli/src/commands/schedule.rs b/crates/biorouter-cli/src/commands/schedule.rs index 3a069f941..19dde8bae 100644 --- a/crates/biorouter-cli/src/commands/schedule.rs +++ b/crates/biorouter-cli/src/commands/schedule.rs @@ -16,6 +16,30 @@ //! notices such a write itself (`Scheduler::spawn_file_watcher`). An agent's //! shell is always on this path: the daemon's secret is stripped from every tool //! child's environment (issue #57), and that is deliberate. +//! +//! ## Who says yes to a change made in the file +//! +//! PR #251 (QA 2026-09-10, F1) made `platform__manage_schedule` park an approval +//! card before it changes what the scheduler will do, because a standing +//! unattended agent run is the most consequential thing an agent can arrange and +//! it was arranging them with nobody asked. This command was the way round that +//! card: a chat with `developer__shell` and no schedule tool ran `biorouter +//! schedule add`, which wrote the file directly, and nobody was asked here +//! either. +//! +//! So a change on the FILE path now needs a person at this terminal +//! ([`Consent`]) — `needs_terminal::require` first, then a confirmation that says +//! in words when the job will run, what it will run, and that every run is an +//! unattended agent. The daemon path is not gated here and does not need to be: +//! reaching it takes `BIOROUTER_SERVER__SECRET_KEY`, which is the operator's own +//! key and is stripped from every tool child — so an agent's shell has neither a +//! daemon to ask nor a terminal to be asked at, which is exactly the case that +//! must fail. A scripted deployment keeps working: give it the key and a running +//! daemon. +//! +//! ⚠ **There is deliberately no `--yes`.** A flag that skips the question is a +//! flag the agent writes into the same command line, which would leave the gate +//! costing an honest operator a keystroke and an agent nothing. use anyhow::{anyhow, bail, Context, Result}; use biorouter::scheduler::{ @@ -27,6 +51,7 @@ use std::path::Path; use std::sync::Arc; use super::apps::{configured_port, daemon_ok, DAEMON_HOST}; +use super::needs_terminal; use super::session_watch::{daemon_auth, daemon_json_request, DaemonAuth}; fn validate_cron_expression(cron: &str) -> Result<()> { @@ -121,6 +146,65 @@ impl Reach { } } +/// Who says yes to a change this terminal makes in the schedule file itself. +/// +/// The gate `platform__manage_schedule` gets from `agents/platform_approval.rs`, +/// in the one form a separate process can offer it: that card is parked in the +/// daemon's `PendingUserActions` and shown in the interface, and nothing this +/// command can reach. +pub(crate) enum Consent { + /// Ask the person at this terminal, and refuse when there is none. What an + /// agent's tool child gets, because it has no terminal. + AskThisTerminal { terminal: bool }, + /// A test's stand-in for a person who said yes, so the paths that follow the + /// gate stay testable. `#[cfg(test)]` for the same reason + /// [`LocalStore::at_data_dir`] is: it must not exist in a shipped binary. + #[cfg(test)] + AlreadyGiven, +} + +/// What a refused change says. One sentence per surface it names, because the +/// reader is either a person who needs the alternative or an agent that needs to +/// be told to ask the person. +fn needs_a_person(action: &str) -> String { + format!( + "`{action}` would change what Biorouter runs on its own, so it needs a person: run it at an \ + interactive terminal, or do it in Biorouter itself (the Scheduler page, or ask the \ + assistant — `manage_schedule` shows an approval card). Nothing was changed.\nA script \ + can do it without a terminal by asking a running daemon instead: set \ + BIOROUTER_SERVER__SECRET_KEY to that daemon's key (it is deliberately not in a tool's \ + environment) and BIOROUTER_PORT to its port." + ) +} + +impl Consent { + /// `Ok` once the change may go ahead. + /// + /// `action` names it for the refusal (`schedule add`); `summary` is what the + /// person is shown, and must say what will happen in their terms rather than + /// restate the arguments — the rule `platform_approval`'s card follows. + fn require(&self, action: &str, summary: &str) -> Result<()> { + let terminal = match self { + Consent::AskThisTerminal { terminal } => *terminal, + #[cfg(test)] + Consent::AlreadyGiven => return Ok(()), + }; + // First, and before anything is printed or written: a refusal must not + // arrive after a wall of output, and `cliclack` under a pipe dies with a + // bare `Error: not connected` (see `needs_terminal`). + needs_terminal::require(terminal, &needs_a_person(action))?; + println!("{summary}"); + if cliclack::confirm("Go ahead?") + .initial_value(false) + .interact()? + { + Ok(()) + } else { + bail!("Declined. Nothing was changed.") + } + } +} + /// The schedule file this terminal writes when no daemon can be reached, and /// the session store a `Scheduler` over it needs. pub(crate) struct LocalStore { @@ -361,6 +445,9 @@ pub async fn handle_schedule_add( let report = add_schedule( Reach::from_environment().await, LocalStore::for_this_user, + Consent::AskThisTerminal { + terminal: needs_terminal::prompt_can_run(), + }, &schedule_id, &cron, &workflow_source_arg, @@ -375,6 +462,7 @@ pub async fn handle_schedule_add( pub(crate) async fn add_schedule( reach: Reach, local: impl FnOnce() -> Result, + consent: Consent, schedule_id: &str, cron: &str, workflow_source_arg: &str, @@ -386,6 +474,18 @@ pub(crate) async fn add_schedule( Reach::File { why } => why, }; + // ⚠ Before the `Scheduler` is built, and so before the workflow is copied + // into its store: a refusal must leave nothing behind. + consent.require( + "biorouter schedule add", + &format!( + "Schedule '{schedule_id}' will run the workflow {workflow_source_arg} automatically \ + {}, in background mode: every run is a new session that nobody watches, under the \ + permission mode Biorouter is configured with.\n cron: {cron}", + biorouter::agents::describe_cron(cron) + ), + )?; + // The Scheduler's add_scheduled_job will handle copying the workflow from workflow_source_arg // to its internal storage and validating the path. let job = ScheduledJob { @@ -528,6 +628,9 @@ pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> { let report = remove_schedule( Reach::from_environment().await, LocalStore::for_this_user, + Consent::AskThisTerminal { + terminal: needs_terminal::prompt_can_run(), + }, &schedule_id, ) .await?; @@ -539,6 +642,7 @@ pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> { pub(crate) async fn remove_schedule( reach: Reach, local: impl FnOnce() -> Result, + consent: Consent, schedule_id: &str, ) -> Result { let why = match reach { @@ -547,6 +651,15 @@ pub(crate) async fn remove_schedule( } Reach::File { why } => why, }; + // Gated for the reason `manage_schedule`'s delete is: a standing run the user + // set up is theirs, and removing it takes its workflow copy with it. + consent.require( + "biorouter schedule remove", + &format!( + "Schedule '{schedule_id}' will be deleted, along with the copy of its workflow \ + Biorouter keeps. It will never run again." + ), + )?; let store = local()?; let scheduler = store.scheduler().await?; @@ -615,6 +728,9 @@ pub async fn handle_schedule_run_now(schedule_id: String) -> Result<()> { let report = run_schedule_now( Reach::from_environment().await, LocalStore::for_this_user, + Consent::AskThisTerminal { + terminal: needs_terminal::prompt_can_run(), + }, &schedule_id, ) .await?; @@ -627,6 +743,7 @@ pub async fn handle_schedule_run_now(schedule_id: String) -> Result<()> { pub(crate) async fn run_schedule_now( reach: Reach, local: impl FnOnce() -> Result, + consent: Consent, schedule_id: &str, ) -> Result { let why = match reach { @@ -635,6 +752,16 @@ pub(crate) async fn run_schedule_now( } Reach::File { why } => why, }; + // The most immediate of the three: this does not arrange an agent run, it + // starts one. `manage_schedule`'s `run_now` parks a card for the same reason. + consent.require( + "biorouter schedule run-now", + &format!( + "The workflow of schedule '{schedule_id}' will run once, now, in this terminal's own \ + process: an agent session that nobody watches, under the permission mode Biorouter \ + is configured with. Its regular schedule is unchanged." + ), + )?; eprintln!( "No running Biorouter could be reached from this terminal ({why}), so '{schedule_id}' \ runs here, in this terminal." @@ -1004,6 +1131,10 @@ mod tests { let report = add_schedule( reach, local_store_is_off_limits, + // ⚠ Not `AlreadyGiven`. The daemon path must not reach the gate at + // all, and a consent that can never be granted is what proves it: + // this test fails if the gate is ever moved above the branch. + Consent::AskThisTerminal { terminal: false }, "qaf-probe", "0 2 * * *", &workflow.to_string_lossy(), @@ -1049,6 +1180,7 @@ mod tests { let error = add_schedule( reach, local_store_is_off_limits, + Consent::AskThisTerminal { terminal: false }, "qaf-probe", "0 2 * * *", &workflow.to_string_lossy(), @@ -1089,12 +1221,13 @@ mod tests { let report = add_schedule( reach, || Ok(LocalStore::at_data_dir(&data_dir)), + Consent::AlreadyGiven, "qaf-probe", "0 2 * * *", &workflow.to_string_lossy(), ) .await - .expect("with no daemon the job still goes into the file"); + .expect("with no daemon, and a person who said yes, the job goes into the file"); let on_disk: Vec = serde_json::from_str(&std::fs::read_to_string(data_dir.join("schedule.json")).unwrap()) @@ -1147,9 +1280,14 @@ mod tests { }) .await; let reach = Reach::probe(Some(DaemonAuth::for_test("s3cret", "")), daemon.port).await; - let report = remove_schedule(reach, local_store_is_off_limits, "qaf-probe") - .await - .expect("the daemon removed it"); + let report = remove_schedule( + reach, + local_store_is_off_limits, + Consent::AskThisTerminal { terminal: false }, + "qaf-probe", + ) + .await + .expect("the daemon removed it"); assert_eq!(daemon.requests().len(), 1, "{:?}", daemon.requests()); assert!(report.contains("running Biorouter"), "{report}"); } @@ -1160,9 +1298,14 @@ mod tests { async fn a_schedule_the_daemon_does_not_have_is_not_found() { let daemon = fake_daemon(|_| (404, String::new())).await; let reach = Reach::probe(Some(DaemonAuth::for_test("s3cret", "")), daemon.port).await; - let error = remove_schedule(reach, local_store_is_off_limits, "nightly") - .await - .expect_err("404 is not a removal"); + let error = remove_schedule( + reach, + local_store_is_off_limits, + Consent::AskThisTerminal { terminal: false }, + "nightly", + ) + .await + .expect_err("404 is not a removal"); assert!(format!("{error}").contains("not found"), "{error}"); } @@ -1188,6 +1331,155 @@ mod tests { assert!(listing.contains("running Biorouter"), "{listing}"); } + // ────────────────────────────────────────────────────────────────────── + // The approval gate on the file path (QA 2026-09-12, the CLI half of F1). + // + // PR #251 made `platform__manage_schedule` park a card before it changes + // what the scheduler will do. `biorouter schedule add` from a chat's shell + // was the way round it: the file path wrote a standing unattended agent run + // and nobody was asked. An agent's tool child has neither the daemon's key + // (#57) nor a terminal, so requiring a person at one closes it and leaves a + // keyed script working. + // ────────────────────────────────────────────────────────────────────── + + /// The measured defect: with no daemon to reach and nobody at a terminal, + /// `schedule add` wrote the job and printed a success. + /// + /// Fails the shipped command twice: it returns `Ok`, and `schedule.json` + /// exists. + #[tokio::test] + #[serial_test::serial] + async fn a_schedule_added_with_nobody_to_ask_is_refused_and_writes_nothing() { + let root = tempfile::tempdir().unwrap(); + let _env = env_lock::lock_env([( + "BIOROUTER_PATH_ROOT", + Some(root.path().to_string_lossy().into_owned()), + )]); + let data_dir = root.path().join("data"); + let workflow = root.path().join("probe.yaml"); + std::fs::write( + &workflow, + "title: Probe\ndescription: d\nprompt: echo probe\n", + ) + .unwrap(); + + let error = add_schedule( + // The agent's shell exactly: no key, so no daemon to ask. + Reach::probe(None, unused_port().await).await, + || Ok(LocalStore::at_data_dir(&data_dir)), + Consent::AskThisTerminal { terminal: false }, + "agent-added", + "0 2 * * *", + &workflow.to_string_lossy(), + ) + .await + .expect_err("a standing agent run may not be created with nobody asked"); + + assert!( + !data_dir.join("schedule.json").exists(), + "the refusal must land before anything is written" + ); + let text = format!("{error:#}"); + assert!(text.contains("needs a person"), "{text}"); + assert!( + text.contains("manage_schedule"), + "the refusal must name the surface that does ask: {text}" + ); + assert!( + text.contains("BIOROUTER_SERVER__SECRET_KEY"), + "and the one a script can use: {text}" + ); + assert!(text.contains("Nothing was changed"), "{text}"); + } + + /// `main` gives the refusal exit 2 and prints the sentence alone, and it can + /// only do that by downcasting — so the type has to survive the `?`. + #[tokio::test] + #[serial_test::serial] + async fn the_refusal_is_the_needs_a_terminal_one_so_main_exits_2() { + let root = tempfile::tempdir().unwrap(); + let _env = env_lock::lock_env([( + "BIOROUTER_PATH_ROOT", + Some(root.path().to_string_lossy().into_owned()), + )]); + let workflow = root.path().join("probe.yaml"); + std::fs::write( + &workflow, + "title: Probe\ndescription: d\nprompt: echo probe\n", + ) + .unwrap(); + let error = add_schedule( + Reach::probe(None, unused_port().await).await, + || Ok(LocalStore::at_data_dir(&root.path().join("data"))), + Consent::AskThisTerminal { terminal: false }, + "agent-added", + "0 2 * * *", + &workflow.to_string_lossy(), + ) + .await + .unwrap_err(); + assert!( + error + .downcast_ref::() + .is_some(), + "{error:#}" + ); + } + + /// The other two file-path mutations are gated as well: `manage_schedule` + /// parks a card for delete and run_now, and run_now is the one that does not + /// arrange an unattended agent run but starts one. + /// + /// Fails the shipped command, which ran and deleted with nobody asked. + #[tokio::test] + #[serial_test::serial] + async fn removing_and_running_a_schedule_need_a_person_too() { + let root = tempfile::tempdir().unwrap(); + let _env = env_lock::lock_env([( + "BIOROUTER_PATH_ROOT", + Some(root.path().to_string_lossy().into_owned()), + )]); + let data_dir = root.path().join("data"); + let port = unused_port().await; + + for error in [ + remove_schedule( + Reach::probe(None, port).await, + || panic!("the gate must refuse before a Scheduler is built"), + Consent::AskThisTerminal { terminal: false }, + "nightly", + ) + .await + .expect_err("a delete with nobody asked is refused"), + run_schedule_now( + Reach::probe(None, port).await, + || panic!("the gate must refuse before a Scheduler is built"), + Consent::AskThisTerminal { terminal: false }, + "nightly", + ) + .await + .expect_err("starting an unattended agent run with nobody asked is refused"), + ] { + let text = format!("{error:#}"); + assert!(text.contains("needs a person"), "{text}"); + } + assert!( + !data_dir.join("schedule.json").exists(), + "nothing may be written on either path" + ); + } + + /// The sentence the person reads has to say when the job runs, and it says it + /// in the same words the `manage_schedule` card does — one describer, not + /// two, so the terminal and the card cannot describe one cron differently. + #[test] + fn the_question_says_when_the_job_will_run_in_the_cards_own_words() { + assert_eq!( + biorouter::agents::describe_cron("0 2 * * *"), + "every day at 02:00, this computer's local time" + ); + } + /// `schedule run-now` runs the job IN the daemon when there is one — where /// the desktop's Stop button can reach it — rather than in this terminal. /// @@ -1203,9 +1495,14 @@ mod tests { }) .await; let reach = Reach::probe(Some(DaemonAuth::for_test("s3cret", "")), daemon.port).await; - let report = run_schedule_now(reach, local_store_is_off_limits, "qaf-probe") - .await - .expect("the daemon ran it"); + let report = run_schedule_now( + reach, + local_store_is_off_limits, + Consent::AskThisTerminal { terminal: false }, + "qaf-probe", + ) + .await + .expect("the daemon ran it"); assert!(report.contains("20260911_42"), "{report}"); } } diff --git a/crates/biorouter-cli/src/commands/serve.rs b/crates/biorouter-cli/src/commands/serve.rs index 998b7b74c..ea6d09aff 100644 --- a/crates/biorouter-cli/src/commands/serve.rs +++ b/crates/biorouter-cli/src/commands/serve.rs @@ -44,6 +44,18 @@ //! Before this, the only thing that ever stopped the daemon was a terminal's //! Ctrl-C, which reaches the whole foreground process group and so the daemon //! directly. `kill ` from anywhere else left it running. +//! +//! # Where the browser token comes from +//! +//! `--token`, else `BIOROUTER_BROWSER_TOKEN` from this shell, else one minted +//! for this launch — the same order [`resolve_web_dir`] uses for the interface +//! directory, and for the same reason: a command line outranks the environment +//! it runs in, and both outrank a default. The variable is what a service unit +//! has to work with (a systemd `EnvironmentFile`, read only by the service +//! user), and it is the one way an address stays valid across a restart that +//! nobody is watching. It used to be ignored here and overwritten with a random +//! token, so the documented service deployment printed an address the operator +//! could not know and refused the one they had published. use crate::commands::exe_path::{biorouterd_for, current_exe_resolved, daemon_file_name}; use anyhow::{bail, Context, Result}; @@ -63,11 +75,17 @@ const READY_TIMEOUT: Duration = Duration::from_secs(60); /// How long the daemon gets to shut down on its own before it is killed. /// +/// The daemon bounds its own graceful shutdown only when it finds itself +/// orphaned (`ORPHAN_EXIT_DEADLINE` in `biorouter-server`'s `commands::agent`). +/// Everywhere else the bound is the sender's to apply, and this is it — which is +/// why every command that starts a daemon must stop it through [`stop_daemon`] +/// rather than sending a SIGTERM and hoping. +/// /// Its graceful shutdown waits for open connections to finish, and a browser /// tab that is still open holds some that never do — so without a limit, /// stopping `serve` with a tab open would wait forever. The daemon applies the /// same figure to itself when it finds itself orphaned. -const STOP_GRACE: Duration = Duration::from_secs(10); +pub(crate) const STOP_GRACE: Duration = Duration::from_secs(10); /// Run the browser-served interface. #[allow(clippy::too_many_arguments)] @@ -94,11 +112,12 @@ pub async fn handle_serve( let web_dir = resolve_web_dir(web_dir)?; - let browser_token = if no_token { - None - } else { - Some(token.unwrap_or_else(|| random_hex(32))) - }; + let browser_token = choose_browser_token( + token, + no_token, + std::env::var("BIOROUTER_BROWSER_TOKEN").ok(), + || random_hex(32), + ); let secret_key = random_hex(32); // Fail on an occupied port here, with a clear message, rather than letting @@ -121,6 +140,18 @@ pub async fn handle_serve( command .arg("--exit-with-parent") .arg(std::process::id().to_string()); + match browser_token.value() { + Some(token) => { + command.env("BIOROUTER_BROWSER_TOKEN", token); + } + // ⚠ Removed, not merely unset. The child inherits this shell's + // environment, so `--no-token` in a shell that exports a token would + // leave the daemon demanding one while the URL printed below carries + // none: every open a 401, and nothing on screen to say why. + None => { + command.env_remove("BIOROUTER_BROWSER_TOKEN"); + } + } let mut child = command .env("BIOROUTER_HOST", &host) .env("BIOROUTER_PORT", port.to_string()) @@ -134,13 +165,18 @@ pub async fn handle_serve( // value makes "a serve daemon never admits a `file:` page" a property of // the spawn rather than of whoever's shell this ran in. .env_remove("BIOROUTER_RENDERER_ORIGIN") - .envs( - browser_token - .iter() - .map(|t| ("BIOROUTER_BROWSER_TOKEN", t.as_str())), - ) // See the module documentation: no proof-of-user digest, on purpose. .stdin(Stdio::null()) + // SD-12: and this daemon says so. The desktop launcher sets + // `BIOROUTER_USER_ACTION_EXPECTED` to declare that it *does* send a + // digest, which makes a daemon that receives none refuse rather than + // exempt a private new chat. `serve` sends none by design, so an + // inherited value from the operator's shell would turn every private new + // chat here into a refusal nobody can clear — the failure SD-12 exists + // to remove. Cleared rather than trusted; the literal is defined once, in + // `biorouter_server::launch::USER_ACTION_EXPECTED_ENV` (this crate does + // not depend on `biorouter-server`). + .env_remove("BIOROUTER_USER_ACTION_EXPECTED") // A backstop for a panic unwinding through here. Every ordinary path // goes through `stop_daemon`, which asks before it insists. .kill_on_drop(true) @@ -156,14 +192,8 @@ pub async fn handle_serve( } } - let url = browser_url(&host, port, browser_token.as_deref()); - print_banner( - &url, - &host, - port, - browser_token.as_deref(), - bind_is_loopback, - ); + let url = browser_url(&host, port, browser_token.value()); + print_banner(&url, &host, port, &browser_token, bind_is_loopback); if open_browser { let _ = webbrowser::open(&url); } @@ -191,7 +221,7 @@ fn print_banner( url: &str, host: &str, port: u16, - browser_token: Option<&str>, + browser_token: &BrowserToken, bind_is_loopback: bool, ) { println!("\n Biorouter is serving at\n\n {url}\n"); @@ -199,7 +229,7 @@ fn print_banner( match reachable_address(host) { Some(addr) => println!( " From another machine on this network:\n\n {}\n", - browser_url(&addr, port, browser_token) + browser_url(&addr, port, browser_token.value()) ), // The old implementation fell back to 127.0.0.1 here, which printed // a URL that could not possibly work from the other machine the user @@ -211,11 +241,7 @@ fn print_banner( ), } } - if browser_token.is_none() { - println!(" No access token: anything that can reach this port can use it.\n"); - } else { - println!(" The token above is shown once, and is new on every launch.\n"); - } + println!(" {}\n", browser_token.provenance()); println!(" The model is whichever `biorouter configure` chose; a browser cannot change it."); println!(" Press Ctrl-C to stop.\n"); } @@ -227,7 +253,7 @@ fn print_banner( /// foreground process group, daemon included; `nohup` exists to make both /// ignore it, and installing a handler here would override that. Any other /// SIGHUP ends `serve` the default way and the daemon's parent watch follows. -struct StopSignals { +pub(crate) struct StopSignals { #[cfg(unix)] interrupt: tokio::signal::unix::Signal, #[cfg(unix)] @@ -235,7 +261,7 @@ struct StopSignals { } impl StopSignals { - fn install() -> Result { + pub(crate) fn install() -> Result { #[cfg(unix)] use tokio::signal::unix::{signal, SignalKind}; Ok(Self { @@ -247,7 +273,7 @@ impl StopSignals { } /// Resolve on the next request to stop. - async fn recv(&mut self) { + pub(crate) async fn recv(&mut self) { #[cfg(unix)] tokio::select! { _ = self.interrupt.recv() => {} @@ -267,7 +293,13 @@ impl StopSignals { /// A second request to stop while it is shutting down skips the rest of the /// wait. A daemon that has already exited is only reaped, so every path can end /// here without first asking whether it needs to. -async fn stop_daemon(child: &mut Child, stop: &mut StopSignals) { +/// +/// Shared with `biorouter apps serve`, which starts the same daemon and owes the +/// same guarantee. One supervision model rather than two: `apps serve` handled +/// only `ctrl_c` and dropped its child handle, so `kill ` left a daemon +/// holding the port, the app and the daemon's secret — the defect this command +/// had and no longer has. +pub(crate) async fn stop_daemon(child: &mut Child, stop: &mut StopSignals) { if matches!(child.try_wait(), Ok(Some(_))) { return; } @@ -314,6 +346,83 @@ fn ask_to_stop(child: &Child) { #[cfg(not(unix))] fn ask_to_stop(_child: &Child) {} +/// The access token this launch will use, and where it came from. +/// +/// The provenance is carried rather than recomputed because it changes what the +/// operator is told: "shown once, and new on every launch" is true of a minted +/// token and false of one they chose, and printing it over an operator's own +/// token would be an instruction to go looking for a new address that does not +/// exist. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BrowserToken { + /// `--no-token`: the gate is off. Only reachable for a loopback bind. + Off, + /// `--token `. + Flag(String), + /// `BIOROUTER_BROWSER_TOKEN` in this shell. + Variable(String), + /// Minted for this launch, because nothing named one. + Minted(String), +} + +impl BrowserToken { + /// The token itself, or `None` when there is no gate. + pub(crate) fn value(&self) -> Option<&str> { + match self { + BrowserToken::Off => None, + BrowserToken::Flag(t) | BrowserToken::Variable(t) | BrowserToken::Minted(t) => Some(t), + } + } + + /// The line under the banner. + fn provenance(&self) -> &'static str { + match self { + BrowserToken::Off => "No access token: anything that can reach this port can use it.", + BrowserToken::Flag(_) => { + "The token above is the one --token named; it works until you change it." + } + BrowserToken::Variable(_) => { + "The token above came from BIOROUTER_BROWSER_TOKEN; it works until you change it." + } + BrowserToken::Minted(_) => "The token above is shown once, and is new on every launch.", + } + } +} + +/// Decide the token: `--no-token`, else `--token`, else +/// `BIOROUTER_BROWSER_TOKEN`, else one minted here. +/// +/// `mint` is passed in so a test can state the order without matching a random +/// string, and the variable is passed in rather than read here for the reason +/// [`choose_web_dir`] gives: other tests in this binary read the process +/// environment, and a test that sets one races them. +/// +/// A blank variable reads as unset, exactly as `BIOROUTER_SERVE_UI`'s does. The +/// value is trimmed: it arrives from an environment file, where a stray newline +/// or a trailing space is a typo rather than part of a credential, and a token +/// that differs from the published one by an invisible byte fails as a flat 401 +/// with nothing on screen to explain it. +pub(crate) fn choose_browser_token( + flag: Option, + no_token: bool, + variable: Option, + mint: impl FnOnce() -> String, +) -> BrowserToken { + // ⚠ First, and before the variable is even looked at. `--no-token` is a + // refusal to have a gate; a token this shell happens to export is not a + // reason to put one back that the printed URL would not carry. + if no_token { + return BrowserToken::Off; + } + if let Some(token) = flag { + return BrowserToken::Flag(token); + } + match variable.map(|t| t.trim().to_string()) { + Some(token) if !token.is_empty() => BrowserToken::Variable(token), + _ => BrowserToken::Minted(mint()), + } +} + /// The URL to open, with the browser token in it. /// /// Opening it exchanges the token for a session cookie and redirects, so the @@ -642,6 +751,115 @@ mod tests { assert!(a.chars().all(|c| c.is_ascii_hexdigit())); } + fn minted() -> String { + "minted".to_string() + } + + /// The order, stated once: flag, then variable, then a minted token. + #[test] + fn the_token_comes_from_the_flag_then_the_variable_then_a_fresh_one() { + assert_eq!( + choose_browser_token(Some("from-the-flag".into()), false, None, minted), + BrowserToken::Flag("from-the-flag".into()) + ); + assert_eq!( + choose_browser_token(None, false, Some("from-the-file".into()), minted), + BrowserToken::Variable("from-the-file".into()) + ); + assert_eq!( + choose_browser_token(None, false, None, minted), + BrowserToken::Minted("minted".into()) + ); + } + + /// A command line outranks the environment it runs in, as it does for + /// `--web-dir` against `BIOROUTER_SERVE_UI`. + #[test] + fn the_token_flag_takes_precedence_over_the_variable() { + assert_eq!( + choose_browser_token( + Some("from-the-flag".into()), + false, + Some("from-the-file".into()), + minted + ), + BrowserToken::Flag("from-the-flag".into()) + ); + } + + /// The measured defect (2026-09-12): `BIOROUTER_BROWSER_TOKEN= biorouter + /// serve` printed a random token and answered `?t=` with 401, so the + /// systemd deployment in `docs/deployment/headless-linux.md` — whose whole + /// point is a token that survives a restart — could not work as written. + /// + /// Fails the shipped command, which minted a token here. + #[test] + fn an_operator_supplied_token_is_used_rather_than_overwritten() { + let chosen = choose_browser_token(None, false, Some("token-from-the-file".into()), || { + panic!("a token was named; nothing may be minted over it") + }); + assert_eq!(chosen.value(), Some("token-from-the-file")); + assert!( + chosen.provenance().contains("BIOROUTER_BROWSER_TOKEN"), + "the operator must be told the token is theirs, not a new one: {}", + chosen.provenance() + ); + } + + /// Blank reads as unset, as it does for `BIOROUTER_SERVE_UI` — and a value + /// that is only whitespace is a mistake in an environment file, not a + /// credential. + #[test] + fn a_blank_token_variable_is_not_a_choice() { + for blank in ["", " ", "\n"] { + assert_eq!( + choose_browser_token(None, false, Some(blank.into()), minted), + BrowserToken::Minted("minted".into()), + "{blank:?}" + ); + } + } + + /// A newline an environment file left behind is not part of the credential. + #[test] + fn a_token_from_the_environment_is_trimmed() { + assert_eq!( + choose_browser_token(None, false, Some(" tok\n".into()), minted), + BrowserToken::Variable("tok".into()) + ); + } + + /// `--no-token` is a refusal to have a gate. An inherited token must not put + /// one back: the daemon would demand it and the URL printed here would not + /// carry it, so every open would be a 401 with nothing on screen to say why. + #[test] + fn no_token_beats_a_token_this_shell_happens_to_export() { + let chosen = choose_browser_token(None, true, Some("inherited".into()), || { + panic!("--no-token mints nothing") + }); + assert_eq!(chosen, BrowserToken::Off); + assert_eq!(chosen.value(), None); + } + + /// The tests above pass the variable in, so on their own they would pass + /// against a build that never read it. This one goes through the real + /// environment, exactly as `serve_reads_the_variable_it_documents` does for + /// the interface directory. + #[test] + fn serve_reads_the_token_variable_it_documents() { + let _env = env_lock::lock_env([( + "BIOROUTER_BROWSER_TOKEN", + Some("token-from-the-environment".to_string()), + )]); + let chosen = choose_browser_token( + None, + false, + std::env::var("BIOROUTER_BROWSER_TOKEN").ok(), + minted, + ); + assert_eq!(chosen.value(), Some("token-from-the-environment")); + } + /// The error is the only thing the reader has, so it must name every place /// that was looked at. A bare "not found" leaves them guessing which of four /// installation layouts they are in. diff --git a/crates/biorouter-cli/src/commands/session_watch.rs b/crates/biorouter-cli/src/commands/session_watch.rs index e923dc7f0..56d350b4d 100644 --- a/crates/biorouter-cli/src/commands/session_watch.rs +++ b/crates/biorouter-cli/src/commands/session_watch.rs @@ -64,10 +64,15 @@ pub(crate) struct DaemonAuth { /// the public tier — the same answer, stated by saying nothing rather than /// by saying something meaningless. caller_provider: String, - /// Present only for attach/cancel routes that require proof of a live - /// operator: the attached event stream, interrupt, cancel, and a - /// provenance-less reply to a subagent. - /// It is never sourced from argv, environment, config, or desktop settings. + /// The raw user-action key, when this terminal holds it: the person supplied + /// it on stdin (`--user-action-key-stdin`), or a daemon refused a request for + /// want of it and the person then typed it (see [`key_verdict`]). `None` + /// otherwise, and a protected request then goes out WITHOUT the header, for + /// the daemon to judge. + /// + /// Sent only on the requests the proof can change: the attached event + /// stream, `/interrupt`, `/agent/cancel` and `/reply`. It is never sourced + /// from argv, environment, config, or desktop settings. user_action: Option>>, } @@ -94,48 +99,234 @@ pub(crate) async fn daemon_auth() -> Result { }) } -async fn daemon_auth_with_user_action(from_stdin: bool) -> Result { - let auth = daemon_auth().await?; - auth_with_user_action(auth, from_stdin).await -} +// ────────────────────────────────────────────────────────────────────────────── +// The user-action key: the daemon is asked before the person is. +// +// A daemon started with a key (the desktop app's, or one launched with its +// digest on stdin) wants it before it lets anyone stop or steer a turn. One +// started without (`biorouter serve`, a hand-run `biorouterd agent`) holds +// nothing to check a key against, and admits those requests on the reach gate +// instead (serve decision SD-11). A terminal cannot tell the two apart, and the +// old answer to that — ask the person for a key before sending anything — asked +// every `serve` user for a key that does not exist. +// +// So the request goes out without the key and the daemon's answer says which +// kind it is (`key_verdict`). The person is asked only when the daemon wanted +// the key, and the request is made once more with it; a refusal the key cannot +// change is shown in the daemon's own words. None of this relaxes anything: the +// daemon is the boundary, and every refusal read here is given before the route +// touches the turn. A subagent's session still needs the proof, and a daemon +// without a key still refuses it. +// ────────────────────────────────────────────────────────────────────────────── -async fn auth_with_user_action(mut auth: DaemonAuth, from_stdin: bool) -> Result { - let key = tokio::task::spawn_blocking(move || read_user_action_key(from_stdin)) +/// `--user-action-key-stdin`: take the key from stdin's first line before +/// anything is sent. +/// +/// Read up front, unlike the terminal prompt, which waits for a daemon to ask: +/// on `attach` every later line of stdin is a message, so the key's line must +/// be taken before the reader that treats lines as messages starts. +async fn with_supplied_key(auth: DaemonAuth, key_from_stdin: bool) -> Result { + if !key_from_stdin { + return Ok(auth); + } + let key = tokio::task::spawn_blocking(read_key_from_stdin) .await .map_err(|join| anyhow!("could not read the user-action key: {join}"))??; - auth.user_action = Some(Arc::new(key)); - Ok(auth) + Ok(auth.with_user_action(key)) } -fn read_user_action_key(from_stdin: bool) -> Result> { - let key = if from_stdin { - let mut key = String::new(); - std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut key)?; - while key.ends_with('\n') || key.ends_with('\r') { - key.pop(); - } - Zeroizing::new(key) - } else { - if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { - return Err(anyhow!( - "steering and cancellation require the user-action key that was hashed into \ - biorouterd at startup. Run this from a controlling terminal, or pass \ - --user-action-key-stdin and pipe the raw key as the first line" - )); - } - eprintln!( - "Enter the user-action key supplied when this daemon was launched \ - (input is hidden):" - ); - Zeroizing::new(console::Term::stderr().read_secure_line()?) - }; +fn read_key_from_stdin() -> Result> { + let mut key = Zeroizing::new(String::new()); + std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut key)?; + while key.ends_with('\n') || key.ends_with('\r') { + key.pop(); + } + non_empty_key(key) +} + +/// Ask the person at the controlling terminal for the key, with echo off. Only +/// ever called once a daemon has refused a request for want of it. +async fn ask_terminal_for_key(key_use: KeyUse) -> Result> { + tokio::task::spawn_blocking(move || prompt_for_key(key_use)) + .await + .map_err(|join| anyhow!("could not read the user-action key: {join}"))? +} + +fn prompt_for_key(key_use: KeyUse) -> Result> { + if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { + return Err(anyhow!("{}", key_use.no_terminal())); + } + eprintln!("{}", key_use.prompt()); + non_empty_key(Zeroizing::new(console::Term::stderr().read_secure_line()?)) +} + +fn non_empty_key(key: Zeroizing) -> Result> { if key.is_empty() { return Err(anyhow!("the user-action key cannot be empty")); } Ok(key) } +/// What the key is wanted for, in the words its prompt and its refusals use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyUse { + /// `session cancel` — `POST /agent/cancel`. + Stop, + /// `session attach`, asked as it joins — `POST /interrupt`. + Steer, + /// `session send` — `POST /reply`. + Send, +} + +impl KeyUse { + /// What the daemon wants the key for. + fn act(self) -> &'static str { + match self { + KeyUse::Stop => "stopping a turn", + KeyUse::Steer => "steering a session", + KeyUse::Send => "sending to this session", + } + } + + /// What the refused request left undone. + fn undone(self) -> &'static str { + match self { + KeyUse::Stop => "The turn was not stopped.", + KeyUse::Steer => "The session was not steered.", + KeyUse::Send => "The message was not delivered.", + } + } + + fn prompt(self) -> String { + format!( + "This daemon was started with a user-action key and wants it for {}. \ + Enter the key (input is hidden):", + self.act() + ) + } + + fn no_terminal(self) -> String { + let read_only = match self { + KeyUse::Steer => " `--read-only` follows the session without it.", + KeyUse::Stop | KeyUse::Send => "", + }; + format!( + "This daemon was started with a user-action key and wants it for {}, but there is \ + no terminal to ask for it on. Run this from a terminal, or pass \ + --user-action-key-stdin and pipe the raw key as the first line of stdin. {}{read_only}", + self.act(), + self.undone() + ) + } + + fn wrong_key(self) -> String { + format!( + "the daemon refused the user-action key: it is not the key this daemon was started \ + with. {}", + self.undone() + ) + } +} + +/// What a daemon's answer to a stop or a steer says about the user-action key. +/// +/// ⚠ **Read only off the turn-control routes, `/agent/cancel` and +/// `/interrupt`.** There the two kinds of daemon refuse in shapes that cannot be +/// confused (serve decision SD-11). One that holds a key refuses a request that +/// lacks the proof, or carries a wrong one, with an EMPTY 403 +/// (`routes::reply::authorize_turn_control`). One that holds none gates through +/// `authorize_agent_control` instead, and every refusal of that gate carries a +/// sentence (`SESSION_REACH_NO_KEY`, `SUBAGENT_CONTROL_NO_KEY`). `/reply` +/// promises no such thing: its subagent refusal is an empty 403 on EITHER kind +/// of daemon, which is why `send` asks the steer gate instead of reading its own +/// 403. +/// +/// Both halves are pinned from the daemon's side, by `routes::reply`'s keyed +/// tests and by `tests/turn_control_no_user_key.rs`, because this reading +/// decides whether a person is asked for a key at all. +#[derive(Debug, Clone, PartialEq, Eq)] +enum KeyVerdict { + /// Not refused for want of the key; the status says what did happen. + NotAsked, + /// Refused for want of the key: this daemon holds one, and the request + /// carried none, or a wrong one. + Wanted, + /// Refused in the daemon's own words. No key changes this answer, so the + /// person is shown the words rather than asked for one. + Refused(String), +} + +fn key_verdict(code: u16, body: &str) -> KeyVerdict { + if code != 403 { + return KeyVerdict::NotAsked; + } + match refusal_sentence(body) { + Some(sentence) => KeyVerdict::Refused(sentence), + None => KeyVerdict::Wanted, + } +} + +/// The sentence a refusal carries, or `None` for an empty body. +/// +/// `ErrorResponse` answers `{"message": …}` and the reach gate answers plain +/// text where a route hands its refusal back directly. Both are the daemon's +/// own words and are shown as they are. +fn refusal_sentence(body: &str) -> Option { + let text = json_object(body) + .and_then(|value| value.get("message")?.as_str().map(str::to_string)) + .unwrap_or_else(|| body.to_string()); + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) +} + +/// Make a request without the key and, only if the daemon refuses it for want +/// of one, ask the person for the key and make the request once more with it. +/// +/// ⚠ **The first attempt is the question, not a courtesy.** Asking the person +/// first would ask a `biorouter serve` user for a key that does not exist; the +/// request itself asks the daemon instead, and costs nothing when refused, +/// because every refusal [`key_verdict`] reads is given before the route touches +/// anything. A request that already carried a key (`--user-action-key-stdin`) +/// is never followed by a prompt: its refusal means the key is wrong, not +/// missing. +/// +/// Returns the last answer, what it said about the key, and the auth it was +/// made with — which holds the key exactly when it was supplied or wanted. +/// `attempt`, `verdict` and `ask` are arguments so this can be driven over +/// every answer order in a test, as `run_ladder` is. +async fn with_key_if_wanted( + auth: DaemonAuth, + mut attempt: Attempt, + verdict: impl Fn(&T) -> KeyVerdict, + ask: Ask, +) -> Result<(T, KeyVerdict, DaemonAuth)> +where + Attempt: FnMut(DaemonAuth) -> AttemptFut, + AttemptFut: std::future::Future>, + Ask: FnOnce() -> AskFut, + AskFut: std::future::Future>>, +{ + let answer = attempt(auth.clone()).await?; + let judged = verdict(&answer); + if judged != KeyVerdict::Wanted || auth.holds_key() { + return Ok((answer, judged, auth)); + } + let auth = auth.with_user_action(ask().await?); + let answer = attempt(auth.clone()).await?; + let judged = verdict(&answer); + Ok((answer, judged, auth)) +} + impl DaemonAuth { + fn with_user_action(mut self, key: Zeroizing) -> Self { + self.user_action = Some(Arc::new(key)); + self + } + + fn holds_key(&self) -> bool { + self.user_action.is_some() + } + /// The two headers every request carries, already CRLF-terminated. /// /// Composed in one place so a request cannot state its secret without also @@ -226,23 +417,33 @@ pub(crate) fn build_get_request(path: &str, host: &str, auth: &DaemonAuth) -> St ) } -fn build_user_action_get_request( - path: &str, - host: &str, - auth: &DaemonAuth, -) -> Result> { - let proof = auth.user_action.as_ref().ok_or_else(|| { - anyhow!("this action requires a user-action key from the controlling terminal") - })?; +/// Put the user-action proof on a request to a route it can change, exactly +/// when `auth` holds the key. +/// +/// Without a key the header is left off entirely, never sent empty, and the +/// daemon judges the request as it would any other caller's: that is how +/// `with_key_if_wanted` learns whether this daemon wants the key at all. The +/// buffer is zeroizing either way, since it may hold the raw key. +fn push_proof(request: &mut Zeroizing, auth: &DaemonAuth) { + if let Some(proof) = auth.user_action.as_ref() { + request.push_str(USER_ACTION_HEADER); + request.push_str(": "); + request.push_str(proof); + request.push_str("\r\n"); + } +} + +/// `build_get_request`, for the one GET the proof can change: attach's event +/// stream, where it lets a person reach a private chat on a daemon that holds a +/// key. See [`push_proof`]. +fn build_protected_get_request(path: &str, host: &str, auth: &DaemonAuth) -> Zeroizing { let mut request = Zeroizing::new(format!( "GET {path} HTTP/1.1\r\nHost: {host}\r\n{}", auth.headers() )); - request.push_str(USER_ACTION_HEADER); - request.push_str(": "); - request.push_str(proof); - request.push_str("\r\nAccept: text/event-stream\r\nConnection: close\r\n\r\n"); - Ok(request) + push_proof(&mut request, auth); + request.push_str("Accept: text/event-stream\r\nConnection: close\r\n\r\n"); + request } #[cfg(test)] @@ -256,27 +457,25 @@ pub(crate) fn build_post_request(path: &str, host: &str, auth: &DaemonAuth, body ) } -fn build_user_action_post_request( +/// A POST to a route the user-action proof can change — `/interrupt`, +/// `/agent/cancel`, `/reply` — carrying the proof exactly when `auth` holds the +/// key. See [`push_proof`]. +fn build_protected_post_request( path: &str, host: &str, auth: &DaemonAuth, body: &str, -) -> Result> { - let proof = auth.user_action.as_ref().ok_or_else(|| { - anyhow!("this action requires a user-action key from the controlling terminal") - })?; +) -> Zeroizing { let mut request = Zeroizing::new(format!( "POST {path} HTTP/1.1\r\nHost: {host}\r\n{}", auth.headers() )); - request.push_str(USER_ACTION_HEADER); - request.push_str(": "); - request.push_str(proof); - request.push_str("\r\nContent-Type: application/json\r\nContent-Length: "); + push_proof(&mut request, auth); + request.push_str("Content-Type: application/json\r\nContent-Length: "); request.push_str(&body.len().to_string()); request.push_str("\r\nAccept: application/json\r\nConnection: close\r\n\r\n"); request.push_str(body); - Ok(request) + request } /// Append `chunk` to `buffer` and drain every COMPLETE SSE frame into `out`. @@ -619,7 +818,13 @@ async fn stream_request( /// A connection to the configured daemon, or the actionable "no daemon" error. async fn connect_to_daemon() -> Result { - let port = configured_port(); + connect_to_daemon_at(configured_port()).await +} + +/// [`connect_to_daemon`] for a port the caller names — a test's stand-in +/// daemon, which must not be reached by setting `BIOROUTER_PORT` in a process +/// every test shares. +async fn connect_to_daemon_at(port: u16) -> Result { if !daemon_ok(DAEMON_HOST, port).await { return Err(anyhow!("{}", no_daemon_at(port))); } @@ -887,11 +1092,20 @@ fn json_object(body: &str) -> Option { /// request made from inside an interactive loop must not be able to hang it, /// hence the deadline (as in `running_session_ids`). async fn post_json(path: &str, body: &str, auth: &DaemonAuth) -> Result<(u16, String)> { - let port = configured_port(); + post_json_to(configured_port(), path, body, auth).await +} + +/// [`post_json`] to the daemon on `port`; see [`connect_to_daemon_at`]. +async fn post_json_to( + port: u16, + path: &str, + body: &str, + auth: &DaemonAuth, +) -> Result<(u16, String)> { if !daemon_ok(DAEMON_HOST, port).await { return Err(anyhow!("{}", no_daemon_at(port))); } - let request = build_user_action_post_request(path, DAEMON_HOST, auth, body)?; + let request = build_protected_post_request(path, DAEMON_HOST, auth, body); let raw = tokio::time::timeout(std::time::Duration::from_secs(10), async { let mut stream = tokio::net::TcpStream::connect(format!("{DAEMON_HOST}:{port}")).await?; stream.write_all(request.as_bytes()).await?; @@ -909,14 +1123,46 @@ async fn post_json(path: &str, body: &str, auth: &DaemonAuth) -> Result<(u16, St .await .map_err(|_| anyhow!("the daemon did not answer POST {path} within 10s"))??; - let text = String::from_utf8_lossy(&raw).to_string(); - let (head, body) = text - .split_once("\r\n\r\n") - .ok_or_else(|| anyhow!("daemon sent a malformed response to POST {path}"))?; + parse_http_response(&raw, &format!("POST {path}")) +} + +/// One HTTP response, as its status code and its body — dechunked when the head +/// says the body is chunked. +/// +/// ⚠ **Chunked framing is not part of the body, and reading it as one is not a +/// theoretical worry.** hyper answers an EMPTY body with `transfer-encoding: +/// chunked` and a lone `0\r\n\r\n` terminator, so a parser that hands the bytes +/// back as they came reports a body of `"0"`. Measured against a real daemon +/// that holds a user-action key on 2026-09-11: [`key_verdict`] read that `"0"` +/// as the daemon's refusal sentence, so `session cancel` printed *the daemon +/// would not stop the turn: 0* instead of asking for the key the daemon was +/// waiting for — the empty 403 is exactly the answer that must reach it intact. +fn parse_http_response(raw: &[u8], what: &str) -> Result<(u16, String)> { + // Split on BYTES: a chunk size counts bytes, and a lossy decode first could + // move them. + let end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| { + anyhow!( + "the daemon closed the connection before sending a complete response to {what}, so \ + it is not known whether the request was carried out" + ) + })?; + let head = String::from_utf8_lossy(&raw[..end]).into_owned(); let status = head.lines().next().unwrap_or_default(); let code = status_code(status) .ok_or_else(|| anyhow!("daemon sent a response carrying no status code: {status}"))?; - Ok((code, body.to_string())) + let body = &raw[end + 4..]; + let body = if head + .to_ascii_lowercase() + .contains("transfer-encoding: chunked") + { + dechunk(body) + } else { + body.to_vec() + }; + Ok((code, String::from_utf8_lossy(&body).into_owned())) } /// One request to a JSON route that takes the secret key and nothing more — the @@ -958,31 +1204,7 @@ pub(crate) async fn daemon_json_request( None => exchange.await?, }; - // Split on BYTES: a chunk size counts bytes, and a lossy decode first could - // move them. - let end = raw - .windows(4) - .position(|w| w == b"\r\n\r\n") - .ok_or_else(|| { - anyhow!( - "the daemon closed the connection before sending a complete response to {method} \ - {path}, so it is not known whether the request was carried out" - ) - })?; - let head = String::from_utf8_lossy(&raw[..end]).into_owned(); - let status = head.lines().next().unwrap_or_default(); - let code = status_code(status) - .ok_or_else(|| anyhow!("daemon sent a response carrying no status code: {status}"))?; - let body = &raw[end + 4..]; - let body = if head - .to_ascii_lowercase() - .contains("transfer-encoding: chunked") - { - dechunk(body) - } else { - body.to_vec() - }; - Ok((code, String::from_utf8_lossy(&body).into_owned())) + parse_http_response(&raw, &format!("{method} {path}")) } /// An HTTP/1.1 chunked body, joined. Malformed framing ends the body where it @@ -1067,6 +1289,12 @@ pub(crate) enum SendOutcome { /// 202: the session is a subagent still starting, and the message was kept /// as steering for its first turn (`routes/reply.rs`). Queued, + /// 403: refused by the reach gate or the subagent rule, both of which + /// `/reply` asks before it takes the turn lock or writes anything — so + /// nothing happened, and the request can be made again. Whether the + /// user-action key would change the answer is [`send_to`]'s question; the + /// 403 alone cannot say (see [`key_verdict`]). + Forbidden, } /// Send one `POST /reply` over `stream` and read the answer as far as `wait` @@ -1116,11 +1344,97 @@ where turn_id: streamed.turn_id, }), 202 => Ok(SendOutcome::Queued), + 403 => Ok(SendOutcome::Forbidden), code => Err(anyhow!( "daemon refused the request: HTTP {code}\n\ - (401 usually means BIOROUTER_SERVER__SECRET_KEY does not match the daemon's; \ - 403 means the user-action key does not match the daemon's configured digest)" + (401 usually means BIOROUTER_SERVER__SECRET_KEY does not match the daemon's)" + )), + } +} + +/// The empty steer [`settle_steering_key`] and [`send_to`] ask a daemon with. +/// +/// ⚠ **A question, never a delivery.** `/interrupt` judges who may steer before +/// it reads the text, and refuses empty text with a 400 before it touches the +/// turn, the agent or a subagent's pending input (`routes::reply::interrupt`). +/// So the answer is the gate's verdict and nothing else: 400 when this terminal +/// may steer the session as it is, an empty 403 when the daemon wants the key, +/// and a 403 in the daemon's words when no key would help. ⚠ On a **keyless** +/// daemon the third is the only answer there is — since SD-11 settled, a steer +/// asks for the proof on both kinds of daemon, so `reply::steer_refusal` answers +/// from the headers and the 400 is unreachable there whatever chat is named. The +/// daemon's side is pinned by `routes::reply`'s keyed tests and +/// `tests/turn_control_no_user_key.rs`. +fn steer_gate_question(session_id: &str) -> String { + serde_json::json!({ "session_id": session_id, "text": "" }).to_string() +} + +/// One `/reply` attempt, and — when it was refused — the steer gate's answer +/// to the same auth, which says whether the key would change that. +struct ReplyAttempt { + outcome: SendOutcome, + gate: Option<(u16, String)>, +} + +/// `session send` against the daemon on `port`, asking the person for the key +/// only if the daemon wants it (see [`with_key_if_wanted`]). +/// +/// A refused `/reply` wrote nothing (see [`SendOutcome::Forbidden`]), so making +/// it again with the key cannot deliver the text twice. +async fn send_to( + port: u16, + session_id: &str, + text: &str, + wait: bool, + auth: DaemonAuth, + ask: Ask, +) -> Result +where + Ask: FnOnce() -> AskFut, + AskFut: std::future::Future>>, +{ + let body = reply_body(session_id, text); + let question = steer_gate_question(session_id); + let (body, question) = (&body, &question); + let (attempt, verdict, _) = with_key_if_wanted( + auth, + |auth| async move { + let request = build_protected_post_request("/reply", DAEMON_HOST, &auth, body); + let mut stream = connect_to_daemon_at(port).await?; + let outcome = + send_turn(&mut stream, request.as_bytes(), wait, NO_WAIT_DEADLINE).await?; + let gate = match outcome { + SendOutcome::Forbidden => { + Some(post_json_to(port, "/interrupt", question, &auth).await?) + } + _ => None, + }; + Ok(ReplyAttempt { outcome, gate }) + }, + |attempt: &ReplyAttempt| { + attempt + .gate + .as_ref() + .map_or(KeyVerdict::NotAsked, |(code, answer)| { + key_verdict(*code, answer) + }) + }, + ask, + ) + .await?; + match (attempt.outcome, verdict) { + (SendOutcome::Forbidden, KeyVerdict::Wanted) => { + Err(anyhow!("{}", KeyUse::Send.wrong_key())) + } + (SendOutcome::Forbidden, KeyVerdict::Refused(sentence)) => Err(anyhow!( + "the daemon would not start a turn in session {session_id}: {sentence}" + )), + (SendOutcome::Forbidden, KeyVerdict::NotAsked) => Err(anyhow!( + "the daemon refused to start a turn in session {session_id} (HTTP 403) and gave no \ + reason. {}", + KeyUse::Send.undone() )), + (outcome, _) => Ok(outcome), } } @@ -1138,16 +1452,14 @@ pub async fn handle_session_send( wait: bool, user_action_key_stdin: bool, ) -> Result<()> { - let auth = daemon_auth_with_user_action(user_action_key_stdin).await?; - let request = build_user_action_post_request( - "/reply", - DAEMON_HOST, - &auth, - &reply_body(session_id, text), - )?; - // `/reply` streams the turn back, so a send that waits is one request. - let mut stream = connect_to_daemon().await?; - match send_turn(&mut stream, request.as_bytes(), wait, NO_WAIT_DEADLINE).await? { + let auth = with_supplied_key(daemon_auth().await?, user_action_key_stdin).await?; + // `/reply` streams the turn back, so a send that waits is one request — + // two, and a question between them, only when a daemon wants the key. + let outcome = send_to(configured_port(), session_id, text, wait, auth, || { + ask_terminal_for_key(KeyUse::Send) + }) + .await?; + match outcome { SendOutcome::Streamed => {} SendOutcome::Accepted { turn_id } => { match turn_id { @@ -1165,6 +1477,13 @@ pub async fn handle_session_send( "[queued] session {session_id} is a subagent that is still starting; the message \ will be part of its first turn" ), + // `send_to` turns a refusal into its reason before it gets here; this + // is only the wording it would fall back to. + SendOutcome::Forbidden => { + return Err(anyhow!( + "the daemon refused to start a turn in session {session_id} (HTTP 403)" + )) + } } Ok(()) } @@ -1208,16 +1527,53 @@ pub(crate) fn next_attempt(attempts: u8) -> Option { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CtrlCAction { Detach, - /// A `/reply` socket is open: exiting now cancels the turn (`stream_event` - /// trips the turn's cancellation token when the client hangs up). - WarnWouldCancel, + /// A `/reply` socket this attach opened is still streaming: leaving hands + /// the turn to the daemon with nobody watching it. + /// + /// ⚠ It does **not** cancel the turn, and this warned that it did until + /// 2026-09-12. `stream_event` in `routes/reply.rs` "cannot fail and cannot + /// cancel anything, and that is the point": a send failure ends that one + /// HTTP response and nothing else, and a turn with zero observers is an + /// ordinary state. What leaving does cost is the reaper's clock — see + /// [`ReplyWindow`]. + WarnTurnGoesUnwatched, ForceExit, } +/// The first ctrl-c's heads-up, when a turn this attach started is streaming. +/// +/// ⚠ It said "leaving now CANCELS it" until 2026-09-12, and that had stopped +/// being true: since the live-turn-stream work a `/reply` connection is only the +/// turn's first observer and `stream_event` "cannot fail and cannot cancel +/// anything" (`routes/reply.rs`). What leaving really costs is the reaper's +/// clock, so that is what this says — in the same words `session send --no-wait` +/// already used for the same daemon behaviour, including that `session watch` +/// is not an attachment to the reply stream. +/// +/// A function, not an inline `eprintln!`, so the claim can be asserted: the +/// stale one sat in the middle of an async `select!` loop where no test could +/// reach it. +pub(crate) fn leaving_warning(session_id: &str) -> String { + format!( + "\n⚠ a turn you started from here is still streaming. Leaving does NOT stop it: it keeps \ + running in the daemon, which ends a turn only after five minutes with nothing attached \ + to its reply stream (`session watch` does not count). Press ctrl-c again to leave, or \ + `biorouter session cancel {session_id}` to stop it." + ) +} + +/// What the second ctrl-c prints as it leaves. +pub(crate) fn leaving_notice(session_id: &str) -> String { + format!( + "\nleaving. The turn you started keeps running; attach again to follow it, or \ + `biorouter session cancel {session_id}` to stop it." + ) +} + pub(crate) fn ctrl_c_action(reply_socket_open: bool, already_warned: bool) -> CtrlCAction { match (reply_socket_open, already_warned) { (false, _) => CtrlCAction::Detach, - (true, false) => CtrlCAction::WarnWouldCancel, + (true, false) => CtrlCAction::WarnTurnGoesUnwatched, (true, true) => CtrlCAction::ForceExit, } } @@ -1301,10 +1657,18 @@ pub(crate) enum Delivered { /// Whether a `/reply` socket is open right now, and whether ctrl-c has already /// warned about it. Shared between the attach loop and its delivery worker. /// +/// **What the open window means.** Not that leaving would cancel the turn — it +/// would not, and this file used to say otherwise throughout. It means this +/// attach is the turn's only observer, so leaving starts the daemon's orphan +/// reaper clock (`turn_stream::DEFAULT_ORPHAN_TIMEOUT`, five minutes with +/// nothing attached to the turn's reply stream). The turn runs on until someone +/// attaches again or that runs out. Worth one heads-up before a turn the user +/// started here is left to it; not worth refusing to leave over. +/// /// **Counted, not a flag.** A `/reply` socket outlives the `deliver` call that /// opened it, so two can be streaming at once. A flag would let the first to -/// finish declare ctrl-c harmless while the second was still streaming, and -/// ctrl-c would then cancel that turn silently. +/// finish declare ctrl-c uneventful while the second was still streaming, and +/// the second turn would be left unwatched with nothing said. /// /// **One critical section, not two atomics.** Reading "open" and "warned" /// separately and then latching the warning with no re-check let a delivery @@ -1354,7 +1718,7 @@ impl ReplyWindow { fn on_ctrl_c(&self) -> CtrlCAction { let mut state = self.lock(); let action = ctrl_c_action(state.open > 0, state.warned); - if action == CtrlCAction::WarnWouldCancel { + if action == CtrlCAction::WarnTurnGoesUnwatched { state.warned = true; } action @@ -1375,12 +1739,31 @@ async fn post_interrupt(session_id: &str, text: &str, auth: &DaemonAuth) -> Resu .to_string(), }), (409, _) => Ok(SteerOutcome::Refused), - (code, _) => Err(anyhow!( + (code, body) => Err(steer_refusal(code, &body, auth)), + } +} + +/// Why a steer was refused, from the answer `/interrupt` gave. +/// +/// Attach settled the key as it joined ([`settle_steering_key`]), so a refusal +/// for want of it here means the daemon changed underneath the attach — most +/// likely restarted with a key it did not hold before. The prompt cannot be +/// offered now, because stdin is the steering channel, so the person is told to +/// attach again rather than asked. +fn steer_refusal(code: u16, body: &str, auth: &DaemonAuth) -> anyhow::Error { + match key_verdict(code, body) { + KeyVerdict::Refused(sentence) => anyhow!("the daemon refused the steer: {sentence}"), + KeyVerdict::Wanted if auth.holds_key() => anyhow!("{}", KeyUse::Steer.wrong_key()), + KeyVerdict::Wanted => anyhow!( + "the daemon now wants a user-action key for steering, which it did not when this \ + attach began; it may have been restarted with one. Detach with ctrl-c and attach \ + again to be asked for it." + ), + KeyVerdict::NotAsked => anyhow!( "the daemon refused the steer: HTTP {code}\n\ (400 means the message was empty; 401 means BIOROUTER_SERVER__SECRET_KEY \ - does not match the daemon's; 403 means the user-action key does not \ - match the daemon's configured digest)" - )), + does not match the daemon's)" + ), } } @@ -1391,9 +1774,13 @@ async fn post_interrupt(session_id: &str, text: &str, auth: &DaemonAuth) -> Resu /// /// * Printing would double every line — the observer stream is already /// rendering this turn. -/// * Abandoning the socket would CANCEL the turn: `/reply`'s `stream_event` -/// trips the turn's cancellation token the moment its `tx.send` fails. So the -/// body must be consumed and discarded, not dropped. +/// * Dropping the socket would leave the turn UNOBSERVED: `/reply`'s response is +/// "simply the turn's FIRST observer" (`routes/reply.rs`), and when the last +/// observer goes the daemon's orphan reaper starts its five-minute clock. It +/// no longer *cancels* the turn — `stream_event` "cannot fail and cannot +/// cancel anything" since the live-turn-stream work — but a turn nobody is +/// watching is still on borrowed time. So the body is consumed and discarded, +/// not dropped. /// * But *waiting* for it here would block the delivery worker for the whole /// turn, and a mid-turn correction the user typed would then sit in a local /// queue and go out minutes later as a brand new turn — adjudicated by the @@ -1401,8 +1788,8 @@ async fn post_interrupt(session_id: &str, text: &str, auth: &DaemonAuth) -> Resu /// including the ones it started itself, so the wait has to go. /// /// Hence: the acceptance comes from the **status line** over a channel, and the -/// holder task owns the socket (and the `ReplyWindow`, so ctrl-c still knows an -/// exit would cancel a turn) until the terminal frame. +/// holder task owns the socket (and the `ReplyWindow`, so ctrl-c still knows a +/// turn started from here is streaming) until the terminal frame. async fn post_reply_quiet( session_id: &str, text: &str, @@ -1410,7 +1797,7 @@ async fn post_reply_quiet( window: Arc, ) -> Result { let request = - build_user_action_post_request("/reply", DAEMON_HOST, auth, &reply_body(session_id, text))?; + build_protected_post_request("/reply", DAEMON_HOST, auth, &reply_body(session_id, text)); let (status_tx, status_rx) = tokio::sync::oneshot::channel(); // Opened from before the request rather than from the 200: erring towards // warning about a turn that does not exist is harmless, erring the other way @@ -1451,7 +1838,7 @@ async fn post_reply_quiet( Ok(code) => Err(anyhow!( "the daemon refused to start a turn: HTTP {code}\n\ (401 usually means BIOROUTER_SERVER__SECRET_KEY does not match the daemon's; \ - 403 means the user-action key does not match the daemon's configured digest)" + 403 means the daemon will not start a turn in this session for this terminal)" )), // The sender was dropped without a status: no status line was ever // read, so the request failed outright. The holder carries the reason. @@ -1710,6 +2097,57 @@ fn spawn_delivery_worker( send_tx } +/// Before stdin becomes the steering channel: will the daemon on `port` take a +/// steer from this terminal, and does it want the key for one? +/// +/// ⚠ **Asked here, once, and never at the first steer.** A daemon's refusal is +/// the only way to learn it wants the key ([`key_verdict`]), but by the first +/// steer the stdin reader owns stdin, holding its lock for the whole loop. A +/// hidden prompt then would block on that lock; without the lock, the typed key +/// would race the reader and could be delivered to the session as a message. +/// So attach asks as it joins, with the empty steer [`steer_gate_question`] +/// describes, and settles the key before anything reads a line. +/// +/// Returns the auth every later attach request carries: holding the key only +/// when the person supplied it or the daemon wanted it, because a daemon that +/// holds none has nothing to check a key against. +async fn settle_steering_key( + port: u16, + session_id: &str, + auth: DaemonAuth, + ask: Ask, +) -> Result +where + Ask: FnOnce() -> AskFut, + AskFut: std::future::Future>>, +{ + let question = steer_gate_question(session_id); + let question = &question; + let ((code, _), verdict, auth) = with_key_if_wanted( + auth, + |auth| async move { post_json_to(port, "/interrupt", question, &auth).await }, + |(code, answer): &(u16, String)| key_verdict(*code, answer), + ask, + ) + .await?; + match verdict { + KeyVerdict::Wanted => Err(anyhow!("{}", KeyUse::Steer.wrong_key())), + KeyVerdict::Refused(sentence) => Err(anyhow!( + "the daemon will not take a steer from this terminal in session {session_id}: \ + {sentence}\nTo follow the session without steering it: \ + biorouter session attach {session_id} --read-only" + )), + KeyVerdict::NotAsked if code == 401 => Err(anyhow!( + "the daemon refused this terminal: HTTP 401 \ + (BIOROUTER_SERVER__SECRET_KEY does not match the daemon's)" + )), + // 400 is the answer to the question: the gate let this terminal + // through, and only the empty text was refused. Anything else is left to + // the event stream, which reports it in its own terms. + KeyVerdict::NotAsked => Ok(auth), + } +} + /// `biorouter session attach ` — render where the session is, follow it /// live, and steer it from stdin. /// @@ -1729,10 +2167,16 @@ pub async fn handle_session_attach( // lookup. let auth = daemon_auth().await?; let session_id = resolve_attach_target(session_id, name, of).await?; + // Before the stdin reader starts — see `settle_steering_key` for why it + // cannot wait for the first steer. let auth = if read_only { auth } else { - auth_with_user_action(auth, user_action_key_stdin).await? + let auth = with_supplied_key(auth, user_action_key_stdin).await?; + settle_steering_key(configured_port(), &session_id, auth, || { + ask_terminal_for_key(KeyUse::Steer) + }) + .await? }; if read_only { @@ -1760,20 +2204,13 @@ pub async fn handle_session_attach( // The observer stream, exactly as `watch --follow`, except that its first // frame is rendered as a transcript. It is READ-ONLY: its task in the daemon // merely returns when the channel closes and cancels nothing, so detaching - // can never stop the session. - let observer_request = if read_only { - Zeroizing::new(build_get_request( - &format!("/sessions/{session_id}/events"), - DAEMON_HOST, - &auth, - )) - } else { - build_user_action_get_request( - &format!("/sessions/{session_id}/events"), - DAEMON_HOST, - &auth, - )? - }; + // can never stop the session. It carries the key when this attach holds one + // — never with `--read-only`. + let observer_request = build_protected_get_request( + &format!("/sessions/{session_id}/events"), + DAEMON_HOST, + &auth, + ); let observer = stream_request_bytes( observer_request.as_bytes(), Until::Closed, @@ -1830,15 +2267,11 @@ pub async fn handle_session_attach( ); return Ok(()); } - CtrlCAction::WarnWouldCancel => { - eprintln!( - "\n⚠ a turn you started from here is still streaming, and leaving \ - now CANCELS it. Press ctrl-c again to leave anyway, or wait for it \ - to finish." - ); + CtrlCAction::WarnTurnGoesUnwatched => { + eprintln!("{}", leaving_warning(&session_id)); } CtrlCAction::ForceExit => { - eprintln!("\nleaving: the turn you started is being cancelled."); + eprintln!("{}", leaving_notice(&session_id)); return Ok(()); } } @@ -1881,21 +2314,58 @@ pub(crate) fn render_cancel(response: &serde_json::Value) -> Result { /// `workspace_close scope:"turn"` is the agent's version of the same act, and /// `POST /agent/cancel` is the route the GUI's Stop button already uses. pub async fn handle_session_cancel(session_id: &str, user_action_key_stdin: bool) -> Result<()> { - let auth = daemon_auth_with_user_action(user_action_key_stdin).await?; + let auth = with_supplied_key(daemon_auth().await?, user_action_key_stdin).await?; + let line = cancel_turn(configured_port(), session_id, auth, || { + ask_terminal_for_key(KeyUse::Stop) + }) + .await?; + println!("{line}"); + Ok(()) +} + +/// `session cancel` against the daemon on `port`: the line to print, or why the +/// turn was not stopped. The person is asked for the key only if the daemon +/// wants it (see [`with_key_if_wanted`]); a refused cancel stopped nothing, so +/// making it again with the key is safe. +async fn cancel_turn( + port: u16, + session_id: &str, + auth: DaemonAuth, + ask: Ask, +) -> Result +where + Ask: FnOnce() -> AskFut, + AskFut: std::future::Future>>, +{ let body = serde_json::json!({ "session_id": session_id }).to_string(); - let (code, body) = post_json("/agent/cancel", &body, &auth).await?; + let body = &body; + let ((code, answer), verdict, _) = with_key_if_wanted( + auth, + |auth| async move { post_json_to(port, "/agent/cancel", body, &auth).await }, + |(code, answer): &(u16, String)| key_verdict(*code, answer), + ask, + ) + .await?; + match verdict { + KeyVerdict::Wanted => return Err(anyhow!("{}", KeyUse::Stop.wrong_key())), + KeyVerdict::Refused(sentence) => { + return Err(anyhow!("the daemon would not stop the turn: {sentence}")) + } + KeyVerdict::NotAsked => {} + } if code != 200 { + let said = refusal_sentence(&answer) + .map(|sentence| format!(": {sentence}")) + .unwrap_or_default(); return Err(anyhow!( - "the daemon refused the cancel: HTTP {code}\n\ - (401 usually means BIOROUTER_SERVER__SECRET_KEY does not match the daemon's; \ - 403 means the user-action key does not match the daemon's configured digest)" + "the daemon refused the cancel: HTTP {code}{said}\n\ + (401 usually means BIOROUTER_SERVER__SECRET_KEY does not match the daemon's)" )); } - let response = json_object(&body).ok_or_else(|| { + let response = json_object(&answer).ok_or_else(|| { anyhow!("the daemon answered POST /agent/cancel with a body this client could not read") })?; - println!("{}", render_cancel(&response)?); - Ok(()) + render_cancel(&response) } #[cfg(test)] @@ -2238,26 +2708,675 @@ mod tests { assert!(!ordinary.contains("proof-known-only-to-the-operator")); for path in ["/interrupt", "/agent/cancel", "/reply"] { - let protected = build_user_action_post_request(path, "127.0.0.1", &auth, "{}").unwrap(); + let protected = build_protected_post_request(path, "127.0.0.1", &auth, "{}"); assert!(protected.contains("X-User-Action: proof-known-only-to-the-operator\r\n")); assert!(protected.contains("X-Secret-Key: s3cret\r\n")); assert!(protected.contains("X-Caller-Provider: versa_azure\r\n")); + assert!( + protected.ends_with("\r\n\r\n{}"), + "{path}: the body must follow" + ); } let protected_get = - build_user_action_get_request("/sessions/child/events", "127.0.0.1", &auth).unwrap(); + build_protected_get_request("/sessions/child/events", "127.0.0.1", &auth); assert!(protected_get.contains("X-User-Action: proof-known-only-to-the-operator\r\n")); let ordinary_get = build_get_request("/sessions/child/events", "127.0.0.1", &auth); assert!(!ordinary_get.contains(USER_ACTION_HEADER)); assert!(!ordinary_get.contains("proof-known-only-to-the-operator")); } + /// Without the key, a protected request goes out WITHOUT the header: not + /// refused here, and not sent with an empty one. + /// + /// This replaces a test that pinned the opposite — a builder that refused to + /// make the request at all, which is what made `session cancel` and attach's + /// steering refuse locally against a `biorouter serve` daemon that would have + /// admitted them (serve decision SD-11). The local refusal was never a + /// boundary: the daemon is, and its answer to this very request is how the + /// terminal learns whether it wants the key (`with_key_if_wanted`). #[test] - fn a_protected_post_without_live_operator_proof_fails_closed() { + fn a_protected_request_without_the_key_is_sent_without_the_header() { let auth = DaemonAuth::for_test("s3cret", "versa_azure"); - let error = - build_user_action_post_request("/agent/cancel", "127.0.0.1", &auth, "{}").unwrap_err(); - assert!(error.to_string().contains("user-action key")); + for path in ["/interrupt", "/agent/cancel", "/reply"] { + let request = build_protected_post_request(path, "127.0.0.1", &auth, "{\"a\":1}"); + assert!(!request.contains(USER_ACTION_HEADER), "{path}"); + // …and it is still a whole request. + assert!(request.starts_with(&format!("POST {path} HTTP/1.1\r\n"))); + assert!(request.contains("X-Secret-Key: s3cret\r\n")); + assert!(request.contains("X-Caller-Provider: versa_azure\r\n")); + assert!(request.contains("Content-Length: 7\r\n")); + assert!(request.ends_with("\r\n\r\n{\"a\":1}")); + } + let get = build_protected_get_request("/sessions/child/events", "127.0.0.1", &auth); + assert!(!get.contains(USER_ACTION_HEADER)); + assert!(get.ends_with("Connection: close\r\n\r\n")); + } + + /// A daemon that holds no key refusing in its own words — the shape of + /// `SUBAGENT_CONTROL_NO_KEY` inside `ErrorResponse` — for the tests below. + const KEYLESS_REFUSAL: &str = "This daemon was started without a user-action key, so it \ + cannot verify that a request came from the person at the \ + keyboard. Nothing was changed. This control is unavailable \ + on this daemon; use the desktop app."; + + fn keyless_refusal_body() -> String { + serde_json::json!({ "message": KEYLESS_REFUSAL }).to_string() + } + + /// The two kinds of daemon, told apart from the answer to a stop or a steer + /// sent without the key: the reading that decides whether a person is asked + /// for one at all. + #[test] + fn only_a_keyed_daemons_empty_refusal_asks_for_the_key() { + // A daemon that holds a key, refusing a request without the proof + // (`authorize_turn_control`'s `Unproven` arm). + assert_eq!(key_verdict(403, ""), KeyVerdict::Wanted); + assert_eq!(key_verdict(403, "\r\n"), KeyVerdict::Wanted); + + // A daemon that holds none, in its own words: no key would help. + assert_eq!( + key_verdict(403, &keyless_refusal_body()), + KeyVerdict::Refused(KEYLESS_REFUSAL.to_string()) + ); + // …in plain text too, as the reach gate answers where a route hands its + // refusal back directly. + assert_eq!( + key_verdict( + 403, + "That chat is private, or there is no chat with that id." + ), + KeyVerdict::Refused( + "That chat is private, or there is no chat with that id.".to_string() + ) + ); + + // Nothing that is not a 403 is about the key — including an admitted + // empty steer's 400, which is the answer attach asks for. + for (code, body) in [ + (200, "{\"cancelled\":false}"), + (202, "{\"turn_id\":\"t\"}"), + (400, ""), + (401, ""), + (409, "{}"), + (500, "{\"message\":\"Failed to get session\"}"), + ] { + assert_eq!(key_verdict(code, body), KeyVerdict::NotAsked, "HTTP {code}"); + } + } + + #[test] + fn a_refusal_is_shown_in_the_daemons_own_words() { + assert_eq!( + refusal_sentence("{\"message\":\" No. \"}"), + Some("No.".to_string()) + ); + assert_eq!( + refusal_sentence("No, in plain text.\n"), + Some("No, in plain text.".to_string()) + ); + assert_eq!(refusal_sentence(""), None); + assert_eq!(refusal_sentence(" \r\n"), None); + // An object with no message is shown as it came rather than dropped. + assert_eq!( + refusal_sentence("{\"error\":\"x\"}"), + Some("{\"error\":\"x\"}".to_string()) + ); + } + + /// The person at the terminal, typing `key` — and counting how often they + /// were asked. + fn person_types<'a>( + key: &'static str, + asked: &'a std::cell::Cell, + ) -> impl FnOnce() -> std::future::Ready>> + 'a { + move || { + asked.set(asked.get() + 1); + std::future::ready(Ok(Zeroizing::new(key.to_string()))) + } + } + + /// `with_key_if_wanted` over every answer order a daemon can give, driven + /// through the function itself (as `run_ladder`'s test is): the first + /// request never carries a key the person did not supply; the person is + /// asked only after a refusal for want of one, and at most once; and a key + /// supplied up front is never followed by a prompt. + #[tokio::test] + async fn the_person_is_asked_for_the_key_only_after_the_daemon_wants_it() { + /// One daemon's answers, and what the terminal must do with them. + struct Case { + /// What the daemon answers, one per request, in order. + answers: Vec<(u16, String)>, + /// `--user-action-key-stdin` supplied the key before anything was sent. + supplied: bool, + /// Whether each request in turn carried the key. + held: Vec, + /// How often the person was asked for it. + asked: u32, + verdict: KeyVerdict, + } + + let admitted = (200u16, "{}".to_string()); + let wants_key = (403u16, String::new()); + let in_words = (403u16, keyless_refusal_body()); + let cases = vec![ + // A daemon without a key admits it: one request, no key, nobody asked. + Case { + answers: vec![admitted.clone()], + supplied: false, + held: vec![false], + asked: 0, + verdict: KeyVerdict::NotAsked, + }, + // A daemon with one wants it: asked once, and the second request carries it. + Case { + answers: vec![wants_key.clone(), admitted.clone()], + supplied: false, + held: vec![false, true], + asked: 1, + verdict: KeyVerdict::NotAsked, + }, + // A daemon without a key refuses in words: shown, and nobody is asked. + Case { + answers: vec![in_words], + supplied: false, + held: vec![false], + asked: 0, + verdict: KeyVerdict::Refused(KEYLESS_REFUSAL.to_string()), + }, + // A wrong key: refused again, and the person is not asked twice. + Case { + answers: vec![wants_key.clone(), wants_key.clone()], + supplied: false, + held: vec![false, true], + asked: 1, + verdict: KeyVerdict::Wanted, + }, + // Supplied on stdin: sent at once, and never followed by a prompt — + // not when admitted, and not when refused either. + Case { + answers: vec![admitted], + supplied: true, + held: vec![true], + asked: 0, + verdict: KeyVerdict::NotAsked, + }, + Case { + answers: vec![wants_key], + supplied: true, + held: vec![true], + asked: 0, + verdict: KeyVerdict::Wanted, + }, + ]; + for case in cases { + let held = std::cell::RefCell::new(Vec::::new()); + let asked = std::cell::Cell::new(0); + let auth = if case.supplied { + DaemonAuth::for_test_with_user_action("s3cret", "", "from-stdin") + } else { + DaemonAuth::for_test("s3cret", "") + }; + let answers = &case.answers; + let (_, verdict, auth) = with_key_if_wanted( + auth, + |auth: DaemonAuth| { + let answer = answers[held.borrow().len()].clone(); + held.borrow_mut().push(auth.holds_key()); + async move { Ok(answer) } + }, + |(code, body): &(u16, String)| key_verdict(*code, body), + person_types("typed", &asked), + ) + .await + .unwrap(); + assert_eq!(*held.borrow(), case.held, "answers {answers:?}"); + assert_eq!(asked.get(), case.asked, "answers {answers:?}"); + assert_eq!(verdict, case.verdict, "answers {answers:?}"); + assert_eq!(auth.holds_key(), case.held.last() == Some(&true)); + } + } + + /// With no terminal to ask on, a daemon that wants the key gets no second + /// request, and the error says how to supply it. + #[tokio::test] + async fn with_no_terminal_to_ask_on_the_request_is_not_retried() { + let attempts = std::cell::Cell::new(0); + let err = with_key_if_wanted( + DaemonAuth::for_test("s3cret", ""), + |_auth: DaemonAuth| { + attempts.set(attempts.get() + 1); + async { Ok((403u16, String::new())) } + }, + |(code, body): &(u16, String)| key_verdict(*code, body), + || async { Err(anyhow!("{}", KeyUse::Stop.no_terminal())) }, + ) + .await + // `DaemonAuth` has no `Debug`, on purpose: it holds the secret and the key. + .map(|_| ()) + .unwrap_err() + .to_string(); + assert_eq!(attempts.get(), 1); + assert!(err.contains("--user-action-key-stdin"), "{err}"); + assert!(err.contains("The turn was not stopped."), "{err}"); + } + + /// A stand-in daemon on an ephemeral port. It answers the `GET /status` + /// every command probes first, then each further request, in order, with the + /// next scripted response, and records what it was sent, so a test reads + /// exactly what went over the wire, key header included. Reached by port + /// rather than through `BIOROUTER_PORT`, which every test in this process + /// shares. + struct FakeDaemon { + port: u16, + requests: Arc>>, + } + + impl FakeDaemon { + async fn start(script: Vec) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let requests = Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = requests.clone(); + let mut script = std::collections::VecDeque::from(script); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let request = read_one_request(&mut socket).await; + let response = if request.starts_with("GET /status ") { + "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n".to_string() + } else { + seen.lock().unwrap().push(request); + // Past the script is a 500 no flow here treats as + // success, so an extra request shows up as a failure. + script.pop_front().unwrap_or_else(|| { + http("500 Internal Server Error", "{\"message\":\"unscripted\"}") + }) + }; + let _ = socket.write_all(response.as_bytes()).await; + } + }); + Self { port, requests } + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + } + + /// One whole request: the head, then as many body bytes as it declares. + async fn read_one_request(socket: &mut tokio::net::TcpStream) -> String { + let mut raw = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + let read = socket.read(&mut chunk).await.unwrap_or(0); + if read == 0 { + break; + } + raw.extend_from_slice(&chunk[..read]); + if let Some(end) = raw.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase(); + let declared = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + if raw.len() >= end + 4 + declared { + break; + } + } + } + String::from_utf8_lossy(&raw).into_owned() + } + + fn http(status: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + } + + /// The same answer as a real daemon frames it: chunked, which is what hyper + /// does for these routes. See [`empty_403`]. + fn chunked(status: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ntransfer-encoding: chunked\ + \r\n\r\n{:x}\r\n{body}\r\n0\r\n\r\n", + body.len() + ) + } + + /// `authorize_turn_control`'s refusal on a daemon that holds a key, framed + /// as a real one frames it. + /// + /// ⚠ **Chunked, with a lone `0\r\n\r\n` terminator, because that is what was + /// on the wire.** Captured from a keyed `biorouterd` on 2026-09-11: hyper + /// sends no `content-length` for an empty body. A fixture that used + /// `content-length: 0` passed every test here while the real thing was read + /// as a refusal whose sentence was "0" — so the terminal printed that + /// instead of asking for the key. [`parse_http_response`] is what keeps the + /// framing out of the body. + fn empty_403() -> String { + "HTTP/1.1 403 Forbidden\r\nconnection: close\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n" + .to_string() + } + + /// An empty refusal is an empty refusal however the daemon framed it, and + /// either way it is the answer that asks the person for the key. + #[tokio::test] + async fn an_empty_refusal_reads_as_wanted_however_it_is_framed() { + for empty in [ + empty_403(), + "HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n".to_string(), + // Belt and braces: a chunked body that is genuinely empty, spelled + // with the terminator on its own read. + "HTTP/1.1 403 Forbidden\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n".to_string(), + ] { + let daemon = + FakeDaemon::start(vec![empty.clone(), http("200 OK", "{\"cancelled\":false}")]) + .await; + let asked = std::cell::Cell::new(0); + let line = cancel_turn( + daemon.port, + "20260911_4", + DaemonAuth::for_test("s3cret", ""), + person_types("the-typed-key", &asked), + ) + .await + .unwrap_or_else(|err| panic!("framing {empty:?} was not read as an empty 403: {err}")); + assert_eq!( + line, + "nothing to cancel: this session had no turn in flight" + ); + assert_eq!(asked.get(), 1, "framing {empty:?}"); + } + } + + /// The regression SD-11 left behind, end to end over a socket: on a daemon + /// that holds no key, `session cancel` goes out without one, the daemon + /// admits it, and the person is never asked for a key that does not exist. + #[tokio::test] + async fn cancel_on_a_daemon_without_a_key_never_asks_for_one() { + let daemon = FakeDaemon::start(vec![http( + "200 OK", + "{\"cancelled\":true,\"turn_id\":\"turn-3\"}", + )]) + .await; + let asked = std::cell::Cell::new(0); + let line = cancel_turn( + daemon.port, + "20260911_4", + DaemonAuth::for_test("s3cret", ""), + person_types("never-typed", &asked), + ) + .await + .unwrap(); + assert_eq!(line, "cancelled turn turn-3"); + assert_eq!( + asked.get(), + 0, + "a daemon that admitted the request was not asked about" + ); + let requests = daemon.requests(); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert!(requests[0].starts_with("POST /agent/cancel HTTP/1.1\r\n")); + assert!(requests[0].ends_with("{\"session_id\":\"20260911_4\"}")); + assert!(!requests[0].contains(USER_ACTION_HEADER)); + } + + /// A daemon that holds a key: the first request asks it, the person is + /// asked once, and the key goes on the second request and nowhere else. + #[tokio::test] + async fn cancel_on_a_daemon_with_a_key_asks_once_and_sends_it() { + let daemon = FakeDaemon::start(vec![ + empty_403(), + http("200 OK", "{\"cancelled\":false,\"turn_id\":null}"), + ]) + .await; + let asked = std::cell::Cell::new(0); + let line = cancel_turn( + daemon.port, + "20260911_4", + DaemonAuth::for_test("s3cret", ""), + person_types("the-typed-key", &asked), + ) + .await + .unwrap(); + assert_eq!( + line, + "nothing to cancel: this session had no turn in flight" + ); + assert_eq!(asked.get(), 1); + let requests = daemon.requests(); + assert_eq!(requests.len(), 2, "{requests:?}"); + assert!(!requests[0].contains(USER_ACTION_HEADER)); + assert!(!requests[0].contains("the-typed-key")); + assert!(requests[1].contains("X-User-Action: the-typed-key\r\n")); + } + + /// A refusal no key can change — a subagent's session on a daemon that holds + /// none — is shown in the daemon's words, and nobody is asked for a key. + #[tokio::test] + async fn a_refusal_no_key_can_change_is_shown_rather_than_prompted_for() { + // Chunked, as a real daemon sends it: the sentence must survive the + // framing intact, with no chunk sizes in it. + let daemon = + FakeDaemon::start(vec![chunked("403 Forbidden", &keyless_refusal_body())]).await; + let asked = std::cell::Cell::new(0); + let err = cancel_turn( + daemon.port, + "20260911_5", + DaemonAuth::for_test("s3cret", ""), + person_types("never-typed", &asked), + ) + .await + .unwrap_err() + .to_string(); + assert!(err.contains(KEYLESS_REFUSAL), "{err}"); + assert!( + !err.contains("does not match"), + "a keyless refusal is not a key mismatch: {err}" + ); + assert_eq!(asked.get(), 0); + assert_eq!(daemon.requests().len(), 1); + } + + /// A key the daemon does not recognise is reported as wrong, whether it was + /// typed or supplied on stdin, and is never answered with another prompt. + #[tokio::test] + async fn a_wrong_key_is_reported_as_wrong_and_not_asked_for_again() { + let daemon = FakeDaemon::start(vec![empty_403(), empty_403()]).await; + let asked = std::cell::Cell::new(0); + let err = cancel_turn( + daemon.port, + "20260911_4", + DaemonAuth::for_test("s3cret", ""), + person_types("a-wrong-key", &asked), + ) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("not the key this daemon was started with"), + "{err}" + ); + assert!(err.contains("The turn was not stopped."), "{err}"); + assert_eq!(asked.get(), 1); + assert_eq!(daemon.requests().len(), 2); + + let daemon = FakeDaemon::start(vec![empty_403()]).await; + let err = cancel_turn( + daemon.port, + "20260911_4", + DaemonAuth::for_test_with_user_action("s3cret", "", "a-wrong-key"), + person_types("never-typed", &asked), + ) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("not the key this daemon was started with"), + "{err}" + ); + assert_eq!( + asked.get(), + 1, + "a supplied key is never followed by a prompt" + ); + let requests = daemon.requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0].contains("X-User-Action: a-wrong-key\r\n")); + } + + /// Attach asks before it reads a single line, with an empty steer, and goes + /// on without a key where the daemon admits it; asks for the key once where + /// the daemon wants it, checking the key before anything is typed; and stops + /// with the daemon's words, and the way to watch instead, where no key would + /// help. + #[tokio::test] + async fn attach_settles_the_key_with_an_empty_steer_before_it_reads_stdin() { + // A daemon without a key: the gate lets this terminal through and only + // the empty text is refused. + let daemon = FakeDaemon::start(vec![http("400 Bad Request", "")]).await; + let asked = std::cell::Cell::new(0); + let auth = settle_steering_key( + daemon.port, + "20260911_6", + DaemonAuth::for_test("s3cret", ""), + person_types("never-typed", &asked), + ) + .await + .unwrap(); + assert!(!auth.holds_key()); + assert_eq!(asked.get(), 0); + let requests = daemon.requests(); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert!(requests[0].starts_with("POST /interrupt HTTP/1.1\r\n")); + assert!( + requests[0].ends_with("{\"session_id\":\"20260911_6\",\"text\":\"\"}"), + "the question carries no text to deliver: {}", + requests[0] + ); + assert!(!requests[0].contains(USER_ACTION_HEADER)); + + // A daemon with a key: asked once, and the key is tried on the same + // question before attach reads anything. + let daemon = FakeDaemon::start(vec![empty_403(), http("400 Bad Request", "")]).await; + let auth = settle_steering_key( + daemon.port, + "20260911_6", + DaemonAuth::for_test("s3cret", ""), + person_types("the-typed-key", &asked), + ) + .await + .unwrap(); + assert!(auth.holds_key()); + assert_eq!(asked.get(), 1); + assert!(daemon.requests()[1].contains("X-User-Action: the-typed-key\r\n")); + + // A daemon without a key, and a session it will not let this terminal + // steer: no prompt, its words, and `--read-only`. + let daemon = FakeDaemon::start(vec![http("403 Forbidden", &keyless_refusal_body())]).await; + let err = settle_steering_key( + daemon.port, + "20260911_7", + DaemonAuth::for_test("s3cret", ""), + person_types("never-typed", &asked), + ) + .await + .map(|_| ()) + .unwrap_err() + .to_string(); + assert!(err.contains(KEYLESS_REFUSAL), "{err}"); + assert!( + err.contains("biorouter session attach 20260911_7 --read-only"), + "{err}" + ); + assert_eq!(asked.get(), 1, "not asked again"); + } + + /// A turn's whole stream, as `/reply` sends it when a `send` waits. + fn a_whole_turn() -> String { + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n\ + data: {\"type\":\"Finish\",\"reason\":\"stop\",\"seq\":1,\"turn_id\":\"turn-8\"}\n\n" + .to_string() + } + + /// `send` makes one proof-less `/reply`, and only its refusal leads anywhere + /// else: to the steer gate, which says whether the key would change it, and + /// on to the person only when it would. + #[tokio::test] + async fn send_asks_for_the_key_only_when_a_daemon_holding_one_refuses() { + let asked = std::cell::Cell::new(0); + + // An ordinary chat, on either kind of daemon: one request, no key. + let daemon = FakeDaemon::start(vec![a_whole_turn()]).await; + let outcome = send_to( + daemon.port, + "20260911_8", + "hello", + true, + DaemonAuth::for_test("s3cret", ""), + person_types("never-typed", &asked), + ) + .await + .unwrap(); + assert_eq!(outcome, SendOutcome::Streamed); + assert_eq!(asked.get(), 0); + let requests = daemon.requests(); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert!(requests[0].starts_with("POST /reply HTTP/1.1\r\n")); + assert!(!requests[0].contains(USER_ACTION_HEADER)); + + // A subagent's session on a daemon that holds a key: the refusal, the + // question, the person, and the same text again with the key. + let daemon = FakeDaemon::start(vec![empty_403(), empty_403(), a_whole_turn()]).await; + let outcome = send_to( + daemon.port, + "20260911_9", + "hello", + true, + DaemonAuth::for_test("s3cret", ""), + person_types("the-typed-key", &asked), + ) + .await + .unwrap(); + assert_eq!(outcome, SendOutcome::Streamed); + assert_eq!(asked.get(), 1); + let requests = daemon.requests(); + assert_eq!(requests.len(), 3, "{requests:?}"); + assert!(requests[0].starts_with("POST /reply ")); + assert!(requests[1].starts_with("POST /interrupt ")); + assert!(requests[2].starts_with("POST /reply ")); + assert!(!requests[0].contains(USER_ACTION_HEADER)); + assert!(!requests[1].contains(USER_ACTION_HEADER)); + assert!(requests[2].contains("X-User-Action: the-typed-key\r\n")); + assert!(requests[2].contains("\"text\":\"hello\"")); + + // A subagent's session on a daemon that holds none: `/reply`'s empty 403 + // cannot say which kind of daemon this is, and the gate's words can. + let daemon = FakeDaemon::start(vec![ + empty_403(), + http("403 Forbidden", &keyless_refusal_body()), + ]) + .await; + let err = send_to( + daemon.port, + "20260911_9", + "hello", + true, + DaemonAuth::for_test("s3cret", ""), + person_types("never-typed", &asked), + ) + .await + .unwrap_err() + .to_string(); + assert!(err.contains(KEYLESS_REFUSAL), "{err}"); + assert_eq!( + asked.get(), + 1, + "not asked for a key no daemon here can check" + ); + assert_eq!(daemon.requests().len(), 2); } /// Issue #56 — **every** daemon request states the capability this terminal @@ -2948,11 +4067,57 @@ mod tests { assert_eq!(*attempted.borrow(), 1, "the ladder stopped at the failure"); } + /// The measured defect (2026-09-12): attach warned that leaving would cancel + /// the turn, and the daemon had stopped doing that. `stream_event` in + /// `routes/reply.rs` "cannot fail and cannot cancel anything"; a `/reply` + /// connection's "departure means nothing to it"; only the orphan reaper ends + /// a turn for want of an audience, after five minutes. A warning that + /// overstates the cost teaches the user to distrust the ones that do not. + /// + /// Fails the shipped sentence on the first assertion: it read "leaving now + /// CANCELS it". + #[test] + fn leaving_is_described_as_what_it_does_rather_than_as_a_cancellation() { + let warning = leaving_warning("20260912_1"); + assert!( + !warning.to_lowercase().contains("cancels it"), + "leaving does not cancel the turn: {warning}" + ); + assert!(warning.contains("does NOT stop it"), "{warning}"); + assert!( + warning.contains("five minutes"), + "the real cost is the orphan reaper's clock: {warning}" + ); + assert!( + warning.contains("`session watch` does not count"), + "the same caveat `session send --no-wait` gives: {warning}" + ); + assert!( + warning.contains("biorouter session cancel 20260912_1"), + "a user who does want it stopped needs the command: {warning}" + ); + + // And the line printed as it leaves must not claim a cancellation it did + // not perform either. It may *offer* `session cancel`, which is the + // command a user who does want it stopped needs — what it must not say + // is that leaving cancelled anything. + let notice = leaving_notice("20260912_1"); + assert!(!notice.contains("being cancelled"), "{notice}"); + assert!(notice.contains("keeps running"), "{notice}"); + assert!( + notice.contains("biorouter session cancel 20260912_1"), + "{notice}" + ); + } + #[test] - fn ctrl_c_never_silently_cancels_a_turn_the_attach_started() { + fn ctrl_c_never_silently_leaves_a_turn_the_attach_started() { assert_eq!(ctrl_c_action(false, false), CtrlCAction::Detach); assert_eq!(ctrl_c_action(false, true), CtrlCAction::Detach); - assert_eq!(ctrl_c_action(true, false), CtrlCAction::WarnWouldCancel); + assert_eq!( + ctrl_c_action(true, false), + CtrlCAction::WarnTurnGoesUnwatched + ); assert_eq!(ctrl_c_action(true, true), CtrlCAction::ForceExit); } @@ -3034,15 +4199,15 @@ mod tests { /// and then latching the warning with no re-check let a delivery finish in /// between, leaving `warned` set on a window that had already shut. The next /// delivery's FIRST ctrl-c then read `(open, warned)` and force-exited — - /// cancelling a turn with no warning at all, which is exactly what closing - /// the window resets the warning to prevent. + /// leaving a turn unwatched with no warning at all, which is exactly what + /// closing the window resets the warning to prevent. #[test] fn closing_the_window_re_arms_the_warning_for_the_next_turn() { let window = ReplyWindow::default(); assert_eq!(window.on_ctrl_c(), CtrlCAction::Detach, "nothing to lose"); window.open(); - assert_eq!(window.on_ctrl_c(), CtrlCAction::WarnWouldCancel); + assert_eq!(window.on_ctrl_c(), CtrlCAction::WarnTurnGoesUnwatched); assert_eq!(window.on_ctrl_c(), CtrlCAction::ForceExit); window.close(); @@ -3054,15 +4219,15 @@ mod tests { window.open(); assert_eq!( window.on_ctrl_c(), - CtrlCAction::WarnWouldCancel, + CtrlCAction::WarnTurnGoesUnwatched, "a warning spent on an earlier turn must not arm this one" ); } /// A `/reply` socket outlives the `deliver` call that opened it, so two can /// be streaming at once. The window is COUNTED for that reason: a flag would - /// let the first holder to finish declare ctrl-c harmless while the second - /// was still streaming, and ctrl-c would then cancel that turn silently. + /// let the first holder to finish declare ctrl-c uneventful while the second + /// was still streaming, and that turn would be left unwatched in silence. #[test] fn the_reply_window_stays_open_until_the_last_socket_closes() { let window = ReplyWindow::default(); @@ -3071,7 +4236,7 @@ mod tests { window.close(); assert_eq!( window.on_ctrl_c(), - CtrlCAction::WarnWouldCancel, + CtrlCAction::WarnTurnGoesUnwatched, "one socket is still streaming" ); window.close(); diff --git a/crates/biorouter-cli/src/commands/web.rs b/crates/biorouter-cli/src/commands/web.rs index 595047715..1a648470d 100644 --- a/crates/biorouter-cli/src/commands/web.rs +++ b/crates/biorouter-cli/src/commands/web.rs @@ -15,13 +15,16 @@ use base64::Engine; use biorouter::agents::turn_abort::TurnFailed; use biorouter::agents::{Agent, AgentEvent}; use biorouter::conversation::message::Message as BioRouterMessage; -use biorouter::session::session_manager::SessionType; +use biorouter::privacy::visibility::{may_read, may_write}; +use biorouter::privacy::{ProviderTier, SessionClassification}; +use biorouter::session::session_manager::{SessionManager, SessionType}; use futures::{sink::SinkExt, stream::StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashSet; use std::{net::ToSocketAddrs, sync::Arc}; use tokio::sync::{Mutex, RwLock}; -use tower_http::cors::{AllowOrigin, Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tracing::error; use webbrowser; @@ -33,6 +36,11 @@ struct AppState { cancellations: CancellationStore, auth_token: Option, ws_token: String, + /// The chats this server started, through `GET /`. See [`page_capability`]. + started_here: Arc>>, + /// The tier of the provider this server was started on, read once, before + /// the first turn. See [`page_capability`]. + server_tier: ProviderTier, } #[derive(Serialize, Deserialize)] @@ -155,7 +163,16 @@ fn token_matches(candidate: &str, expected: &str) -> bool { diff == 0 } +/// ⚠ **An empty `--auth-token` is not a token, and must not satisfy this guard.** +/// `Some("")` used to pass it, so `--host 0.0.0.0 --auth-token ""` bound to every +/// interface while `auth_middleware` would admit anyone who sent +/// `Authorization: Bearer ` with nothing after it — no protection at all, past +/// the one check whose whole job is to insist on protection. `cli.rs` now refuses +/// an empty value at argument-parse time, which is the real fix; this treats it +/// as absent as well, because `handle_web` is a public function and the guard +/// must not depend on its one caller having been careful. fn validate_network_auth(host: &str, auth_token: &Option) { + let auth_token = auth_token.as_deref().filter(|token| !token.is_empty()); if !is_loopback_address(host) && auth_token.is_none() { eprintln!( "Error: --auth-token is required when the server is exposed on the network ({}).", @@ -190,7 +207,9 @@ fn get_provider_and_model() -> (String, String) { (provider_name, model) } -async fn create_agent(provider_name: &str, model: &str) -> Result { +/// The agent every chat on this server shares, and the tier of the provider it +/// was started on. +async fn create_agent(provider_name: &str, model: &str) -> Result<(Agent, ProviderTier)> { let model_config = biorouter::model::ModelConfig::new(model)?; let agent = Agent::new(); @@ -205,6 +224,9 @@ async fn create_agent(provider_name: &str, model: &str) -> Result { .await?; let provider = biorouter::providers::create(provider_name, model_config).await?; + // Read here, not off the agent later: the agent is shared by every chat, and + // a turn in a chat whose row names another provider rebinds it (Gate B). + let server_tier = provider.tier(); agent.update_provider(provider, &init_session.id).await?; let enabled_configs = biorouter::config::get_enabled_extensions(); @@ -214,36 +236,57 @@ async fn create_agent(provider_name: &str, model: &str) -> Result { } } - Ok(agent) + Ok((agent, server_tier)) } -fn build_cors_layer(auth_token: &Option, host: &str, port: u16) -> CorsLayer { - if auth_token.is_none() { - let allowed_origins = [ - "http://localhost:3000".parse().unwrap(), - "http://127.0.0.1:3000".parse().unwrap(), - format!("http://{}:{}", host, port).parse().unwrap(), - ]; - CorsLayer::new() - .allow_origin(AllowOrigin::list(allowed_origins)) - .allow_methods(Any) - .allow_headers(Any) - } else { - CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any) - } +/// No origin but this server's own may read anything this server serves, and +/// nothing needs to. +/// +/// ⚠ **This layer used to hand the WebSocket token to another origin, which is +/// the same capability the reflected XSS gave** (see [`serve_session`]). Without +/// `--auth-token` — the default — `auth_middleware` lets every request through, +/// so a cross-origin `fetch` of `/session/…` that the browser permits *reads the +/// page*, and `data-ws-token` is in it. From there: open `/ws` with the token, +/// which is not subject to the same-origin policy, and send a message to an +/// agent holding `developer__shell`. Escaping the reflection while leaving this +/// open would have closed the sink and left the outcome. +/// +/// The allow-list was `localhost:3000`, `127.0.0.1:3000` and this server's own +/// origin. `--port` **defaults to 3000**, so on a default run all three are this +/// server; the grant only starts meaning something on any other port, where it +/// hands `http://…:3000` — a frontend dev server, or a page the operator was +/// talked into opening — read access to a chat page on, say, `:8080`. The +/// documented invocations include `--port 8080`. +/// +/// ⚠ **There is deliberately no flag to turn this back on.** The two routes a +/// cross-origin browser client could have wanted, `/api/sessions` and +/// `/api/sessions/{id}`, are the ones SD-13 deleted; what is left is the page +/// itself, `/static/*`, a static `/api/health` and the WebSocket, which CORS +/// does not govern. Nothing in this repository reads any of it from another +/// origin — `scripts/test_web.sh` uses `curl`, which ignores CORS entirely. An +/// opt-in would therefore be an opt-in to the token leak and to nothing else. +/// +/// The layer is kept rather than removed so that a preflight gets a definite +/// answer from code that says why, instead of a 405 from the router. +fn build_cors_layer() -> CorsLayer { + CorsLayer::new() } +/// ⚠ **There is no `/api/sessions` route, and there must not be one again.** +/// `GET /api/sessions` listed every user and scheduled chat on the machine (id, +/// title, working directory), and `GET /api/sessions/{id}` returned any chat's +/// full transcript, private ones included, behind no reach check at all — and +/// behind no credential either unless `--auth-token` was passed, which puts the +/// token in this process's argv. The page read one of the two, for a message +/// count and a tab title. Both were removed rather than gated (issue #56, SD-13 +/// in `docs/deployment/serve-decisions.md`); a chat is reached through this +/// server only by sending it a message, which [`turn_reach`] judges. fn build_router(state: AppState, cors_layer: CorsLayer) -> Router { Router::new() .route("/", get(serve_index)) .route("/session/{session_name}", get(serve_session)) .route("/ws", get(websocket_handler)) .route("/api/health", get(health_check)) - .route("/api/sessions", get(list_sessions)) - .route("/api/sessions/{session_id}", get(get_session)) .route("/static/{*path}", get(serve_static)) .layer(middleware::from_fn_with_state( state.clone(), @@ -273,22 +316,24 @@ pub async fn handle_web( crate::logging::setup_logging(Some("biorouter-web"), None)?; let (provider_name, model) = get_provider_and_model(); - let agent = create_agent(&provider_name, &model).await?; + let (agent, server_tier) = create_agent(&provider_name, &model).await?; - let ws_token = if auth_token.is_none() { - uuid::Uuid::new_v4().to_string() - } else { - String::new() - }; + // Unconditional. It used to be empty whenever `--auth-token` was set, which + // was only safe because [`websocket_handler`] then skipped the check + // altogether — and `token_matches("", "")` is `true`, so the pair was one + // careless edit away from an open socket. See that handler's doc comment. + let ws_token = uuid::Uuid::new_v4().to_string(); let state = AppState { agent: Arc::new(agent), cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())), auth_token: auth_token.clone(), ws_token, + started_here: Arc::default(), + server_tier, }; - let cors_layer = build_cors_layer(&auth_token, &host, port); + let cors_layer = build_cors_layer(); let app = build_router(state, cors_layer); let addr = (host.as_str(), port) @@ -333,6 +378,7 @@ async fn serve_index( ) .await .map_err(|err| (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + state.started_here.write().await.insert(session.id.clone()); let redirect_url = if let Some(query) = uri.query() { format!("/session/{}?{}", session.id, query) @@ -343,20 +389,95 @@ async fn serve_index( Ok(Redirect::to(&redirect_url)) } +/// The line in `index.html` the boot values are written in front of. +const BOOT_ANCHOR: &str = ""; + +/// What this page is allowed to do, sent as a header rather than a `` so +/// that injected markup cannot appear above it and displace it. +/// +/// `script-src 'self'` is the half that earns its keep: with it, an injected +/// ` +/// ``` +/// +/// where a `'` ended the string literal and `` ended the element, so +/// `GET /session/` ran the sender's code. On this +/// page that is not defacement: the injected script runs on the server's own +/// origin, reads the WebSocket token out of the very document it was injected +/// into, opens `/ws` with it, and sends a message to an agent holding +/// `developer__shell`. That token is the only thing standing between a drive-by +/// page and the socket — WebSockets are not subject to CORS — and the injection +/// is handed it. One link is remote code execution. +/// +/// ⚠ **The fix is to leave script context, not to escape for it.** HTML-escaping +/// a `", +) -> Response { + let html = include_str!("../../static/index.html").replace( + BOOT_ANCHOR, &format!( - "\n ", - session_name, - state.ws_token - ) + "\n {BOOT_ANCHOR}", + escape_html_attribute(&session_name), + escape_html_attribute(&state.ws_token), + ), ); - Html(html_with_session) + ( + [("content-security-policy", CONTENT_SECURITY_POLICY)], + Html(html), + ) + .into_response() } async fn serve_static(axum::extract::Path(path): axum::extract::Path) -> Response { @@ -392,68 +513,205 @@ async fn health_check() -> Json { })) } -async fn list_sessions(State(state): State) -> Json { - match state.agent.config.session_manager.list_sessions().await { - Ok(sessions) => { - let mut session_info = Vec::new(); - - for session in sessions { - session_info.push(serde_json::json!({ - "name": session.id, - "path": session.id, - "description": session.name, - "message_count": session.message_count, - "working_dir": session.working_dir - })); - } - Json(serde_json::json!({ - "sessions": session_info - })) - } - Err(e) => Json(serde_json::json!({ - "error": e.to_string() - })), +/// What a page is told when its message names a chat it may not reach. +/// +/// ⚠ **One sentence for "that chat is private" and for "there is no chat with +/// that id", deliberately**, for the reason the daemon's `SESSION_OUT_OF_REACH` +/// gives (`routes/session_reach.rs`): a refusal that told the two apart would +/// enumerate the machine's private chats one id at a time. It is fixed text and +/// names nothing about the chat, so the two answers are equal byte for byte. +/// +/// It states the page's own situation and forecloses the retry. The condition it +/// names cannot be met for a chat that already exists elsewhere, so it hands a +/// reader no way around itself; the one way through is the desktop app, where a +/// person can show they are at the keyboard. +const CHAT_OUT_OF_REACH: &str = + "That chat is private, or there is no chat with that id, and the two answers are \ + deliberately the same so that nothing about the chat is disclosed. `biorouter web` cannot \ + tell which model or which person is sending these messages, so it opens a private chat only \ + when it started that chat itself and runs a private model. Your message was not sent and \ + nothing was read; sending it again will be refused the same way. To continue a private \ + chat, open it in the Biorouter desktop app."; + +/// The capability a page brings to the chat its message names. +/// +/// ⚠ **Nothing on the socket says who is on the other end of it.** The server's +/// one credential proves nothing about that either: the WebSocket token is served +/// by `/session/{name}` to anyone who can reach the port, and `--auth-token` sits +/// in this process's argv, where any process of the same user reads it (AR-11 in +/// `docs/security/privacy-tiers-execution-plan.md`). So a page is a PUBLIC +/// caller, which is also how the daemon resolves a caller that states no +/// capability (`session_reach::caller_capability`). +/// +/// The exception is a chat this server started itself, which the page reaches at +/// the tier of the provider this server was started on. Without it a server on a +/// private model would give one reply per chat: that reply ratchets the chat to +/// private, and the next message would be refused. On a public model the +/// exception changes nothing, because the page is Public for every chat — so a +/// chat it started that was taken private somewhere else is refused like any +/// other. +fn page_capability(started_here: bool, server_tier: ProviderTier) -> ProviderTier { + if started_here { + server_tier + } else { + ProviderTier::Public } } -async fn get_session( - State(state): State, - axum::extract::Path(session_id): axum::extract::Path, -) -> Json { - match state - .agent - .config - .session_manager - .get_session(&session_id, true) - .await - { - Ok(session) => Json(serde_json::json!({ - "metadata": session, - "messages": session.conversation.unwrap_or_default().messages() - })), - Err(e) => Json(serde_json::json!({ - "error": e.to_string() - })), + +/// May a page with this capability run a turn in a chat in this state? +/// +/// `target` is the chat's classification, or `None` when its row could not be +/// read — no such chat, a deleted one, a store error — which is answered exactly +/// as a private chat is. `enforced` is DR-15's master switch, taken as an +/// argument so that "the switch is off" is a corner the tests drive; with it off +/// the gate is inert, like every other. +fn refuse_turn_unless_reachable( + enforced: bool, + capability: ProviderTier, + target: Option, +) -> Result<(), &'static str> { + if !enforced { + return Ok(()); + } + let target = target.unwrap_or(SessionClassification::Private); + // A turn reads the whole conversation into the model and writes into it, so + // it asks both verbs, as `workspace_send_prompt` does. They coincide today; + // asking both keeps a later narrowing of either from being skipped here. + if may_read(capability, target) && may_write(capability, target) { + Ok(()) + } else { + Err(CHAT_OUT_OF_REACH) } } +/// The gate on the one door into a chat this server keeps: a WebSocket message, +/// which runs a turn in whichever chat it names. +/// +/// ⚠ **It is the same door as the daemon's `POST /reply`, and it was open.** A +/// message naming a private chat started anywhere else ran a turn there — Gate B +/// rebinds the shared agent to the private model that chat's row names — and the +/// reply, which can quote the whole conversation, streamed back to whoever held +/// the socket. Removing `GET /api/sessions/{id}` alone would have closed the +/// smaller door and left this one. +/// +/// Called before anything touches the chat, as `session_reach` is. The row is +/// read metadata-only, so resolving the tier never loads the transcript this may +/// be about to refuse. +async fn turn_reach( + manager: &SessionManager, + started_here: &RwLock>, + server_tier: ProviderTier, + session_id: &str, +) -> Result<(), &'static str> { + // DR-15's master opt-out, read directly: a turn is not a tool call and has + // no sampled capability to inherit. Short-circuit before the store read. + let enforced = biorouter::privacy::privacy_tiers_enabled(); + if !enforced { + return Ok(()); + } + let capability = page_capability(started_here.read().await.contains(session_id), server_tier); + let target = manager + .get_session(session_id, false) + .await + .ok() + .map(|session| session.privacy_tier); + refuse_turn_unless_reachable(enforced, capability, target) +} + #[derive(Deserialize)] struct WsQuery { token: Option, } +/// Is this `Origin` this very server? +/// +/// A mirror of the daemon's `routes::origin_matches_host`, in the same spirit as +/// [`token_matches`] mirroring its `secret_matches`: `biorouter-cli` does not +/// depend on `biorouter-server` and must not start — SD-7 is why `serve` spawns +/// `biorouterd` as a subprocess rather than linking it — so the rule is +/// duplicated rather than imported. If the two ever need to be one symbol, the +/// move is into the `biorouter` core library that both already depend on, never +/// a new command-line-interface-to-server dependency. ⚠ PR #233 is editing the +/// daemon's copy; the shape below is that PR's, not the older one. +/// +/// It is the **strict core of that rule and neither of its exceptions**, and +/// deliberately so: +/// +/// - No `is_local_origin` widening. #233 removes exactly that from the daemon's +/// socket gates — "`is_local_origin` is the CORS rule now and nothing else; do +/// not hand it back to a socket" — and here it would re-open the hole +/// [`build_cors_layer`] just closed, by admitting a page on `localhost:3000`. +/// - No `file://` and no declared-renderer origin. Those exist for the Electron +/// renderer, which reaches the daemon from another local origin. This server +/// serves its own page from its own origin and has no such client, so an +/// opaque origin is refused like any other. +fn origin_is_this_server(origin: &str, host: Option<&str>) -> bool { + let Some(host) = host else { + // Nothing to compare against. Refuse rather than guess. + return false; + }; + // `null`, `file://` and anything else opaque strip no scheme and so can + // never match. + let Some(authority) = origin + .strip_prefix("http://") + .or_else(|| origin.strip_prefix("https://")) + else { + return false; + }; + !authority.is_empty() && !host.is_empty() && authority.eq_ignore_ascii_case(host) +} + +/// The socket is the chat, so it carries both locks the rest of this file +/// assumes: it is this server's own page asking, and it holds this process's +/// token. +/// +/// ⚠ **The token check used to be skipped entirely whenever `--auth-token` was +/// set**, on the reasoning that `auth_middleware` had already authenticated the +/// handshake. That is defensible and it left a landmine, because `handle_web` +/// also made `ws_token` the empty string in that mode: `token_matches("", "")` +/// is `true`, so deleting the `if` without touching the generation would have +/// admitted *every* socket while reading like a tightening. The generation is +/// unconditional now, the check is unconditional, and an empty expected token is +/// refused outright so the landmine cannot be re-armed. +/// +/// ⚠ **And there was no `Origin` check on any path**, while the tree's other two +/// upgrade sites (`routes/workspace.rs`, `routes/apps.rs`) both have one. CORS +/// does not govern a WebSocket handshake, so a page on any origin that had the +/// token could drive an agent holding `developer__shell` (CSWSH). The token is no +/// longer readable cross-origin either ([`build_cors_layer`]), which is the point +/// of having both: the two locks fail independently. +/// +/// A client that sends no `Origin` at all is let past this gate, as the daemon's +/// gates let one past: that is a non-browser client, and the token still guards +/// it. A local process reading the token off this port is issue #47 and unchanged. async fn websocket_handler( ws: WebSocketUpgrade, State(state): State, + headers: axum::http::HeaderMap, Query(query): Query, ) -> Result { - if state.auth_token.is_none() { - let provided_token = query.token.as_deref().unwrap_or(""); - if !token_matches(provided_token, &state.ws_token) { - tracing::warn!("WebSocket connection rejected: invalid token"); + if let Some(origin) = headers.get(axum::http::header::ORIGIN) { + let host = headers + .get(axum::http::header::HOST) + .and_then(|h| h.to_str().ok()); + if !origin_is_this_server(origin.to_str().unwrap_or(""), host) { + tracing::warn!("WebSocket connection rejected: cross-origin handshake"); return Err(StatusCode::FORBIDDEN); } } + if state.ws_token.is_empty() { + // Unreachable through `handle_web`, which always generates one. A + // refusal rather than a `debug_assert`, because the cost of being wrong + // is an open socket. + tracing::error!("WebSocket connection rejected: this server has no socket token"); + return Err(StatusCode::FORBIDDEN); + } + if !token_matches(query.token.as_deref().unwrap_or(""), &state.ws_token) { + tracing::warn!("WebSocket connection rejected: invalid token"); + return Err(StatusCode::FORBIDDEN); + } + Ok(ws.on_upgrade(|socket| handle_socket(socket, state))) } @@ -505,6 +763,18 @@ async fn handle_user_message( sender: Arc>>, state: &AppState, ) { + if let Err(refusal) = turn_reach( + &state.agent.config.session_manager, + &state.started_here, + state.server_tier, + &session_id, + ) + .await + { + send_error(&sender, refusal).await; + return; + } + let agent = state.agent.clone(); let session_id_clone = session_id.clone(); @@ -538,11 +808,33 @@ async fn handle_user_message( }); } +/// ⚠ **Gated, even though the map it reads can only hold chats the gate already +/// admitted.** A handle lands in `cancellations` only after +/// [`handle_user_message`] passed [`turn_reach`], so a cancel naming an +/// unreachable chat could never abort anything it was not already allowed to. +/// What it *could* do is answer: the old code replied `Cancelled` when a handle +/// existed and said nothing when it did not, which is one bit about a chat the +/// sender may not reach. Judging it first also makes the property this file +/// wants a flat one — **every socket message that names a chat is judged before +/// the chat is touched** — rather than a claim that has to be re-derived from +/// what else happens to be true of the map. async fn handle_cancel_message( session_id: String, sender: &Arc>>, state: &AppState, ) { + if let Err(refusal) = turn_reach( + &state.agent.config.session_manager, + &state.started_here, + state.server_tier, + &session_id, + ) + .await + { + send_error(sender, refusal).await; + return; + } + let abort_handle = { let mut cancellations = state.cancellations.write().await; cancellations.remove(&session_id) @@ -595,10 +887,8 @@ async fn process_message_streaming( let session = agent .config .session_manager - .get_session(&session_id, true) + .get_session(&session_id, false) .await?; - let mut messages = session.conversation.unwrap_or_default(); - messages.push(user_message.clone()); let session_config = SessionConfig { id: session.id.clone(), @@ -795,7 +1085,23 @@ async fn send_error( #[cfg(test)] mod tests { - use super::token_matches; + use super::*; + use biorouter::agents::AgentConfig; + use biorouter::config::permission::PermissionManager; + use biorouter::config::BioRouterMode; + use serde_json::json; + use std::net::SocketAddr; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_tungstenite::tungstenite::Message as Frame; + + const TIERS: [ProviderTier; 2] = [ProviderTier::Public, ProviderTier::Private]; + const TARGETS: [Option; 3] = [ + Some(SessionClassification::Public), + Some(SessionClassification::Private), + None, + ]; + const WS_TOKEN: &str = "test-ws-token"; #[test] fn token_match_is_exact_and_length_checked() { @@ -807,4 +1113,710 @@ mod tests { assert!(!token_matches("", "abc123")); assert!(token_matches("", "")); } + + /// Nothing on the socket names a model, so a page is a public caller — in + /// every chat but the ones its own server started, where it has the tier of + /// the model it has been talking to. + #[test] + fn a_page_is_a_public_caller_outside_the_chats_its_server_started() { + for server_tier in TIERS { + assert_eq!(page_capability(false, server_tier), ProviderTier::Public); + assert_eq!(page_capability(true, server_tier), server_tier); + } + } + + /// The rule at every corner, with the switch on. An unreadable row is judged + /// as a private one, so a public page is refused both. + #[test] + fn a_turn_reaches_a_chat_only_at_the_tier_the_page_holds() { + use SessionClassification::{Private, Public}; + #[rustfmt::skip] + let cases = [ + // capability target admitted + (ProviderTier::Public, Some(Public), true), + (ProviderTier::Public, Some(Private), false), + (ProviderTier::Public, None, false), + (ProviderTier::Private, Some(Public), true), + (ProviderTier::Private, Some(Private), true), + (ProviderTier::Private, None, true), + ]; + for (capability, target, admitted) in cases { + assert_eq!( + refuse_turn_unless_reachable(true, capability, target).is_ok(), + admitted, + "a {capability:?} page naming a chat classified {target:?}" + ); + } + } + + /// DR-15: with privacy tiers off, this gate refuses nothing, like every other. + #[test] + fn with_privacy_tiers_off_the_gate_refuses_nothing() { + for capability in TIERS { + for target in TARGETS { + assert_eq!( + refuse_turn_unless_reachable(false, capability, target), + Ok(()), + "{capability:?} / {target:?}" + ); + } + } + } + + /// A page cannot tell "no such chat" from "a private chat" by the answer. + /// Asserted as equality at every capability rather than as each answer being + /// vague, because a vagueness check passes an implementation that adds one + /// helpful clause to the branch it can tell apart. + #[test] + fn no_such_chat_and_a_private_chat_are_the_same_refusal() { + for capability in TIERS { + assert_eq!( + refuse_turn_unless_reachable(true, capability, None), + refuse_turn_unless_reachable( + true, + capability, + Some(SessionClassification::Private) + ), + "a {capability:?} page can tell a missing chat from a private one" + ); + } + assert_eq!( + refuse_turn_unless_reachable(true, ProviderTier::Public, None), + Err(CHAT_OUT_OF_REACH) + ); + } + + /// The page and the router agree: the page asks for no chat list and no + /// transcript, so the routes that served them could go. + #[test] + fn the_page_asks_for_no_chat_list_and_no_transcript() { + let page = include_str!("../../static/script.js"); + assert!( + !page.contains("/api/sessions"), + "the page fetches a route this server no longer has" + ); + } + + /// A session name that tries to leave a double-quoted HTML attribute, and + /// the script context it used to be written into. Percent-encoded at the + /// call site because a raw `"` is not legal in a request target. + const BREAKOUT: &str = "\"'&"; + const BREAKOUT_ENCODED: &str = + "%3C%2Fscript%3E%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E%22%27%26"; + + #[test] + fn an_attribute_escape_neutralises_every_character_that_could_leave_one() { + assert_eq!( + escape_html_attribute(BREAKOUT), + "</script><img src=x onerror=alert(1)>"'&" + ); + // An `&` escaped anywhere but first would come back doubled. + assert_eq!(escape_html_attribute("<"), "&lt;"); + assert_eq!( + escape_html_attribute("plain-20260911_120000"), + "plain-20260911_120000" + ); + } + + /// ⚠ **The reflected XSS this branch closes.** `GET /session/{name}` wrote + /// the name of the chat straight into an inline `` ended the element and everything after it was parsed + /// as markup. The consequence is not cosmetic — see [`serve_session`]: the + /// injected script reads the WebSocket token out of the same document and + /// drives an agent that holds `developer__shell`. + /// + /// Asserted as **"the page has exactly the script elements its own template + /// has"** rather than as "the payload does not appear". The weaker form + /// passes an implementation that HTML-escapes inside the ``. + #[tokio::test] + async fn a_session_name_cannot_reach_script_context() { + let server = TestServer::start(ProviderTier::Public).await; + let template = include_str!("../../static/index.html"); + let response = server + .raw_get(&format!("/session/{BREAKOUT_ENCODED}")) + .await; + + assert_eq!( + response.matches("` that would + // close it, not against `onerror=` — that substring survives inside the + // escaped attribute, where it is text and not a handler. + assert!( + !response.contains(""), + "the payload kept a raw `>`, so something closed a tag:\n{response}" + ); + assert!( + response.contains(&format!( + "data-session-name=\"{}\"", + escape_html_attribute(BREAKOUT) + )), + "the name is not where the page reads it from, escaped:\n{response}" + ); + } + + /// The same route on the same server, with an ordinary name: the page still + /// gets the value it needs, unescaped once the parser has decoded it. A + /// breakout test alone passes a handler that drops the name entirely. + #[tokio::test] + async fn an_ordinary_session_name_still_reaches_the_page() { + let server = TestServer::start(ProviderTier::Public).await; + let started = server.start_chat().await; + let response = server.raw_get(&format!("/session/{started}")).await; + assert!( + response.contains(&format!("data-session-name=\"{started}\"")), + "the page cannot tell which chat it is in:\n{response}" + ); + assert!( + response.contains(&format!("data-ws-token=\"{WS_TOKEN}\"")), + "the page cannot open the socket:\n{response}" + ); + } + + /// Defence in depth behind the escape, and the two halves that have to move + /// together: a policy refusing inline script, and a template carrying none. + #[tokio::test] + async fn the_page_is_served_under_a_policy_that_refuses_inline_script() { + let server = TestServer::start(ProviderTier::Public).await; + let response = server.raw_get("/session/20260911_120000").await; + let policy = TestServer::header(&response, "content-security-policy") + .unwrap_or_else(|| panic!("no content-security-policy header in {response:?}")); + assert!( + policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"), + "a policy that permits inline script is not one: {policy}" + ); + + let template = include_str!("../../static/index.html"); + assert!( + !template.contains("onclick="), + "the template carries an inline handler the policy above would refuse" + ); + } + + /// The origin rule, at every corner. The strict core of the daemon's + /// `origin_matches_host` with neither of its exceptions — see + /// [`origin_is_this_server`] for why each is deliberately absent. + #[test] + fn an_origin_is_this_server_only_when_it_matches_this_request_s_host() { + let host = Some("127.0.0.1:8080"); + assert!(origin_is_this_server("http://127.0.0.1:8080", host)); + // Case-insensitive on the authority, as the daemon's copy is. + assert!(origin_is_this_server( + "http://LOCALHOST:8080", + Some("localhost:8080") + )); + // The scheme prefix is matched literally, so an odd spelling of it is a + // refusal rather than a match. + assert!(!origin_is_this_server("HTTP://127.0.0.1:8080", host)); + // A different port is a different origin. This is the whole point: the + // CORS allow-list this replaces named `localhost:3000` by hand. + assert!(!origin_is_this_server("http://127.0.0.1:3000", host)); + assert!(!origin_is_this_server("http://localhost:8080", host)); + assert!(!origin_is_this_server("http://evil.example", host)); + // Opaque origins strip no scheme, so they can never match — and unlike + // the daemon's gates, `file://` gets no exception here. + for opaque in ["null", "file://", "", "ws://127.0.0.1:8080"] { + assert!(!origin_is_this_server(opaque, host), "{opaque} admitted"); + } + // Nothing to compare against is a refusal, not a guess. + assert!(!origin_is_this_server("http://127.0.0.1:8080", None)); + assert!(!origin_is_this_server("http://", Some(""))); + } + + /// ⚠ **The socket is the chat, and it had no `Origin` check on any path** + /// while the tree's other two upgrade sites both have one. CORS does not + /// govern a handshake, so a page on any origin holding the token could drive + /// an agent with `developer__shell` (CSWSH). Driven as a real handshake + /// because `WebSocketUpgrade` is extracted before the handler body runs: a + /// request without the upgrade headers is rejected with 400 by the extractor + /// and would never reach the rule under test. + #[tokio::test] + async fn a_handshake_from_another_origin_is_refused_even_with_the_right_token() { + let server = TestServer::start(ProviderTier::Public).await; + + assert_eq!(server.handshake(Some(WS_TOKEN), None).await, 101); + let own = format!("http://{}", server.addr); + assert_eq!(server.handshake(Some(WS_TOKEN), Some(&own)).await, 101); + + for origin in [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://evil.example", + "null", + "file://", + ] { + assert_eq!( + server.handshake(Some(WS_TOKEN), Some(origin)).await, + 403, + "{origin} opened the socket" + ); + } + } + + /// The token gate, now that it runs on every path rather than only when + /// `--auth-token` is absent. + /// + /// ⚠ **This is the landmine under "just make the check unconditional".** + /// `ws_token` used to be the empty string whenever `--auth-token` was set, + /// precisely because the check was skipped in that mode — and + /// `token_matches("", "")` is `true`, so deleting the `if` without also + /// changing the generation would have admitted *every* socket while reading + /// like a tightening. Both halves are pinned: an empty expected token is + /// refused by the handler, and `handle_web` never produces one. + #[tokio::test] + async fn the_socket_token_is_required_on_every_path_and_never_empty() { + assert!( + token_matches("", ""), + "the reason an empty expected token must never reach the handler" + ); + + let server = TestServer::start(ProviderTier::Public).await; + assert_eq!(server.handshake(Some(WS_TOKEN), None).await, 101); + assert_eq!(server.handshake(None, None).await, 403); + assert_eq!(server.handshake(Some("wrong"), None).await, 403); + + // A server whose token is empty refuses everything, including the empty + // token that `token_matches` would otherwise accept. + let tokenless = TestServer::start_with_ws_token(ProviderTier::Public, "").await; + assert_eq!(tokenless.handshake(None, None).await, 403); + assert_eq!(tokenless.handshake(Some(""), None).await, 403); + } + + /// `--host 0.0.0.0 --auth-token ""` used to bind to every interface behind a + /// token that `Authorization: Bearer ` satisfies. Pinned at both layers: the + /// argument parser refuses the value, and the guard treats it as absent even + /// if a programmatic caller hands it one. + #[test] + fn an_empty_auth_token_is_refused_and_does_not_satisfy_the_network_guard() { + assert!(crate::cli::parse_auth_token("").is_err()); + assert!(crate::cli::parse_auth_token(" ").is_err()); + assert_eq!( + crate::cli::parse_auth_token("secret").as_deref(), + Ok("secret") + ); + // `validate_network_auth` exits the process when it refuses, so the + // normalisation it applies is asserted rather than the exit: an empty + // token must reduce to `None`, which is the refusing branch. + for token in [None, Some(String::new()), Some(" ".to_string())] { + assert!( + token.as_deref().filter(|t| !t.trim().is_empty()).is_none(), + "{token:?} must not read as a token" + ); + } + } + + /// ⚠ **The other route to the same capability as the reflected XSS.** The + /// allow-list held `http://localhost:3000`, so a page there could `fetch` + /// `/session/…` on any other port, read `data-ws-token` out of the reply, and + /// open `/ws` with it — WebSockets are not subject to the same-origin policy — + /// reaching an agent that holds `developer__shell`. Escaping the reflection + /// and leaving this would have closed the sink and left the outcome. + /// + /// Asserted on the **header**, not on the body: the token is still in the + /// page, because the page needs it. What must not happen is a browser being + /// told another origin may read that page. + #[tokio::test] + async fn no_other_origin_may_read_the_page_that_carries_the_ws_token() { + let server = TestServer::start(ProviderTier::Public).await; + let path = "/session/20260911_120000"; + + for origin in [ + // Both spellings the allow-list named, on the port it hard-coded, + // which is also `--port`'s default — so on any other port these are + // a foreign origin, and `--port 8080` is a documented invocation. + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://evil.example", + ] { + let response = server.raw_request("GET", path, &[("Origin", origin)]).await; + assert!( + response.contains("data-ws-token=\""), + "this test is meaningless if the page stopped carrying the token:\n{response}" + ); + assert_eq!( + TestServer::header(&response, "access-control-allow-origin"), + None, + "{origin} is told it may read the page holding the token" + ); + + // And the preflight, which is what a browser actually asks first + // for anything beyond a simple request. + let preflight = server + .raw_request( + "OPTIONS", + path, + &[("Origin", origin), ("Access-Control-Request-Method", "GET")], + ) + .await; + assert_eq!( + TestServer::header(&preflight, "access-control-allow-origin"), + None, + "the preflight grants {origin} what the response above refused" + ); + } + } + + /// Same-origin still works, which is the only case the page needs — a + /// cross-origin refusal is worthless if it also broke the page itself. + #[tokio::test] + async fn the_page_still_serves_the_origin_it_is_served_from() { + let server = TestServer::start(ProviderTier::Public).await; + let own_origin = format!("http://{}", server.addr); + let response = server + .raw_request( + "GET", + "/session/20260911_120000", + &[("Origin", &own_origin)], + ) + .await; + assert!( + response.starts_with("HTTP/1.1 200"), + "the server's own origin is refused its own page:\n{response}" + ); + assert!(response.contains("data-ws-token=\"test-ws-token\"")); + } + + /// The page reads its boot values from attributes, not from globals an + /// inline `` used to end the element. See `serve_session` in +// crates/biorouter-cli/src/commands/web.rs for the full account. The HTML parser +// decodes entities inside an attribute, so what `dataset` hands back here is the +// value exactly as it arrived, with no byte able to escape the attribute. +function bootValue(name) { + const boot = document.getElementById('biorouter-boot'); + return (boot && boot.dataset[name]) || ''; +} + // Get session ID - either from URL parameter, injected session name, or generate new one function getSessionId() { - // Check if session name was injected by server (for /session/:name routes) - if (window.BIOROUTER_SESSION_NAME) { - return window.BIOROUTER_SESSION_NAME; + // Check if a session name was written into the page (for /session/:name routes) + const injected = bootValue('sessionName'); + if (injected) { + return injected; } - + // Check URL parameters const urlParams = new URLSearchParams(window.location.search); const sessionParam = urlParams.get('session') || urlParams.get('name'); @@ -138,7 +153,7 @@ function removeThinkingIndicator() { // Connect to WebSocket function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const token = window.BIOROUTER_WS_TOKEN || ''; + const token = bootValue('wsToken'); const wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; socket = new WebSocket(wsUrl); @@ -149,9 +164,6 @@ function connectWebSocket() { connectionStatus.textContent = 'Connected'; connectionStatus.className = 'status connected'; sendButton.disabled = false; - - // Check if this session exists and load history if it does - loadSessionIfExists(); }; socket.onmessage = (event) => { @@ -264,24 +276,32 @@ function handleToolRequest(data) { const headerDiv = document.createElement('div'); headerDiv.className = 'tool-header'; - headerDiv.innerHTML = `🔧 ${data.tool_name}`; - + // Every one of these interpolations is model-controlled: a tool name and a + // tool call's arguments are chosen by whatever the agent decided to run, and + // a prompt injection in a file or a web page reaches them. `escapeHtml` is + // adequate here and only here because every hole below sits in element + // content, never inside an attribute value — it does not escape `"`. + headerDiv.innerHTML = `🔧 ${escapeHtml(data.tool_name)}`; + const contentDiv = document.createElement('div'); contentDiv.className = 'tool-content'; - + // Format the arguments if (data.tool_name === 'developer__shell' && data.arguments.command) { contentDiv.innerHTML = `
${escapeHtml(data.arguments.command)}
`; } else if (data.tool_name === 'developer__text_editor') { const action = data.arguments.command || 'unknown'; const path = data.arguments.path || 'unknown'; - contentDiv.innerHTML = `
action: ${action}
`; + contentDiv.innerHTML = `
action: ${escapeHtml(action)}
`; contentDiv.innerHTML += `
path: ${escapeHtml(path)}
`; if (data.arguments.file_text) { contentDiv.innerHTML += `
content:
${escapeHtml(data.arguments.file_text.substring(0, 200))}${data.arguments.file_text.length > 200 ? '...' : ''}
`; } } else { - contentDiv.innerHTML = `
${JSON.stringify(data.arguments, null, 2)}
`; + // `JSON.stringify` escapes for JSON, which says nothing about HTML: it + // leaves `<` and `/` alone, so an argument holding `` arrived here as live markup. + contentDiv.innerHTML = `
${escapeHtml(JSON.stringify(data.arguments, null, 2))}
`; } toolDiv.appendChild(headerDiv); @@ -346,8 +366,8 @@ function handleToolConfirmation(data) { confirmDiv.innerHTML = `
⚠️ Tool Confirmation Required
- ${data.tool_name} wants to execute with: -
${JSON.stringify(data.arguments, null, 2)}
+ ${escapeHtml(data.tool_name)} wants to execute with: +
${escapeHtml(JSON.stringify(data.arguments, null, 2))}
Auto-approved in web mode (UI coming soon)
`; @@ -456,43 +476,20 @@ function sendSuggestion(text) { sendMessage(); } -// Load session history if the session exists (like --resume in CLI) -async function loadSessionIfExists() { - try { - const response = await fetch(`/api/sessions/${sessionId}`); - if (response.ok) { - const sessionData = await response.json(); - if (sessionData.messages && sessionData.messages.length > 0) { - // Remove welcome message since we're resuming - const welcomeMessage = messagesContainer.querySelector('.welcome-message'); - if (welcomeMessage) { - welcomeMessage.remove(); - } - - // Display session resumed message - const resumeDiv = document.createElement('div'); - resumeDiv.className = 'message system-message'; - resumeDiv.innerHTML = `Session resumed: ${sessionData.messages.length} messages loaded`; - messagesContainer.appendChild(resumeDiv); - - // Update page title with session description if available - if (sessionData.metadata && sessionData.metadata.description) { - document.title = `biorouter chat - ${sessionData.metadata.description}`; - } - - messagesContainer.scrollTop = messagesContainer.scrollHeight; - } - } - } catch (error) { - console.log('No existing session found or error loading:', error); - // This is fine - just means it's a new session - } -} - - // Event listeners sendButton.addEventListener('click', sendMessage); +// The welcome pills, bound here rather than through an `onclick` attribute in +// index.html: the page is served under `script-src 'self'`, which refuses inline +// handlers. Delegated from the container because the welcome block is removed +// once the first message is sent. +messagesContainer.addEventListener('click', (e) => { + const pill = e.target.closest('.suggestion-pill[data-suggestion]'); + if (pill) { + sendSuggestion(pill.dataset.suggestion); + } +}); + messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); diff --git a/crates/biorouter-cli/tests/apps_serve_lifecycle.rs b/crates/biorouter-cli/tests/apps_serve_lifecycle.rs new file mode 100644 index 000000000..a9019ce91 --- /dev/null +++ b/crates/biorouter-cli/tests/apps_serve_lifecycle.rs @@ -0,0 +1,362 @@ +//! Stopping `biorouter apps serve` stops the daemon it started. +//! +//! It did not. The command handled `ctrl_c` and nothing else, so SIGTERM ran no +//! code here at all: the default action ended `apps serve` on the spot and left +//! `biorouterd` running, holding the port, serving the app, and holding the +//! daemon's secret. `kill ` — what a process manager, a +//! script or an editor's stop button sends — orphaned it every time. +//! +//! This is the defect PR #226 fixed for `biorouter serve`; these tests are the +//! same measurement, of the same two layers, against the same helpers the two +//! commands now share (`commands::serve::stop_daemon` and `--exit-with-parent`). +//! They run the real binaries and stop the command the way a process manager or +//! an operator does: by pid. +//! +//! ⚠ They need a `biorouterd` built from this tree beside the `biorouter` under +//! test. `cargo test -p biorouter-cli` does not build another package's binary, +//! so build it first: +//! +//! ```text +//! cargo build -p biorouter-cli -p biorouter-server +//! cargo test -p biorouter-cli --test apps_serve_lifecycle +//! ``` +//! +//! Unix only: the second layer (`--exit-with-parent`) is Unix only, and there is +//! no SIGTERM to send on Windows. +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +/// How long the command may take to stop. Its grace for the daemon is ten +/// seconds; this leaves room for a loaded machine beyond that, so a pass means +/// "the daemon did not survive", not "it survived for less than N seconds". +const STOP_BUDGET: Duration = Duration::from_secs(30); + +/// A debug daemon's cold start on a busy machine. +const READY_BUDGET: Duration = Duration::from_secs(120); + +const APP_ID: &str = "lifecycle-app"; + +fn biorouter() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_biorouter")) +} + +/// `apps serve` starts the daemon that sits beside it, so this is the one it +/// runs. +fn biorouterd() -> PathBuf { + biorouter().with_file_name("biorouterd") +} + +/// Refuse to run against a daemon these tests cannot be about, and warm its +/// first exec while we are here: a freshly linked binary's first run costs +/// seconds at almost no CPU, which would otherwise land inside the readiness +/// wait. +fn require_a_daemon_from_this_tree() { + let daemon = biorouterd(); + assert!( + daemon.is_file(), + "{} does not exist. `cargo test -p biorouter-cli` does not build another \ + package's binary; run `cargo build -p biorouter-server --bin biorouterd` first.", + daemon.display() + ); + let help = Command::new(&daemon) + .args(["agent", "--help"]) + .output() + .expect("run biorouterd agent --help"); + assert!( + String::from_utf8_lossy(&help.stdout).contains("--exit-with-parent"), + "{} predates --exit-with-parent, so it is older than this test. Rebuild it with \ + `cargo build -p biorouter-server --bin biorouterd`.", + daemon.display() + ); +} + +/// A port nothing is listening on. Released before the daemon binds it, which +/// leaves a window another process could take it in; the readiness wait then +/// reports that rather than a wrong result. +fn free_port() -> u16 { + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .and_then(|l| l.local_addr()) + .map(|a| a.port()) + .expect("find a free port") +} + +fn port_is_open(port: u16) -> bool { + TcpStream::connect_timeout( + &SocketAddr::from((Ipv4Addr::LOCALHOST, port)), + Duration::from_millis(500), + ) + .is_ok() +} + +/// The status code of `GET path`, or `None` when nothing answered. +fn http_status(port: u16, path: &str) -> Option { + let mut stream = TcpStream::connect_timeout( + &SocketAddr::from((Ipv4Addr::LOCALHOST, port)), + Duration::from_millis(500), + ) + .ok()?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?; + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ) + .ok()?; + let mut head = [0u8; 64]; + let read = stream.read(&mut head).ok()?; + String::from_utf8_lossy(&head[..read]) + .split_whitespace() + .nth(1)? + .parse() + .ok() +} + +/// Whether `pid` is a process that has not yet exited. A zombie has exited: it +/// is waiting to be reaped by a parent, and holds no port and no memory. +fn is_running(pid: u32) -> bool { + let out = Command::new("ps") + .args(["-o", "stat=", "-p", &pid.to_string()]) + .output() + .expect("run ps"); + let stat = String::from_utf8_lossy(&out.stdout); + let stat = stat.trim(); + !stat.is_empty() && !stat.starts_with('Z') +} + +/// Start time and command line: together they name one process, where a pid +/// alone can be recycled for an unrelated one once the original has exited. +/// Empty once the process is gone. +fn identity(pid: u32) -> String { + let out = Command::new("ps") + .args(["-o", "lstart=,command=", "-p", &pid.to_string()]) + .output() + .expect("run ps"); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn signal(pid: u32, name: &str) { + let status = Command::new("kill") + .args([&format!("-{name}"), &pid.to_string()]) + .status() + .expect("run kill"); + assert!(status.success(), "kill -{name} {pid} failed"); +} + +fn wait_for(budget: Duration, mut done: impl FnMut() -> bool) -> Option { + let start = Instant::now(); + while start.elapsed() < budget { + if done() { + return Some(start.elapsed()); + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +/// A running `apps serve` and the daemon it started. +struct Served { + serve: Child, + daemon: u32, + /// The daemon's [`identity`] when it was found, so cleanup can tell it from + /// a later process that happens to reuse its pid. + daemon_identity: String, + port: u16, + root: tempfile::TempDir, +} + +impl Served { + fn start() -> Self { + require_a_daemon_from_this_tree(); + + let root = tempfile::tempdir().expect("temp dir"); + let home = root.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + // `apps serve` refuses an id the store does not hold, and the store is + // `/config/agent_drafter`. + let path_root = root.path().join("biorouter"); + let app = path_root.join("config").join("agent_drafter").join(APP_ID); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write( + app.join("manifest.json"), + format!(r#"{{"id":"{APP_ID}","title":"Lifecycle","kind":"static","updated_at":1}}"#), + ) + .unwrap(); + let log = std::fs::File::create(root.path().join("serve.log")).unwrap(); + + let port = free_port(); + let serve = Command::new(biorouter()) + .args(["apps", "serve", APP_ID]) + // Nothing here may touch the developer's own configuration, + // sessions or keychain. + .env("HOME", &home) + .env("BIOROUTER_PATH_ROOT", &path_root) + .env("BIOROUTER_PORT", port.to_string()) + .env("BIOROUTER_DISABLE_KEYRING", "true") + .stdin(Stdio::null()) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .expect("spawn biorouter apps serve"); + + let mut served = Self { + serve, + daemon: 0, + daemon_identity: String::new(), + port, + root, + }; + let ready = wait_for(READY_BUDGET, || { + http_status(port, "/status") == Some(200) + || matches!(served.serve.try_wait(), Ok(Some(_))) + }); + assert!( + ready.is_some() && matches!(served.serve.try_wait(), Ok(None)), + "apps serve did not come up on port {port}:\n{}", + served.log() + ); + served.daemon = served.only_child(); + served.daemon_identity = identity(served.daemon); + assert!( + served.daemon_identity.contains("biorouterd") + && served.daemon_identity.contains("agent"), + "the command's child is not the daemon: {:?}", + served.daemon_identity + ); + served + } + + /// The daemon: the command's one child. + fn only_child(&self) -> u32 { + let out = Command::new("pgrep") + .args(["-P", &self.serve.id().to_string()]) + .output() + .expect("run pgrep"); + let pids: Vec = String::from_utf8_lossy(&out.stdout) + .split_whitespace() + .filter_map(|p| p.parse().ok()) + .collect(); + assert_eq!( + pids.len(), + 1, + "expected apps serve to have exactly one child, the daemon: {pids:?}" + ); + pids[0] + } + + fn log(&self) -> String { + std::fs::read_to_string(self.root.path().join("serve.log")).unwrap_or_default() + } + + /// Stop the command with `name` and assert that it exits and takes the + /// daemon with it. + fn stop_with(mut self, name: &str) -> ExitStatus { + signal(self.serve.id(), name); + let mut status = None; + let took = wait_for(STOP_BUDGET, || { + status = self.serve.try_wait().ok().flatten(); + status.is_some() + }); + let status = match (took, status) { + (Some(took), Some(status)) => { + eprintln!("apps serve exited {took:?} after SIG{name}"); + status + } + _ => panic!( + "apps serve was still running {STOP_BUDGET:?} after SIG{name} (its daemon is \ + pid {}):\n{}", + self.daemon, + self.log() + ), + }; + + // The command reaps the daemon before it exits, so both of these hold + // the moment it is gone. They are what an operator stopping it needs to + // be true: nothing left running, and nothing on the port. + assert!( + !is_running(self.daemon), + "the daemon (pid {}) outlived apps serve after SIG{name}:\n{}", + self.daemon, + self.log() + ); + assert!( + !port_is_open(self.port), + "port {} is still accepting connections after apps serve exited on SIG{name}", + self.port + ); + status + } +} + +impl Drop for Served { + /// A failed assertion must not leave a daemon running on the machine. The + /// daemon is killed only while its pid still names the process that was + /// found at startup, so a pid recycled for something unrelated is left + /// alone. + fn drop(&mut self) { + let _ = self.serve.kill(); + let _ = self.serve.wait(); + if self.daemon != 0 && identity(self.daemon) == self.daemon_identity { + let _ = Command::new("kill") + .args(["-KILL", &self.daemon.to_string()]) + .status(); + } + } +} + +/// The measured defect: `apps serve` listened for `ctrl_c` alone, so SIGTERM ran +/// no code here and the daemon was left holding the port. +/// +/// Fails the shipped command on both assertions: the daemon is still running, +/// and the port still answers. +#[test] +fn sigterm_to_apps_serve_stops_its_daemon() { + let status = Served::start().stop_with("TERM"); + assert!( + status.success(), + "a requested stop is a clean exit: {status}" + ); +} + +/// SIGINT was handled before this change, but with a `start_kill` and no grace. +/// It must still stop both, and now through the same bounded stop-then-kill +/// `biorouter serve` uses. +#[test] +fn sigint_to_apps_serve_stops_its_daemon() { + let status = Served::start().stop_with("INT"); + assert!( + status.success(), + "a requested stop is a clean exit: {status}" + ); +} + +/// SIGKILL runs no code in `apps serve` at all, so this is the second layer +/// alone: the daemon sees that its parent is gone and stops itself. +/// +/// Fails the shipped command, which passed no `--exit-with-parent`, so the +/// orphan ran until something else killed it. +#[test] +fn a_daemon_whose_apps_serve_was_killed_outright_stops_itself() { + let mut served = Served::start(); + let daemon = served.daemon; + served.serve.kill().expect("SIGKILL apps serve"); + served.serve.wait().expect("reap apps serve"); + + let took = wait_for(STOP_BUDGET, || !is_running(daemon)); + match took { + Some(took) => eprintln!("the orphaned daemon stopped {took:?} after apps serve was killed"), + None => panic!( + "the daemon (pid {daemon}) was still running {STOP_BUDGET:?} after apps serve was \ + killed:\n{}", + served.log() + ), + } + assert!( + !port_is_open(served.port), + "port {} is still accepting connections after the orphaned daemon stopped", + served.port + ); +} diff --git a/crates/biorouter-cli/tests/serve_lifecycle.rs b/crates/biorouter-cli/tests/serve_lifecycle.rs index ab85e801f..1258b5316 100644 --- a/crates/biorouter-cli/tests/serve_lifecycle.rs +++ b/crates/biorouter-cli/tests/serve_lifecycle.rs @@ -17,6 +17,10 @@ //! cargo test -p biorouter-cli --test serve_lifecycle //! ``` //! +//! The same harness covers the browser token `serve` hands the daemon, because +//! the only way to know which token a daemon ended up with is to present one and +//! read the answer. +//! //! Unix only: the second layer (`--exit-with-parent`) is Unix only, and there //! is no SIGTERM to send on Windows. #![cfg(unix)] @@ -166,6 +170,14 @@ struct Served { impl Served { fn start() -> Self { + Self::start_with(&["--token", TOKEN], &[]) + } + + /// `serve` with `args` appended, and `env` set in its environment. + /// + /// Split out for the browser-token tests: what token the daemon is actually + /// holding cannot be read from the outside, only presented to. + fn start_with(args: &[&str], env: &[(&str, &str)]) -> Self { require_a_daemon_from_this_tree(); let root = tempfile::tempdir().expect("temp dir"); @@ -181,10 +193,13 @@ impl Served { let log = std::fs::File::create(root.path().join("serve.log")).unwrap(); let port = free_port(); - let serve = Command::new(biorouter()) - .args(["serve", "--port", &port.to_string(), "--token", TOKEN]) + let mut command = Command::new(biorouter()); + command + .args(["serve", "--port", &port.to_string()]) + .args(args) .arg("--web-dir") - .arg(&web) + .arg(&web); + let serve = command // Nothing here may touch the developer's own configuration, // sessions or keychain. .env("HOME", &home) @@ -194,6 +209,7 @@ impl Served { // leak into what is under test. .env_remove("BIOROUTER_SERVE_UI") .env_remove("BIOROUTER_BROWSER_TOKEN") + .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(log.try_clone().unwrap()) .stderr(log) @@ -305,6 +321,63 @@ impl Drop for Served { } } +/// The measured defect (2026-09-12): `BIOROUTER_BROWSER_TOKEN= biorouter +/// serve` printed a RANDOM token and answered `?t=` with 401, so the systemd +/// deployment in `docs/deployment/headless-linux.md` — an environment file whose +/// whole purpose is a token that survives a restart — could not work as written. +/// +/// The daemon holds the token and nothing reads it back, so the only honest +/// check is to present the operator's token to the running daemon: 303 is the +/// exchange for a cookie, 401 is a token it has never heard of. +/// +/// Fails the shipped command with `Some(401)`. +#[test] +fn serve_hands_the_daemon_the_token_the_operator_set_in_its_environment() { + let served = Served::start_with(&[], &[("BIOROUTER_BROWSER_TOKEN", "token-from-the-file")]); + assert_eq!( + http_status(served.port, "/?t=token-from-the-file"), + Some(303), + "the operator's own token must open the interface:\n{}", + served.log() + ); + assert_eq!( + http_status(served.port, "/?t=some-other-token"), + Some(401), + "and nothing else may" + ); +} + +/// `--token` outranks the environment it runs in, as a command line does. +#[test] +fn the_token_flag_outranks_the_environment() { + let served = Served::start_with( + &["--token", TOKEN], + &[("BIOROUTER_BROWSER_TOKEN", "from-the-file")], + ); + assert_eq!(http_status(served.port, &format!("/?t={TOKEN}")), Some(303)); + assert_eq!(http_status(served.port, "/?t=from-the-file"), Some(401)); +} + +/// `--no-token` is a refusal to have a gate. A token this shell exports must not +/// put one back: the daemon would demand it while the URL `serve` printed +/// carries none, so every open would be a 401 nothing on screen explains. +/// +/// Fails a fix that honours the variable without removing it from the child's +/// environment. +#[test] +fn no_token_is_not_undone_by_a_token_in_the_environment() { + let served = Served::start_with( + &["--no-token"], + &[("BIOROUTER_BROWSER_TOKEN", "from-the-file")], + ); + assert_eq!( + http_status(served.port, "/"), + Some(200), + "--no-token means the bare address opens the interface:\n{}", + served.log() + ); +} + #[test] fn sigterm_to_serve_stops_its_daemon() { let status = Served::start().stop_with("TERM"); diff --git a/crates/biorouter-mcp/src/active_work.rs b/crates/biorouter-mcp/src/active_work.rs index a7a17cb1d..8f4bb95e4 100644 --- a/crates/biorouter-mcp/src/active_work.rs +++ b/crates/biorouter-mcp/src/active_work.rs @@ -80,6 +80,20 @@ struct Entry { cancel: Option, } +impl Entry { + fn snapshot(&self, id: &str) -> ActiveWorkItem { + ActiveWorkItem { + id: id.to_string(), + kind: self.kind, + title: self.title.clone(), + detail: self.detail.clone(), + session_id: self.session_id.clone(), + started_at_epoch_ms: self.started_at_epoch_ms, + cancellable: self.cancel.is_some(), + } + } +} + /// A closure-free snapshot of one active-work entry, safe to hand to the HTTP /// layer. #[derive(Clone, Debug, PartialEq, Eq)] @@ -90,6 +104,15 @@ pub struct ActiveWorkItem { pub kind: ActiveWorkKind, pub title: String, pub detail: Option, + /// The chat this work belongs to, when the subsystem that registered it + /// knows. + /// + /// ⚠ **Not decoration** (issue #56): `GET /active_work` shows a row only to + /// a caller that could open this chat, and a row with `None` only to a + /// caller that could open a PRIVATE chat, because its title and detail are + /// some chat's command or prompt and nothing says whose. A registrant that + /// knows its chat and leaves this `None` hides its own row from that chat's + /// client. pub session_id: Option, pub started_at_epoch_ms: u128, /// Whether this entry carries a cancel action. @@ -146,18 +169,16 @@ impl ActiveWorkRegistry { /// Snapshot every live entry, sorted by id (creation order within a kind). pub fn list(&self) -> Vec { - self.lock() - .iter() - .map(|(id, e)| ActiveWorkItem { - id: id.clone(), - kind: e.kind, - title: e.title.clone(), - detail: e.detail.clone(), - session_id: e.session_id.clone(), - started_at_epoch_ms: e.started_at_epoch_ms, - cancellable: e.cancel.is_some(), - }) - .collect() + self.lock().iter().map(|(id, e)| e.snapshot(id)).collect() + } + + /// Snapshot the one live entry `id` names, if it still names one. + /// + /// `POST /active_work/{id}/cancel` reads the owning chat off this before it + /// fires anything (issue #56): the id names work, not a chat, and the reach + /// gate is a question about the chat. + pub fn get(&self, id: &str) -> Option { + self.lock().get(id).map(|e| e.snapshot(id)) } /// Fire an entry's cancel action. Returns `false` if no such entry exists. @@ -320,6 +341,35 @@ mod tests { assert!(!reg.cancel("sub-999"), "unknown id should report failure"); } + /// `get` is `list` narrowed to one id — the same snapshot, including the + /// owning chat the cancel route gates on — and `None` once the id names + /// nothing, including after the owner deregistered it. + #[test] + fn get_is_the_listed_snapshot_of_one_entry() { + let reg = fresh(); + let owned = reg.register( + ActiveWorkKind::Subagent, + "task", + Some("child session c".to_string()), + Some("s-parent".to_string()), + Some(Arc::new(|| {})), + ); + let unowned = reg.register(ActiveWorkKind::ForegroundCommand, "cmd", None, None, None); + + for id in [&owned, &unowned] { + let listed = reg.list().into_iter().find(|i| &i.id == id); + assert_eq!(reg.get(id), listed, "{id}"); + } + assert_eq!( + reg.get(&owned).and_then(|i| i.session_id).as_deref(), + Some("s-parent") + ); + assert_eq!(reg.get(&unowned).map(|i| i.session_id), Some(None)); + assert_eq!(reg.get("sub-999"), None); + reg.deregister(&owned); + assert_eq!(reg.get(&owned), None); + } + #[test] fn cancel_without_closure_is_a_noop_success() { let reg = fresh(); diff --git a/crates/biorouter-mcp/src/agent_drafter/bundle.rs b/crates/biorouter-mcp/src/agent_drafter/bundle.rs index 00bd24893..e1022c1f1 100644 --- a/crates/biorouter-mcp/src/agent_drafter/bundle.rs +++ b/crates/biorouter-mcp/src/agent_drafter/bundle.rs @@ -941,6 +941,39 @@ fn npx_cache_dir() -> PathBuf { std::env::temp_dir().join(format!("biorouter-npx-cache-{}", std::process::id())) } +/// The `ui/desktop/node_modules/.bin/esbuild` of the checkout `start` sits in, +/// if that checkout has one. +/// +/// ⚠ **The ascent stops at the checkout root** — the nearest ancestor holding a +/// `.git` entry — and that bound is why this is a named function rather than a +/// loop inside `find_esbuild`. It used to walk a flat six ancestors, and six is +/// exactly far enough to leave a worktree: from +/// `/.claude/worktrees//crates/biorouter-mcp` the sixth step is +/// `` itself, so a worktree with no install of its own silently borrowed +/// the MAIN checkout's bundler. Every esbuild-dependent test then passed locally +/// for a reason CI can never have, which is the shape of "works on my machine" +/// that is hardest to see: the tool was real, it was just not in the tree under +/// test. +/// +/// ⚠ A git worktree's `.git` is a FILE, not a directory, so the bound tests for +/// the ENTRY. Asking `is_dir()` would look right and stop at nothing. +fn esbuild_in_checkout(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(d) = dir { + let candidate = d.join("ui/desktop/node_modules/.bin/esbuild"); + if candidate.exists() { + return Some(candidate); + } + // Checked after the candidate, so the root's own install still counts — + // an ordinary checkout keeps `.git` and `ui/` in the same directory. + if d.join(".git").exists() { + return None; + } + dir = d.parent(); + } + None +} + /// Locate an esbuild executable. Returns `(program, leading_args)` so the caller /// can support both a direct binary and `npx esbuild`. fn find_esbuild() -> Option<(String, Vec)> { @@ -949,17 +982,11 @@ fn find_esbuild() -> Option<(String, Vec)> { return Some((bin, vec![])); } } - // Dev tree: ui/desktop/node_modules/.bin/esbuild, discovered relative to CWD - // and a couple of ancestors (tests/CLI may run from a subdir). + // Dev tree: the install belonging to THIS checkout, found from the CWD + // upwards because a test or the CLI may run from a subdirectory. if let Ok(cwd) = std::env::current_dir() { - let mut dir: Option<&Path> = Some(cwd.as_path()); - for _ in 0..6 { - let Some(d) = dir else { break }; - let cand = d.join("ui/desktop/node_modules/.bin/esbuild"); - if cand.exists() { - return Some((cand.to_string_lossy().to_string(), vec![])); - } - dir = d.parent(); + if let Some(found) = esbuild_in_checkout(&cwd) { + return Some((found.to_string_lossy().to_string(), vec![])); } } if which("esbuild") { @@ -2960,4 +2987,81 @@ document.getElementById("edit")!.addEventListener("click", () => { } } } + + /// The bundler discovery is confined to the checkout it is run from. + /// + /// ⚠ This is the "passes locally, for a reason CI never has" defect, in the + /// shape that is hardest to see: the tool the tests found was real and + /// working, it was simply not in the tree under test. `find_esbuild` walked a + /// flat six ancestors, and six is exactly far enough to leave a worktree — + /// from `/.claude/worktrees//crates/biorouter-mcp` the sixth step + /// is `` itself. So every esbuild-dependent test in a worktree with no + /// install of its own quietly borrowed the main checkout's bundler, and no + /// failure anywhere said so. + /// + /// Tested through `esbuild_in_checkout` rather than `find_esbuild`, because + /// the latter reads `current_dir()` — process-global state that a parallel + /// test run cannot set without racing every other test in this binary. + mod esbuild_discovery { + use super::super::esbuild_in_checkout; + use tempfile::TempDir; + + /// An outer checkout that HAS an install, and a worktree inside it that + /// does not — the real layout, with `.claude/worktrees/` two levels + /// down and a `.git` FILE rather than a directory. + fn nested_checkouts() -> (TempDir, std::path::PathBuf) { + let root = TempDir::new().unwrap(); + let outer = root.path().join("BioRouter"); + let bin = outer.join("ui/desktop/node_modules/.bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("esbuild"), "#!/bin/sh\n").unwrap(); + std::fs::write(outer.join(".git"), "gitdir: elsewhere\n").unwrap(); + + let worktree = outer.join(".claude/worktrees/a-worktree"); + std::fs::create_dir_all(worktree.join("crates/biorouter-mcp")).unwrap(); + std::fs::write(worktree.join(".git"), "gitdir: elsewhere\n").unwrap(); + (root, worktree) + } + + #[test] + fn a_worktree_without_its_own_install_finds_nothing() { + let (_root, worktree) = nested_checkouts(); + assert_eq!( + esbuild_in_checkout(&worktree.join("crates/biorouter-mcp")), + None, + "a worktree borrowed the outer checkout's bundler" + ); + } + + #[test] + fn a_checkouts_own_install_is_found_from_a_subdirectory() { + let (_root, worktree) = nested_checkouts(); + let bin = worktree.join("ui/desktop/node_modules/.bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("esbuild"), "#!/bin/sh\n").unwrap(); + assert_eq!( + esbuild_in_checkout(&worktree.join("crates/biorouter-mcp")), + Some(bin.join("esbuild")), + "the worktree's own install must still be found from a crate directory" + ); + } + + #[test] + fn the_root_of_a_checkout_is_searched_before_the_boundary_stops_it() { + // An ordinary clone keeps `.git` and `ui/` in the same directory, so + // a bound that fired before testing the candidate would find nothing + // anywhere — which would look like "esbuild is missing" on every + // developer machine. + let (_root, worktree) = nested_checkouts(); + let outer = worktree + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .unwrap(); + assert_eq!( + esbuild_in_checkout(outer), + Some(outer.join("ui/desktop/node_modules/.bin/esbuild")) + ); + } + } } diff --git a/crates/biorouter-mcp/src/developer/background.rs b/crates/biorouter-mcp/src/developer/background.rs index 6d81951aa..3dd17f50f 100644 --- a/crates/biorouter-mcp/src/developer/background.rs +++ b/crates/biorouter-mcp/src/developer/background.rs @@ -112,11 +112,17 @@ impl BackgroundJobs { /// Spawn `command` as a background job in its own process group, wire up /// output capture and a supervisor that records the terminal status, and /// register the job. Returns the new job id. + /// + /// `session_id` is the chat that started the job, carried onto its + /// active-work row: the listing shows a row only to a caller that could open + /// its chat, and answers a row that names no chat as a private chat's + /// (issue #56). pub async fn spawn( &self, command: &str, label: Option, working_dir: Option, + session_id: Option, ) -> Result { let id = format!("job-{}", self.next_id.fetch_add(1, Ordering::SeqCst)); let label = label.unwrap_or_else(|| command.chars().take(40).collect()); @@ -173,7 +179,7 @@ impl BackgroundJobs { ActiveWorkKind::BackgroundJob, format!("{id}: {label}"), Some(command.to_string()), - None, + session_id, Some(Arc::new(move || { killed_for_cancel.store(true, Ordering::SeqCst); kill_process_group(pid_for_cancel, identity_for_cancel.clone()); @@ -943,7 +949,7 @@ mod tests { #[tokio::test] async fn start_lists_and_completes_with_output() { let jobs = new_jobs(); - let id = jobs.spawn("echo hello-bg", None, None).await.unwrap(); + let id = jobs.spawn("echo hello-bg", None, None, None).await.unwrap(); assert!(jobs.list().await.contains(&id)); assert_eq!( wait_terminal(&jobs, &id, JOB_WAIT_MS).await, @@ -956,7 +962,7 @@ mod tests { #[tokio::test] async fn list_reports_command_status_and_unread_output() { let jobs = new_jobs(); - let id = jobs.spawn("echo listme", None, None).await.unwrap(); + let id = jobs.spawn("echo listme", None, None, None).await.unwrap(); assert_eq!( wait_terminal(&jobs, &id, JOB_WAIT_MS).await, JobStatus::Exited(0) @@ -998,7 +1004,7 @@ mod tests { #[tokio::test] async fn nonzero_exit_code_is_surfaced() { let jobs = new_jobs(); - let id = jobs.spawn("exit 3", None, None).await.unwrap(); + let id = jobs.spawn("exit 3", None, None, None).await.unwrap(); assert_eq!( wait_terminal(&jobs, &id, JOB_WAIT_MS).await, JobStatus::Exited(3) @@ -1013,7 +1019,7 @@ mod tests { } else { "echo first; sleep 2; echo second" }; - let id = jobs.spawn(command, None, None).await.unwrap(); + let id = jobs.spawn(command, None, None, None).await.unwrap(); let first = collect_output_until(&jobs, &id, "first", JOB_WAIT_MS).await; assert!(first.contains("first"), "first read: {first}"); assert!(!first.contains("second"), "second leaked early: {first}"); @@ -1029,7 +1035,7 @@ mod tests { #[tokio::test] async fn wait_returns_early_on_completion() { let jobs = new_jobs(); - let id = jobs.spawn("echo done", None, None).await.unwrap(); + let id = jobs.spawn("echo done", None, None, None).await.unwrap(); let started = Instant::now(); let out = jobs.wait(&id, 30).await.unwrap(); assert!(out.contains("finished"), "wait result: {out}"); @@ -1039,7 +1045,7 @@ mod tests { #[tokio::test] async fn wait_times_out_without_killing_then_kill_works() { let jobs = new_jobs(); - let id = jobs.spawn("sleep 30", None, None).await.unwrap(); + let id = jobs.spawn("sleep 30", None, None, None).await.unwrap(); let out = jobs.wait(&id, 1).await.unwrap(); assert!(out.contains("Still running"), "wait result: {out}"); assert_eq!( @@ -1215,7 +1221,7 @@ mod tests { #[tokio::test] async fn recorded_identity_matches_the_live_child_of_a_real_spawn() { let jobs = new_jobs(); - let id = jobs.spawn("sleep 30", None, None).await.unwrap(); + let id = jobs.spawn("sleep 30", None, None, None).await.unwrap(); let job = jobs.job(&id).await.unwrap(); let pid = job.pid.unwrap(); @@ -1419,7 +1425,7 @@ mod tests { async fn spawn_records_pidfile_and_terminal_removes_it() { let dir = ensure_test_run_dir().to_path_buf(); let jobs = new_jobs(); - let id = jobs.spawn("sleep 30", None, None).await.unwrap(); + let id = jobs.spawn("sleep 30", None, None, None).await.unwrap(); let pid = jobs.job(&id).await.unwrap().pid.unwrap(); let pidfile = dir.join(pidfile_name(std::process::id(), pid)); diff --git a/crates/biorouter-mcp/src/developer/rmcp_developer.rs b/crates/biorouter-mcp/src/developer/rmcp_developer.rs index 829644c24..3cbda1357 100644 --- a/crates/biorouter-mcp/src/developer/rmcp_developer.rs +++ b/crates/biorouter-mcp/src/developer/rmcp_developer.rs @@ -49,6 +49,31 @@ use super::text_editor::{ use super::undo_history::{self, FileHistory}; use std::time::Duration; +/// The `_meta` key Biorouter's MCP client writes the dispatching chat's id +/// under, on every tool call (`McpMeta` / `session_context::SESSION_ID_HEADER` +/// in the `biorouter` crate, which this crate cannot name). The knowledge and +/// Agent Drafter servers read the same key. +const SESSION_ID_META_KEY: &str = "biorouter-session-id"; + +/// The chat a tool call was dispatched from, when a Biorouter client sent it. +/// +/// It rides the call's `_meta`, which the client composes itself — the model +/// supplies the arguments and never this — so it is the chat that asked, not a +/// chat the model named. `None` for a call from any other MCP client. +/// +/// Issue #56: the shell's active-work rows carry it, because `GET /active_work` +/// shows a row only to a caller that could open its chat and answers a row +/// that names no chat as a private chat's. +fn dispatching_session_id(context: &RequestContext) -> Option { + context + .meta + .0 + .get(SESSION_ID_META_KEY) + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_owned) +} + fn redirect_target_within_base(base: &Path, target: &str) -> Option { // Path::join preserves relative targets and replaces the base for absolute // ones on every supported platform. Always check the resulting path: a @@ -1482,6 +1507,8 @@ impl DeveloperServer { ) -> Result { let params = params.0; let command = ¶ms.command; + // Read before `context` is taken apart below. + let session_id = dispatching_session_id(&context); let peer = context.peer; let request_id = context.id; // rmcp's own request-scoped token. It is a descendant of the serve @@ -1512,7 +1539,7 @@ impl DeveloperServer { if params.background.unwrap_or(false) { let id = self .background_jobs - .spawn(command, params.label.clone(), working_dir) + .spawn(command, params.label.clone(), working_dir, session_id) .await .map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e, None))?; return Ok(CallToolResult::success(vec![Content::text(format!( @@ -1538,7 +1565,7 @@ impl DeveloperServer { mirror_ct.cancel(); })); let output_result = self - .execute_shell_command(command, working_dir, &peer, run_ct) + .execute_shell_command(command, working_dir, session_id, &peer, run_ct) .await; // Clean up the process from tracking @@ -1776,6 +1803,7 @@ impl DeveloperServer { &self, command: &str, working_dir: Option, + session_id: Option, peer: &rmcp::service::Peer, cancellation_token: CancellationToken, ) -> Result<(String, Option), ErrorData> { @@ -1833,7 +1861,8 @@ impl DeveloperServer { // can leave this function by `?` as well as by returning a value, and a // heartbeat that outlives its command would notify the client forever. let started = std::time::Instant::now(); - let _active_work = super::shell::ForegroundWorkGuard::register(&command_text, pid); + let _active_work = + super::shell::ForegroundWorkGuard::register(&command_text, pid, session_id); let _heartbeat = super::shell::AbortOnDrop::new(Self::foreground_heartbeat( peer.clone(), command_text.clone(), @@ -5953,6 +5982,135 @@ mod tests { }); } + /// A tool call from a chat, stamped the way Biorouter's MCP client stamps + /// every call it dispatches: the chat's id on the call's `_meta`. + /// + /// The key is spelled out rather than borrowed from `SESSION_ID_META_KEY` + /// on purpose: it is the WIRE spelling the `biorouter` crate's client + /// writes, and a reader whose constant drifted from it must fail here + /// rather than agree with itself. + fn context_from_chat( + peer: &rmcp::service::Peer, + request: i64, + session_id: &str, + ) -> RequestContext { + let mut meta = rmcp::model::Meta::default(); + meta.0.insert( + "biorouter-session-id".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + RequestContext { + ct: Default::default(), + id: NumberOrString::Number(request), + meta, + extensions: Default::default(), + peer: peer.clone(), + } + } + + /// Issue #56: `GET /active_work` shows a row only to a caller that could + /// open the chat the row belongs to, and treats a row that names no chat as + /// a private chat's. So a foreground command has to say which chat ran it — + /// or every command from every chat is withheld from every caller that + /// cannot open a private one, the public chat's own client included. + #[test] + #[serial] + #[cfg(unix)] + fn a_running_foreground_command_names_the_chat_that_ran_it() { + use crate::active_work::active_work; + + run_shell_test(|| async { + let server = create_test_server(); + let running_service = serve_directly(server.clone(), create_test_transport(), None); + let peer = running_service.peer().clone(); + + let marker = "br56-foreground-owner-probe"; + let command = format!("sleep 30 # {marker}"); + let context = context_from_chat(&peer, 5601, "20260911_4242"); + let server_clone = server.clone(); + let shell_task = tokio::spawn(async move { + server_clone + .shell( + Parameters(ShellParams { + working_directory: None, + command, + background: None, + label: None, + }), + context, + ) + .await + }); + + let mine = || { + active_work() + .list() + .into_iter() + .find(|i| i.detail.as_deref().is_some_and(|d| d.contains(marker))) + }; + let deadline = Instant::now() + Duration::from_secs(20); + let mut entry = None; + while entry.is_none() && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + entry = mine(); + } + let entry = + entry.expect("a running foreground command must appear in the active-work view"); + // Stopped before the assertion, so a failure leaves no sleep behind. + assert!(active_work().cancel(&entry.id)); + let _ = timeout(Duration::from_secs(10), shell_task).await; + assert_eq!( + entry.session_id.as_deref(), + Some("20260911_4242"), + "a foreground command's active-work row does not name the chat that ran it" + ); + + cleanup_test_service(running_service, peer); + }); + } + + /// …and the same for a background job, which outlives the call that + /// started it, so it is the row most likely to be listed long after. + #[test] + #[serial] + #[cfg(unix)] + fn a_background_job_names_the_chat_that_started_it() { + use crate::active_work::active_work; + + run_shell_test(|| async { + let server = create_test_server(); + let running_service = serve_directly(server.clone(), create_test_transport(), None); + let peer = running_service.peer().clone(); + + let marker = "br56-background-owner-probe"; + let started = server + .shell( + Parameters(ShellParams { + working_directory: None, + command: format!("sleep 30 # {marker}"), + background: Some(true), + label: None, + }), + context_from_chat(&peer, 5602, "20260911_4343"), + ) + .await; + assert!(started.is_ok(), "{started:?}"); + let entry = active_work() + .list() + .into_iter() + .find(|i| i.detail.as_deref().is_some_and(|d| d.contains(marker))) + .expect("a background job must appear in the active-work view"); + assert!(active_work().cancel(&entry.id)); + assert_eq!( + entry.session_id.as_deref(), + Some("20260911_4343"), + "a background job's active-work row does not name the chat that started it" + ); + + cleanup_test_service(running_service, peer); + }); + } + /// Issue #72: dropping the shell tool's future must take the command's whole /// process tree with it. /// diff --git a/crates/biorouter-mcp/src/developer/shell.rs b/crates/biorouter-mcp/src/developer/shell.rs index 871857a9a..9a0c43660 100644 --- a/crates/biorouter-mcp/src/developer/shell.rs +++ b/crates/biorouter-mcp/src/developer/shell.rs @@ -546,12 +546,16 @@ impl Drop for AbortOnDrop { /// /// RAII, so an early return, an error or a panic can never leave a phantom /// "still running" entry behind. +/// +/// `session_id` is the chat that ran the command. The entry carries it because +/// the listing shows a row only to a caller that could open its chat, and +/// answers a row that names no chat as a private chat's (issue #56). pub struct ForegroundWorkGuard { _guard: crate::active_work::ActiveWorkGuard, } impl ForegroundWorkGuard { - pub fn register(command: &str, pid: Option) -> Self { + pub fn register(command: &str, pid: Option, session_id: Option) -> Self { let cancel: Option> = pid.map(|pid| { std::sync::Arc::new(move || kill_process_group_now(pid)) as std::sync::Arc @@ -561,7 +565,7 @@ impl ForegroundWorkGuard { crate::active_work::ActiveWorkKind::ForegroundCommand, first_line(command), Some(command.to_string()), - None, + session_id, cancel, ), } diff --git a/crates/biorouter-mcp/src/knowledge/service.rs b/crates/biorouter-mcp/src/knowledge/service.rs index f71d85bb4..0a27ad874 100644 --- a/crates/biorouter-mcp/src/knowledge/service.rs +++ b/crates/biorouter-mcp/src/knowledge/service.rs @@ -4091,7 +4091,80 @@ impl KnowledgeService { primary: PrimaryUpdate<'_>, ) -> anyhow::Result { let _lock = self.lock_root()?; - self.apply_selection_unlocked(session_id, hidden, primary) + self.apply_selection_unlocked(session_id, hidden, primary, &|_| true) + } + + /// [`Self::set_selection`] for a caller that cannot reach every base (issue + /// #56, QA 2026-09-10 H2): **a caller changes only what it can see.** + /// + /// `reachable` is the caller's reach, decided by the daemon's HTTP gate. + /// For a caller that reaches everything it admits every id and this is + /// exactly [`Self::set_selection`]. For one that does not: + /// + /// * `hidden` is taken literally for the bases `reachable` admits, and every + /// base it does not admit keeps the state it already had in this scope — + /// neither hidden nor revealed by a list its caller was never shown. That + /// is the case that matters: a renderer prunes ids missing from the list + /// it was given, and a filtered list would otherwise un-hide every private + /// base on the machine as a side effect of one click. + /// * `Clear` is a no-op when the scope's effective primary is a base the + /// caller cannot reach. It was shown no primary, so it asked to clear none. + /// `Inherit` likewise leaves a pin this scope holds on such a base: it + /// would drop a choice the caller was never shown. + /// * `Set(id)` naming a base the caller cannot reach is refused. The route + /// answers that case first, with the gate's own refusal; this is the + /// backstop, and it names nothing. + /// * A refusal's list of available bases names only reachable ones. + /// + /// One root lock across the read of the stored state and the write, so the + /// merge cannot interleave with another writer (see [`Self::hide_kb`] for + /// why a read-modify-write across two calls loses an edit). + pub fn set_selection_within( + &self, + session_id: Option<&str>, + hidden: Option<&[String]>, + primary: PrimaryUpdate<'_>, + reachable: &dyn Fn(&str) -> bool, + ) -> anyhow::Result { + let _lock = self.lock_root()?; + let hidden = match hidden { + None => None, + Some(submitted) => { + let mut next = Self::sanitize_kb_id_list(submitted)? + .into_iter() + .filter(|id| reachable(id)) + .collect::>(); + next.extend( + self.hidden_for_scope_unlocked(session_id)? + .into_iter() + .filter(|id| !reachable(id)), + ); + Some(next) + } + }; + let primary = match primary { + PrimaryUpdate::Set(id) if !reachable(id) => { + anyhow::bail!("knowledge base '{id}' is not available") + } + PrimaryUpdate::Clear | PrimaryUpdate::Inherit => { + let own = + self.read_primary_file_unlocked(&self.primary_path_for_scope(session_id))?; + // `Clear` is judged against the pointer the scope is USING and + // `Inherit` against the one it HOLDS: clearing hides what is + // shown, and inheriting drops only this scope's own pin. + let judged = match primary { + PrimaryUpdate::Clear => self.effective_primary_unlocked(&own, session_id)?, + _ => own, + }; + if judged.pinned().is_some_and(|id| !reachable(id)) { + PrimaryUpdate::Unchanged + } else { + primary + } + } + other => other, + }; + self.apply_selection_unlocked(session_id, hidden.as_deref(), primary, reachable) } /// Drop one base from this scope's set, in one root-locked step. @@ -4118,7 +4191,7 @@ impl KnowledgeService { if !hidden.iter().any(|id| id == kb_id) { hidden.push(kb_id.to_string()); } - self.apply_selection_unlocked(session_id, Some(&hidden), primary) + self.apply_selection_unlocked(session_id, Some(&hidden), primary, &|_| true) } /// Add one base to this scope's set (un-hide it), in one root-locked step. @@ -4151,7 +4224,7 @@ impl KnowledgeService { .into_iter() .filter(|id| id != kb_id) .collect::>(); - self.apply_selection_unlocked(session_id, Some(&hidden), primary) + self.apply_selection_unlocked(session_id, Some(&hidden), primary, &|_| true) } /// Set this scope's set from the ids that should be **visible** — the @@ -4176,7 +4249,7 @@ impl KnowledgeService { .into_iter() .filter(|id| !visible.contains(id)) .collect::>(); - self.apply_selection_unlocked(session_id, Some(&hidden), primary) + self.apply_selection_unlocked(session_id, Some(&hidden), primary, &|_| true) } /// The engine behind every selection write: decide, validate, *then* write. @@ -4190,11 +4263,18 @@ impl KnowledgeService { /// "commit" line can fail on anything but I/O. /// /// Callers must already hold the root lock. + /// + /// `listed` decides which bases a refusal may NAME when it lists what is + /// available: every base for the in-process callers, the reachable ones for + /// an HTTP caller that cannot reach them all (see + /// [`Self::set_selection_within`]). A refusal that enumerated the rest would + /// hand over the ids the caller was just refused. fn apply_selection_unlocked( &self, session_id: Option<&str>, hidden: Option<&[String]>, primary: PrimaryUpdate<'_>, + listed: &dyn Fn(&str) -> bool, ) -> anyhow::Result { // ---- decide: touch nothing on disk until every branch has succeeded ---- let installed = self.installed_kb_ids_unlocked()?; @@ -4225,10 +4305,15 @@ impl KnowledgeService { PrimaryUpdate::Inherit => Some(StoredPrimary::Inherit), PrimaryUpdate::Set(id) => { if !next_ids.iter().any(|known| known == id) { - let available = if next_ids.is_empty() { + let named = next_ids + .iter() + .filter(|known| listed(known)) + .map(String::as_str) + .collect::>(); + let available = if named.is_empty() { "none".to_string() } else { - next_ids.join(", ") + named.join(", ") }; // Scope-appropriate vocabulary: the CLI and scheduled jobs // pass `None` and have no session concept at all (D11), so @@ -7320,6 +7405,104 @@ mod tests { Ok(()) } + /// Issue #56, QA 2026-09-10 H2: a caller that cannot reach every base + /// changes only the bases it can. Each rule is driven against the one a + /// plausible wrong implementation would break — taking the submitted set + /// literally, clearing a primary it was never shown, dropping a pin it could + /// not see, and naming the rest of the machine's bases in a refusal. + #[test] + fn a_limited_caller_changes_only_what_it_can_see() -> anyhow::Result<()> { + let tmp = tempfile::TempDir::new()?; + let svc = KnowledgeService::new(tmp.path().to_path_buf()); + for id in ["alpha", "beta", "secret"] { + svc.create_base(id, id, None)?; + } + let sees = |id: &str| id != "secret"; + + // The user pins `secret` as this chat's primary, with nothing hidden. + svc.set_selection(Some("s1"), Some(&[]), PrimaryUpdate::Set("secret"))?; + + // A limited caller rewrites the set naming only what it saw: `secret` + // stays visible (it was not hidden) and `beta` is hidden as asked. + let sel = svc.set_selection_within( + Some("s1"), + Some(&["beta".to_string()]), + PrimaryUpdate::Unchanged, + &sees, + )?; + assert_eq!(sel.hidden_kbs, vec!["beta".to_string()]); + assert_eq!(sel.primary_kb.as_deref(), Some("secret")); + + // It asks to clear the primary it was shown as none: `secret` stays. + let sel = svc.set_selection_within(Some("s1"), None, PrimaryUpdate::Clear, &sees)?; + assert_eq!( + sel.primary_kb.as_deref(), + Some("secret"), + "cleared a hidden primary" + ); + // …and to inherit, which would drop this chat's pin on `secret`: stays. + let sel = svc.set_selection_within(Some("s1"), None, PrimaryUpdate::Inherit, &sees)?; + assert_eq!( + sel.primary_kb.as_deref(), + Some("secret"), + "dropped a hidden pin" + ); + + // It may not hide `secret` by naming it, nor pin it. + let sel = svc.set_selection_within( + Some("s1"), + Some(&["secret".to_string()]), + PrimaryUpdate::Unchanged, + &sees, + )?; + assert!( + sel.hidden_kbs.is_empty(), + "hid a base it could not see: {sel:?}" + ); + let err = svc + .set_selection_within(Some("s1"), None, PrimaryUpdate::Set("secret"), &sees) + .unwrap_err() + .to_string(); + assert!(!err.contains("alpha") && !err.contains("beta"), "{err}"); + + // The user hides `secret`; a limited caller that "un-hides everything" + // leaves it hidden. + svc.set_selection( + Some("s1"), + Some(&["secret".to_string()]), + PrimaryUpdate::Set("alpha"), + )?; + let sel = + svc.set_selection_within(Some("s1"), Some(&[]), PrimaryUpdate::Unchanged, &sees)?; + assert_eq!(sel.hidden_kbs, vec!["secret".to_string()]); + + // A refusal names only what the caller can see: hiding `beta` while + // pinning it fails, and the list of what IS available omits `secret` + // even though `secret` is not hidden from the set at this point. + svc.set_selection(Some("s1"), Some(&[]), PrimaryUpdate::Set("alpha"))?; + let err = svc + .set_selection_within( + Some("s1"), + Some(&["beta".to_string()]), + PrimaryUpdate::Set("beta"), + &sees, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("alpha"), "{err}"); + assert!( + !err.contains("secret"), + "a refusal named a base the caller cannot see: {err}" + ); + + // A caller that sees everything is `set_selection`, byte for byte. + let everything = |_: &str| true; + let sel = + svc.set_selection_within(Some("s1"), Some(&[]), PrimaryUpdate::Clear, &everything)?; + assert_eq!(sel.primary_kb, None); + Ok(()) + } + /// The membership primitives every caller actually needs, so none of them /// has to read the hidden list, edit it and write it back. Each takes the /// whole gesture and applies it under one root lock. diff --git a/crates/biorouter-server/src/auth.rs b/crates/biorouter-server/src/auth.rs index 1dbfb2b11..9e40286a0 100644 --- a/crates/biorouter-server/src/auth.rs +++ b/crates/biorouter-server/src/auth.rs @@ -127,6 +127,78 @@ pub fn is_user_action(headers: &axum::http::HeaderMap) -> bool { matches!(user_action_proof(headers), UserActionProof::Proven) } +/// The standing a `biorouter serve` daemon gives its OWN web interface (issue +/// #56, the QA follow-up of 2026-09-10 that closed H2 and M1). +/// +/// A serve daemon holds no user-action digest (SD-7) and pins the provider for +/// every session it runs (SD-1), so the tier the operator's configured provider +/// implies is the only capability its interface can be said to have. This keeps +/// that tier beside the browser token whose cookie marks a request as coming +/// from the document this daemon served — which is how a request from the +/// operator's browser is told from one that merely holds the secret. +/// +/// ⚠ **It widens nothing that was refused.** It is read only by the listing and +/// knowledge-base gates in `routes::session_reach`, which were open to this +/// interface before they existed; the transcript gate, `session_reach` itself, +/// never reads it. A serve daemon's browser therefore keeps exactly the reach it +/// had, and a caller holding only the secret loses it. +/// +/// ⚠ **Not authentication, and not a proof of a person.** `biorouter serve` +/// hands this daemon the token in its environment, beside the secret, so a +/// caller that can read one can read the other — the residual `X-Caller-Provider` +/// already carries (#47). It never satisfies a proof-of-user check: SD-1 and +/// SD-8 stand exactly as they were. +struct ServedOperator { + browser_token: String, + capability: biorouter::privacy::ProviderTier, +} + +static SERVED_OPERATOR: OnceLock = OnceLock::new(); + +/// Record a serve daemon's operator standing. Called once, from +/// `commands::agent::run`, and only when the web interface is served behind a +/// browser token: a `--no-token` daemon cannot tell its own interface from any +/// other local caller, so it gives none. +pub fn install_served_operator( + browser_token: String, + capability: biorouter::privacy::ProviderTier, +) { + let _ = SERVED_OPERATOR.set(ServedOperator { + browser_token, + capability, + }); +} + +/// The capability a request earns by presenting the served document's cookie: +/// the operator's tier on a serve daemon, `Public` for every other request on +/// every other daemon. +pub fn served_operator_capability( + headers: &axum::http::HeaderMap, +) -> biorouter::privacy::ProviderTier { + match SERVED_OPERATOR.get() { + Some(operator) + if served_document_matches( + crate::routes::web_ui::session_cookie(headers), + &operator.browser_token, + ) => + { + operator.capability + } + _ => biorouter::privacy::ProviderTier::Public, + } +} + +/// Does the presented cookie carry the served document's token? +/// +/// Pure, so the rule is testable without the process global; compared without +/// an early return, the same way the secret is. An empty token matches nothing. +pub fn served_document_matches(presented: Option<&str>, browser_token: &str) -> bool { + match presented { + Some(presented) if !browser_token.is_empty() => secret_matches(presented, browser_token), + _ => false, + } +} + /// How many failed authentications one address may have answered `401` inside /// [`FAILED_AUTH_WINDOW`]. Past it, the rest of that address's failures in the /// window are answered `429`. @@ -736,6 +808,21 @@ mod tests { assert!(!is_unauthenticated_path("/tool_bridgeX/abc")); } + /// The serve daemon's operator standing is earned by the served document's + /// cookie and by nothing else: the whole token, not a prefix; not an empty + /// one; not an absent one. + #[test] + fn only_the_served_documents_cookie_earns_the_operator_standing() { + use super::served_document_matches; + assert!(served_document_matches(Some("0123abcd"), "0123abcd")); + assert!(!served_document_matches(Some("0123abc"), "0123abcd")); + assert!(!served_document_matches(Some(""), "0123abcd")); + assert!(!served_document_matches(None, "0123abcd")); + // An empty token is "no token", and "no token" earns nothing — never + // the equality of two empty strings. + assert!(!served_document_matches(Some(""), "")); + } + #[test] fn secret_compare_is_exact() { assert!(secret_matches("abc", "abc")); diff --git a/crates/biorouter-server/src/commands/agent.rs b/crates/biorouter-server/src/commands/agent.rs index 5b2be8b6a..e3a743f95 100644 --- a/crates/biorouter-server/src/commands/agent.rs +++ b/crates/biorouter-server/src/commands/agent.rs @@ -242,6 +242,89 @@ async fn read_user_action_digest() -> Result<[u8; 32], NoUserActionKey> { classify_digest_line(line) } +/// Read the launcher's proof-of-user digest, say what holding none costs, and +/// pin the launch configuration (SD-12). +/// +/// Split out of [`run`] so the block's two decisions — the LEVEL of the report +/// and the launch-state pin — stay beside the read they both depend on, and so +/// `run` stays under `clippy::too_many_lines`. The pin must still land before +/// `AppState::new()` and before any route is mounted; the one call site is +/// where the inline block was. +async fn install_user_action_proof() -> Option<[u8; 32]> { + // SD-12, Finding 3. Holding no key is TWO situations wearing one name: a + // deployment where no proof can ever exist, and a launcher that meant to + // send one and did not. The launcher says which (`launch::USER_ACTION_EXPECTED_ENV`), + // and the difference decides both what is logged here and — in + // `routes::agent::new_chat_bind_decision` — whether SD-12's exemption applies + // at all. A desktop daemon that lost its key is a fault to repair, not a + // headless deployment, so it keeps the refusal. + let launcher_declared_a_key = + biorouter_server::launch::launcher_declared_a_user_action_key_in_env(); + let digest = match read_user_action_digest().await { + Ok(digest) => Some(digest), + Err(reason) => { + // ⚠ One line, and it names every consequence. `reason.warning()` + // carries SD-11's — before SD-11 a keyless desktop daemon announced + // itself at the first click, because Stop answered 403; now Stop + // works there, so the misconfiguration is silent unless this says + // so — and SD-12's new-chat sentence is appended. The LEVEL is the + // launcher's declaration: a deployment that can hold no key is a + // warning, a launcher that dropped one is an error. + if launcher_declared_a_key { + tracing::error!( + "{} This daemon's launcher declared it would send a key ({}=set), so this is \ + a fault and not a deployment: a new chat on a private model will be refused \ + rather than started (SD-12). The key was either never generated or the \ + bounded 2s stdin read timed out. Quit and reopen Biorouter.", + reason.warning(), + biorouter_server::launch::USER_ACTION_EXPECTED_ENV + ); + } else { + tracing::warn!( + "{} A new chat still starts on the provider this daemon was LAUNCHED with, \ + and is refused if that configuration has changed since (SD-12).", + reason.warning() + ); + } + None + } + }; + // SD-12: pin the operator's declaration. Before `AppState::new()` and before + // any route is mounted, so no request is ever served against an unrecorded + // launch state, and so the sample predates anything this process could write + // to `config.yaml` itself. + biorouter_server::launch::record_launch_state(launcher_declared_a_key); + digest +} + +/// The tier SD-1 pins for every session a serve daemon runs: the DECLARED tier +/// of the provider the operator configured, reduced with `least` over the lead +/// provider when a lead model is configured — the reduction a bound lead/worker +/// pair gets, since its transcript reaches both. +/// +/// Read ONCE, at launch. The operator made this choice at the terminal before +/// anyone opened a tab (SD-1), and `config.yaml` is agent-writable (DR-17), so a +/// value re-read per request would be one a model could raise by editing a file. +/// Unconfigured, and a name this install does not publish, both read Public — +/// the fail-safe side, and the reach this interface had for every private chat +/// before it had any. +async fn served_operator_capability() -> biorouter::privacy::ProviderTier { + use biorouter::privacy::ProviderTier; + use biorouter::workflow::privacy::declared_provider_tier; + let config = biorouter::config::Config::global(); + let Ok(provider) = config.get_biorouter_provider() else { + return ProviderTier::Public; + }; + let mut capability = declared_provider_tier(&provider).await; + if config.get_param::("BIOROUTER_LEAD_MODEL").is_ok() { + let lead = config + .get_param::("BIOROUTER_LEAD_PROVIDER") + .unwrap_or_else(|_| provider.clone()); + capability = ProviderTier::least(capability, declared_provider_tier(&lead).await); + } + capability +} + pub async fn run(exit_with_parent: Option) -> Result<()> { crate::logging::setup_logging(Some("biorouterd"))?; @@ -299,18 +382,7 @@ 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 = 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 - } - }; + let user_action_digest = install_user_action_proof().await; // 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 @@ -365,6 +437,21 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { // there, so its absence here means a loopback bind whose launcher // chose not to require one. let browser_token = std::env::var("BIOROUTER_BROWSER_TOKEN").ok(); + // Issue #56, QA 2026-09-10 (SD-10): the interface this daemon serves is + // the operator's, and SD-1 pins the provider every session here runs + // on — so that provider's tier is the reach the listing and + // knowledge-base gates give a request carrying the served document's + // cookie. Without a token there is no such cookie, and the interface + // cannot be told from any other local caller, so it gets none. + if let Some(token) = browser_token.as_deref().filter(|t| !t.is_empty()) { + let capability = served_operator_capability().await; + info!( + ?capability, + "the served interface is given the configured provider's tier on listings \ + and knowledge bases" + ); + biorouter_server::auth::install_served_operator(token.to_string(), capability); + } let ui = crate::routes::web_ui::WebUi::new(&web_dir, &secret_key, browser_token) .map_err(|e| { anyhow::anyhow!( @@ -476,8 +563,11 @@ mod keyless_report_tests { "{empty:?}" ); } - // Present and wrong, which a launcher fault also looks like. - for bad in ["not-hex", "abcd", &digest[..62], &format!("{digest}aa")] { + // 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), diff --git a/crates/biorouter-server/src/launch.rs b/crates/biorouter-server/src/launch.rs new file mode 100644 index 000000000..d90b5415c --- /dev/null +++ b/crates/biorouter-server/src/launch.rs @@ -0,0 +1,292 @@ +//! What this daemon was **launched** with — the operator's own declaration, +//! sampled once before any route is mounted and never re-read. +//! +//! SD-12 (`docs/deployment/serve-decisions.md`) lets a daemon that holds no +//! user-action key bind a **private** provider to a brand-new chat with no proof +//! of a person, because nobody on such a daemon can produce one. The +//! justification is that the provider being bound is the *operator's* choice, +//! made at a terminal with `biorouter configure`. +//! +//! ⚠ **That sentence is only true of a value the operator wrote, and the file it +//! lives in is agent-writable.** DR-14's general filesystem deny is DEFERRED +//! (`docs/security/privacy-tiers.md`, *"Did not ship"*), the agent holds +//! `developer__shell`, and `Config`'s value cache is keyed on a `FileStamp` it +//! re-`stat`s on every read — so `config.yaml` is reloaded live and +//! `configured_new_session_provider()` reads whatever the file says **at request +//! time**. Without this module, a model on a keyless daemon whose operator +//! configured a *public* default could write a private provider into that file +//! and then `POST /agent/start` to mint a Private-capability chat with an +//! extension set of its own choosing. +//! +//! So the exemption is **pinned** to this snapshot. The bind still reads the +//! configuration — an operator who edits the file and restarts the daemon is +//! served — but SD-12's keyless exemption applies only while the +//! capability-deciding configuration still matches what the daemon started with. +//! That is SD-1's own sentence made true of the door: *"the tier implied by the +//! operator's `biorouter configure` choice then holds for every session in that +//! daemon."* +//! +//! It also records whether the **launcher declared** it would hand over a +//! user-action key, which is what separates two states `UserActionProof` +//! deliberately collapses into one: a deployment where no proof can ever exist, +//! and a desktop daemon whose key did not arrive. +//! +//! ⚠ **This is not DR-14, and must not be read as a substitute for it.** A shell +//! is still a shell: a model that can write `config.yaml` can also read the +//! session store and the knowledge bases directly, and can start a *second* +//! `biorouterd` of its own. What this module closes is one door's *stated* +//! guarantee, so that the record does not rest on a claim the tree contradicts. + +use std::sync::{PoisonError, RwLock}; + +/// A launcher that hands this daemon a user-action digest on stdin declares +/// itself here. +/// +/// It exists so the daemon can tell *"no key was ever meant to arrive"* +/// (`biorouter serve`, which spawns with `Stdio::null()`, or a hand-run +/// `biorouterd agent`) from *"one was, and did not"* — a fault to repair rather +/// than a deployment shape. Set unconditionally on the spawn path in +/// `ui/desktop/src/biorouterd.ts`, **including** when that path finds no key to +/// send, because that case is precisely the one worth naming. +/// +/// In the environment rather than on stdin on purpose: it is not a credential and +/// nothing is authenticated by it. A value that can only make this daemon +/// *stricter* is safe to read from a place the model can see but not write. +pub const USER_ACTION_EXPECTED_ENV: &str = "BIOROUTER_USER_ACTION_EXPECTED"; + +/// Reported by [`capability_config_moved_since_launch`] when this process never +/// recorded a launch state at all. Not a config key — a sentinel, so the caller +/// fails closed instead of reading "nothing moved". +pub const NO_LAUNCH_STATE_RECORDED: &str = ""; + +#[derive(Debug)] +struct LaunchState { + /// `(key, value at launch)` for every key in [`pinned_config_keys`]. + capability_config: Vec<(&'static str, Option)>, + /// Did whoever started this daemon say it would send a user-action key? + launcher_declared_a_user_action_key: bool, +} + +static LAUNCH: RwLock> = RwLock::new(None); + +/// The configuration keys whose value at request time must still match the value +/// this daemon started with, for SD-12's keyless exemption to apply. +/// +/// [`biorouter::privacy::CAPABILITY_CONFIG_KEYS`] **verbatim** — the same list +/// `/config/upsert` and `/config/remove` already use to decide *"is this write a +/// tier raise?"* — plus `BIOROUTER_MODEL`. +/// +/// Reusing that list rather than writing a second one is the whole point: +/// `privacy::config_keys`'s scan of the tier-input files is what keeps it honest, +/// so a key that starts deciding capability is pinned here without anyone +/// remembering to, and one that stops deciding it leaves. A hand-written second +/// list would be a third answer to a question that already has two agreeing ones. +/// +/// ⚠ **The provider name alone is not enough.** Flipping `OLLAMA_HOST` to +/// loopback moves `ollama` from Public to Private with `BIOROUTER_PROVIDER` +/// untouched (`self_hosted_tier`) — the same escalation through another key. +/// +/// `BIOROUTER_MODEL` is **not** a capability key — no `tier()` implementation +/// reads the model name — and it is pinned here for a different reason: the +/// exemption is for the operator's own declaration, and `/agent/start` binds +/// *both* halves of it (`configured_new_session_provider`). Its classification +/// lives in `privacy::config_keys::NOT_CAPABILITY_CONFIG_KEYS`. +pub fn pinned_config_keys() -> impl Iterator { + biorouter::privacy::CAPABILITY_CONFIG_KEYS + .iter() + .copied() + .chain(std::iter::once("BIOROUTER_MODEL")) +} + +fn sample_capability_config() -> Vec<(&'static str, Option)> { + let config = biorouter::config::Config::global(); + pinned_config_keys() + .map(|key| (key, config.get_param::(key).ok())) + .collect() +} + +/// Record what this daemon was launched with. +/// +/// Called ONCE from `commands::agent::run`, after the stdin digest read and +/// before `AppState::new()` — so no request can be served against an unrecorded +/// launch state, and so the sample is taken before anything in this process could +/// have written `config.yaml` itself. +/// +/// It overwrites rather than being write-once, because the integration tests that +/// exercise a keyless daemon have to stand up more than one launch posture in a +/// single binary (the config overrides are a `tokio` task-local scoped to a +/// future, so the sample must be taken inside one). Production has exactly one +/// call site. +pub fn record_launch_state(launcher_declared_a_user_action_key: bool) { + let recorded = LaunchState { + capability_config: sample_capability_config(), + launcher_declared_a_user_action_key, + }; + *LAUNCH.write().unwrap_or_else(PoisonError::into_inner) = Some(recorded); +} + +/// The first pinned key whose value no longer matches what this daemon started +/// with, or `None` while the operator's declaration is unchanged. +/// +/// ⚠ **Fails closed.** A process that never recorded a launch state reports +/// [`NO_LAUNCH_STATE_RECORDED`] rather than `None`: the one caller uses this to +/// decide whether to *skip* a privacy proof, and a missing snapshot must not read +/// as a clean one. +pub fn capability_config_moved_since_launch() -> Option { + let guard = LAUNCH.read().unwrap_or_else(PoisonError::into_inner); + let Some(state) = guard.as_ref() else { + return Some(NO_LAUNCH_STATE_RECORDED.to_string()); + }; + let config = biorouter::config::Config::global(); + state + .capability_config + .iter() + .find(|(key, at_launch)| config.get_param::(key).ok() != *at_launch) + .map(|(key, _)| (*key).to_string()) +} + +/// Did whoever started this daemon declare it would hand over a user-action key? +/// +/// Read from the recorded launch state, never from the environment at request +/// time, for the reason the whole module exists: the answer is a property of the +/// launch, not of the moment. +/// +/// `false` when nothing was recorded, which is not a fail-open reading — the +/// composite gate is closed by [`capability_config_moved_since_launch`], which +/// reports drift in exactly that situation. +pub fn expected_a_user_action_key() -> bool { + LAUNCH + .read() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .is_some_and(|state| state.launcher_declared_a_user_action_key) +} + +/// Did the launcher set [`USER_ACTION_EXPECTED_ENV`]? +/// +/// Read exactly once, by `commands::agent::run`, and then frozen into the launch +/// state. Anything other than unset, empty, `0` or `false` counts as a +/// declaration — a launcher that says anything at all here is claiming it sends a +/// key, and the stricter reading is the safe one. +pub fn launcher_declared_a_user_action_key_in_env() -> bool { + match std::env::var(USER_ACTION_EXPECTED_ENV) { + Ok(value) => { + let value = value.trim(); + !(value.is_empty() || value == "0" || value.eq_ignore_ascii_case("false")) + } + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The pinned set is the capability list plus the model, and it is *derived* + /// rather than transcribed — so this asserts the derivation, not a copy. + #[test] + fn the_pinned_set_is_the_capability_list_plus_the_model() { + let pinned: Vec<&str> = pinned_config_keys().collect(); + for key in biorouter::privacy::CAPABILITY_CONFIG_KEYS { + assert!( + pinned.contains(key), + "{key} decides capability but is not pinned to the launch configuration" + ); + } + assert!(pinned.contains(&"BIOROUTER_MODEL")); + assert_eq!( + pinned.len(), + biorouter::privacy::CAPABILITY_CONFIG_KEYS.len() + 1, + "the pinned set grew a key of its own; it must stay a derivation: {pinned:?}" + ); + } + + /// The reading that matters most: an unrecorded launch state is drift, never + /// agreement. + /// + /// Stated against the function rather than against the static, because this + /// binary's tests share one process and another may have recorded a state + /// already. + #[test] + fn an_unrecorded_launch_state_reads_as_drift() { + let unrecorded = LAUNCH + .read() + .unwrap_or_else(PoisonError::into_inner) + .is_none(); + if unrecorded { + assert_eq!( + capability_config_moved_since_launch().as_deref(), + Some(NO_LAUNCH_STATE_RECORDED) + ); + } + assert!( + !expected_a_user_action_key() || !unrecorded, + "an unrecorded launch state must not claim a key was expected" + ); + } + + #[test] + fn only_a_launcher_that_says_nothing_is_read_as_sending_no_key() { + for (value, declared) in [ + (Some("1"), true), + (Some("true"), true), + (Some("yes"), true), + (Some(""), false), + (Some("0"), false), + (Some("false"), false), + (Some("FALSE"), false), + (None, false), + ] { + let _guard = env_lock::lock_env([(USER_ACTION_EXPECTED_ENV, value)]); + assert_eq!( + launcher_declared_a_user_action_key_in_env(), + declared, + "{value:?} was read the wrong way" + ); + } + } + + /// A recorded launch state agrees with the configuration it was sampled + /// from, and disagrees the moment one of the pinned keys moves. + #[tokio::test] + async fn a_pinned_key_that_moves_after_launch_is_named() { + use biorouter::config::with_config_overrides; + use std::collections::HashMap; + + let launched_with = HashMap::from([ + ("BIOROUTER_PROVIDER".to_string(), "ollama".to_string()), + ("BIOROUTER_MODEL".to_string(), "stub-model".to_string()), + ( + "OLLAMA_HOST".to_string(), + "https://ollama.example".to_string(), + ), + ]); + with_config_overrides(launched_with.clone(), async { + record_launch_state(false); + assert_eq!(capability_config_moved_since_launch(), None); + }) + .await; + + // The provider name is untouched; only the endpoint moved — which is the + // case a name-only pin would have waved through. + let mut flipped = launched_with.clone(); + flipped.insert("OLLAMA_HOST".to_string(), "http://127.0.0.1:1".to_string()); + with_config_overrides(flipped, async { + assert_eq!( + capability_config_moved_since_launch().as_deref(), + Some("OLLAMA_HOST") + ); + }) + .await; + + let mut swapped = launched_with; + swapped.insert("BIOROUTER_PROVIDER".to_string(), "versa_azure".to_string()); + with_config_overrides(swapped, async { + assert_eq!( + capability_config_moved_since_launch().as_deref(), + Some("BIOROUTER_PROVIDER") + ); + }) + .await; + } +} diff --git a/crates/biorouter-server/src/lib.rs b/crates/biorouter-server/src/lib.rs index 37ebfc680..e902fbcc4 100644 --- a/crates/biorouter-server/src/lib.rs +++ b/crates/biorouter-server/src/lib.rs @@ -11,6 +11,9 @@ extern crate self as biorouter_server; pub mod auth; pub mod configuration; pub mod error; +// SD-12: what this daemon was LAUNCHED with. Lib-only for the same reason +// `auth` is — it holds a process global, and `routes::agent` is compiled twice. +pub mod launch; pub mod openapi; pub mod routes; pub mod state; diff --git a/crates/biorouter-server/src/routes/active_work.rs b/crates/biorouter-server/src/routes/active_work.rs index b97103e75..bac2448bf 100644 --- a/crates/biorouter-server/src/routes/active_work.rs +++ b/crates/biorouter-server/src/routes/active_work.rs @@ -7,12 +7,44 @@ //! one list so the user (via a GUI panel, deferred) can see and stop //! runaway/forgotten work. `GET /active_work` lists; `POST //! /active_work/{id}/cancel` cancels one item, dispatched by its id. +//! +//! # Whose work a caller sees (issue #56) +//! +//! Every row carries the id of the chat it belongs to and a `title`/`detail` +//! holding that chat's SHELL COMMAND or TASK PROMPT — content, not metadata. So +//! both routes ask `routes::session_reach`'s one decision about that chat: +//! +//! * the list shows a row exactly when `GET /sessions` would show its chat +//! ([`HttpCaller::lists_work`](crate::routes::session_reach::HttpCaller::lists_work)), +//! omitted and never redacted; +//! * the cancel resolves its id to the owning chat and asks the chat READ's own +//! gate ([`work_reach`](crate::routes::session_reach::work_reach)) before it +//! stops anything, and refuses with the read's exact words. +//! +//! ⚠ **Work that names no chat is answered as a private chat's**, on both +//! routes, and so is work whose chat cannot be read, a handle that names +//! nothing and a schedule that is not running: the registry cannot say whose +//! command an unattributed row holds. The shell attributes its rows from the +//! chat id Biorouter's MCP client stamps on every call, so this arm is left to +//! work that genuinely has no chat. +//! +//! ⚠ **The scheduled half is closed in `routes::schedule`, not here — and it +//! had to be.** `POST /schedule/{id}/kill` reaches the SAME `Scheduler` kill as +//! this file's cancel, by the same schedule id, so while it was ungated the gate +//! below protected nothing for its `sched:` arm: a caller refused here re-issued +//! the request one URL over. `GET /schedule/{id}/inspect` is gated on the run's +//! chat for the same reason, and `GET /schedule/list` redacts each row's +//! chat-naming fields (it redacts rather than omitting: a schedule is not a +//! chat, and an idle one names none). Both kills now also pass the chat they +//! admitted to `Scheduler::kill_running_job_in_session`, so a schedule that +//! started a different run between the decision and the kill is refused. use std::sync::Arc; use axum::{ extract::{Path, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; @@ -128,19 +160,51 @@ fn build_items( items } +/// The rows this caller may be shown: those whose chat it could open. See the +/// module header. +/// +/// One resolved caller for the whole list, so the rows cannot half-believe two +/// answers; each row's chat is looked up only when the caller is not already +/// shown every row. +async fn visible_items( + caller: &crate::routes::session_reach::HttpCaller, + manager: &biorouter::session::session_manager::SessionManager, + items: Vec, +) -> Vec { + let mut visible = Vec::with_capacity(items.len()); + for item in items { + if caller.lists_work(manager, item.session_id.as_deref()).await { + visible.push(item); + } + } + visible +} + #[utoipa::path( get, path = "/active_work", responses( - (status = 200, description = "Current background jobs, subagents, and in-flight scheduled runs", body = ActiveWorkResponse), + (status = 200, description = "Current background jobs, subagents, and in-flight scheduled \ + runs, holding only the work of the chats this caller could \ + open: a row whose chat is private, cannot be read, or that \ + names no chat at all is omitted — never redacted — for a \ + caller with neither the user-action proof nor a private \ + capability, as its chat is from `GET /sessions`", body = ActiveWorkResponse), ), tag = "active_work" )] #[axum::debug_handler] -async fn list_active_work(State(state): State>) -> Json { +async fn list_active_work( + State(state): State>, + headers: HeaderMap, +) -> Json { + // Issue #56: every row is some chat's command or prompt, and this handed + // all of them to a caller holding nothing but the daemon secret. + let caller = crate::routes::session_reach::http_caller(&headers).await; let registry = active_work().list(); let jobs = state.scheduler().list_scheduled_jobs().await; let items = build_items(registry, jobs, Utc::now()); + let items = visible_items(&caller, state.session_manager(), items).await; Json(ActiveWorkResponse { items }) } @@ -152,6 +216,13 @@ async fn list_active_work(State(state): State>) -> Json>) -> Json>, Path(id): Path, -) -> Result, StatusCode> { - match classify_cancel_id(&id) { + headers: HeaderMap, +) -> Result, Response> { + let target = classify_cancel_id(&id); + + // Issue #56: the id names WORK, not a chat. Resolve it to the chat that + // owns the work and ask the chat read's own gate BEFORE anything is + // stopped — this route stopped any chat's work for a caller holding only + // the daemon secret. Both lookups are reads; a handle that names nothing, + // and a schedule with no run in a chat, resolve to no chat at all. + let owner = match &target { + CancelTarget::Scheduler(sched_id) => state + .scheduler() + .get_running_job_info(sched_id) + .await + .ok() + .flatten() + .map(|(session_id, _)| session_id), + CancelTarget::Registry(reg_id) => { + active_work().get(reg_id).and_then(|item| item.session_id) + } + }; + crate::routes::session_reach::work_reach(state.session_manager(), owner.as_deref(), &headers) + .await + .map_err(IntoResponse::into_response)?; + + match target { CancelTarget::Scheduler(sched_id) => { state .scheduler() - .kill_running_job(&sched_id) + // Session-CHECKED: `owner` is the run the gate above admitted + // this caller to. A schedule id is stable across runs while + // `current_session_id` is not, so an unchecked kill could land + // on a run that started after the decision — see + // `Scheduler::kill_running_job_in_session`. + .kill_running_job_in_session(&sched_id, owner.as_deref()) .await .map_err(|e| match e { biorouter::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND, biorouter::scheduler::SchedulerError::AnyhowError(_) => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, - })?; + }) + .map_err(IntoResponse::into_response)?; Ok(Json(CancelActiveWorkResponse { message: format!("Requested cancel of scheduled run '{sched_id}'"), })) @@ -183,7 +284,7 @@ async fn cancel_active_work( message: format!("Requested cancel of '{reg_id}'"), })) } else { - Err(StatusCode::NOT_FOUND) + Err(StatusCode::NOT_FOUND.into_response()) } } } @@ -283,4 +384,119 @@ mod tests { CancelTarget::Registry(s) if s == "sub-7" )); } + + // ─── Issue #56: whose work a caller sees ─── + + use crate::routes::session::diverge_tests::{ + install_test_user_action_key, TEST_USER_ACTION_KEY, + }; + use crate::routes::session_reach::{http_caller, CALLER_PROVIDER_HEADER}; + use biorouter::privacy::SessionClassification; + use biorouter::session::session_manager::SessionManager; + + /// A session store of this test's own, holding one public and one private + /// chat, so no `AppState` has to be built and no other test's rows are in + /// it. The private one gets there the way a real one does, by binding a + /// private provider. + async fn store_with_a_public_and_a_private_chat( + ) -> (tempfile::TempDir, SessionManager, String, String) { + let dir = tempfile::tempdir().unwrap(); + let manager = SessionManager::new(dir.path().to_path_buf()); + let mut ids = Vec::new(); + for label in ["public", "private"] { + let session = manager + .create_session( + std::path::PathBuf::from("/tmp/active_work_reach"), + format!("Active work {label} (test fixture)"), + biorouter::session::SessionType::User, + ) + .await + .unwrap(); + ids.push(session.id); + } + manager + .update(&ids[1]) + .provider_name("versa_azure") + .model_config(biorouter::model::ModelConfig::new("gpt-4o").unwrap()) + .raise_privacy(SessionClassification::Private, "turn:versa_azure") + .apply() + .await + .unwrap(); + let private = ids.pop().unwrap(); + let public = ids.pop().unwrap(); + (dir, manager, public, private) + } + + fn owned_by(id: &str, kind: ActiveWorkKind, owner: Option<&str>) -> ActiveWorkItem { + ActiveWorkItem { + session_id: owner.map(str::to_string), + ..reg_item(id, kind, true) + } + } + + fn running_in(id: &str, owner: Option<&str>) -> ScheduledJob { + ScheduledJob { + current_session_id: owner.map(str::to_string), + ..sched_job(id, true) + } + } + + fn ids(items: Vec) -> Vec { + items.into_iter().map(|item| item.id).collect() + } + + /// The list's own filter, row by row and kind by kind — including a + /// scheduled run, whose `currently_running` only the scheduler can set, so + /// the HTTP tests in `session_reach` cannot fabricate one. + /// + /// A secret-only caller keeps exactly the rows of the public chat. The + /// private chat's rows go, and so do the rows that name no chat or a chat + /// that is not there — a schedule between starting its run and naming its + /// chat among them. The person at the keyboard and a program on a private + /// model keep everything. + #[tokio::test] + async fn the_list_shows_each_row_exactly_when_its_chat_would_be_shown() { + install_test_user_action_key(); + let (_dir, manager, public, private) = store_with_a_public_and_a_private_chat().await; + let items = build_items( + vec![ + owned_by("bg-1", ActiveWorkKind::BackgroundJob, Some(public.as_str())), + owned_by("sub-2", ActiveWorkKind::Subagent, Some(private.as_str())), + owned_by("fg-3", ActiveWorkKind::ForegroundCommand, None), + owned_by( + "dturn-4", + ActiveWorkKind::DetachedTurn, + Some("29990101_99999"), + ), + ], + vec![ + running_in("hourly", Some(public.as_str())), + running_in("nightly", Some(private.as_str())), + running_in("starting", None), + ], + Utc::now(), + ); + let every_id = ids(items.clone()); + + let secret_only = http_caller(&HeaderMap::new()).await; + assert_eq!( + ids(visible_items(&secret_only, &manager, items.clone()).await), + ["bg-1", "sched:hourly"], + "a caller holding only the daemon secret must be shown the public chat's work and \ + nothing else" + ); + + let mut proof = HeaderMap::new(); + proof.insert("X-User-Action", TEST_USER_ACTION_KEY.parse().unwrap()); + let mut private_model = HeaderMap::new(); + private_model.insert(CALLER_PROVIDER_HEADER, "versa_azure".parse().unwrap()); + for headers in [proof, private_model] { + let caller = http_caller(&headers).await; + assert_eq!( + ids(visible_items(&caller, &manager, items.clone()).await), + every_id, + "{headers:?} lost a row it could open" + ); + } + } } diff --git a/crates/biorouter-server/src/routes/agent.rs b/crates/biorouter-server/src/routes/agent.rs index dbbe25fa9..678e53020 100644 --- a/crates/biorouter-server/src/routes/agent.rs +++ b/crates/biorouter-server/src/routes/agent.rs @@ -79,7 +79,7 @@ const SUBAGENT_USER_ACTION_REQUIRED: &str = /// 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::authorize_steer`, which +/// 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 = @@ -186,7 +186,7 @@ async fn read_update_session( /// 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::authorize_steer` keeps the proof on every daemon, +/// 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, @@ -383,6 +383,117 @@ fn configured_new_session_provider() -> Result, Er } } +/// Why a brand-new chat's bind to the operator's configured private provider may +/// not go ahead as it stands — SD-12's verdict, with the reason, because two of +/// the three refusals here are actionable by a *person* and a single boolean +/// would have answered all of them in a sentence written for a model. +#[derive(Debug, Clone, PartialEq, Eq)] +enum NewChatBind { + /// Bind it. + Allowed, + /// This daemon can check a proof, and none arrived. + NeedsUserProof, + /// SD-12 would exempt this bind, but the configuration being read is no + /// longer the one the daemon was launched with, so there is nothing left in + /// it that is the operator's choice. Carries the key that moved. + ConfigMovedSinceLaunch(String), + /// The launcher declared it would hand over a user-action key and the key + /// never arrived. A fault to repair, not a deployment where no proof can + /// exist — so the exemption does not apply. + KeyWasExpected, +} + +/// SD-12 (`docs/deployment/serve-decisions.md`): does binding the operator's +/// configured default provider to a brand-new chat need a person's proof? +/// +/// A new chat has no capability of its own yet, so a private default reads as a +/// raise from Public — the reading `update_agent_provider` gives any first bind. +/// What differs is who chose the model: `/agent/start` names no provider, it binds +/// `BIOROUTER_PROVIDER` + `BIOROUTER_MODEL`, and SD-1 says the tier the operator's +/// `biorouter configure` choice implies holds for every session in that daemon. So +/// the proof is asked for only where it can be given: +/// +/// * `Proven` — the desktop renderer, which sends `X-User-Action` on every start +/// (`ui/desktop/src/sessions.ts`). Binds. +/// * `Unproven` — a daemon that holds a key, and a caller that did not present +/// it: a script, or the model holding the daemon secret AR-11 found +/// recoverable. Refused, as before. The person proves themselves here at no +/// cost, and without the refusal a model could mint a private-capability chat +/// with an extension set of its own choosing. +/// * `NoKeyInstalled` — `biorouter serve` (SD-7) or a hand-run `biorouterd`. +/// Nobody on this daemon can prove anything, so a refusal here refuses the +/// person too, on every new chat, always — the 2026-09-10 QA's F1. Binds, under +/// the two conditions below. +/// +/// ⚠ **The original justification for that third arm was wrong, and both +/// conditions exist because of it** (2026-09-12 review, Findings 1 and 3): +/// +/// 1. It said `BIOROUTER_PROVIDER` is *"a key only a proven person may write"*. +/// The HTTP doors are closed, but `config.yaml` is not an HTTP resource: +/// DR-14's filesystem deny is DEFERRED, the agent holds `developer__shell`, and +/// `Config` re-`stat`s and reloads the file — so the value read here is +/// whatever the file says at request time. A model with a shell on a keyless +/// daemon configured with a *public* default could write a private provider +/// there and take this exemption. So the exemption is pinned to the +/// configuration the daemon **launched** with (`launch::pinned_config_keys`); +/// drift refuses, and names the key. +/// 2. `NoKeyInstalled` is only *"this process read no valid digest"*, which a +/// desktop spawn also satisfies when its `userActionKey` is undefined or the +/// daemon's bounded 2s stdin read times out. That is a repairable fault, and on +/// `main` it degraded safely by refusing. It keeps refusing: the launcher +/// declares its intent (`launch::USER_ACTION_EXPECTED_ENV`), which is the only +/// signal that separates the two. +/// +/// Only the configured default is ever exempt, and only at creation: +/// `/agent/start` cannot name another provider, and on a keyless daemon +/// `update_agent_provider` refuses every private bind ([`raise_baseline`]), so a +/// new chat there never reaches a private model the operator did not choose. +fn new_chat_bind_decision( + enforced: bool, + tier: ProviderTier, + proof: UserActionProof, + launcher_declared_a_key: bool, + config_moved_since_launch: Option, +) -> NewChatBind { + // DR-15's master opt-out, and the plain reading that a public default raises + // nothing for anybody. Neither is about who is asking. + if !enforced || !raise_needs_user_action(ProviderTier::Public, tier) { + return NewChatBind::Allowed; + } + match proof { + UserActionProof::Proven => NewChatBind::Allowed, + UserActionProof::Unproven => NewChatBind::NeedsUserProof, + UserActionProof::NoKeyInstalled => { + if launcher_declared_a_key { + NewChatBind::KeyWasExpected + } else if let Some(key) = config_moved_since_launch { + NewChatBind::ConfigMovedSinceLaunch(key) + } else { + NewChatBind::Allowed + } + } + } +} + +/// The capability `update_agent_provider` measures a raise from — SD-12's other +/// half. +/// +/// On a daemon that holds a user-action key it is the chat's live capability, +/// as it always was. On one that holds none, no chat's private capability came +/// from anything a person proved over HTTP: the only private binding such a +/// daemon hands out through its routes is [`new_chat_bind_decision`]'s +/// creation-time bind to the configured default. There is no private floor for +/// a request to build on, so a bind to ANY private provider is measured from +/// Public, and refused, since no proof can arrive. Without this, SD-12 would let +/// a new chat on a private default be moved sideways, `Private -> Private`, to a +/// private model nobody configured. +fn raise_baseline(current: ProviderTier, proof: UserActionProof) -> ProviderTier { + match proof { + UserActionProof::NoKeyInstalled => ProviderTier::Public, + UserActionProof::Proven | UserActionProof::Unproven => current, + } +} + async fn bind_new_session_provider( state: &AppState, session: &Session, @@ -397,17 +508,54 @@ async fn bind_new_session_provider( message: format!("Failed to configure the selected provider for the new chat: {error}"), status: StatusCode::BAD_REQUEST, })?; - if biorouter::privacy::privacy_tiers_enabled() - && raise_needs_user_action(ProviderTier::Public, provider.tier()) - && !is_user_action(headers) - { - return Err(ErrorResponse { - message: PrivacyRefusal::TierRaiseNeedsUser { - requested: provider_name, - } - .to_string(), - status: StatusCode::CONFLICT, - }); + // DR-15's master opt-out is read inside the gate, as every #56 surface does. + // The launch state is read here rather than in the gate for the same reason: + // one sample per request, threaded, so the decision cannot be made against two + // different answers. + match new_chat_bind_decision( + biorouter::privacy::privacy_tiers_enabled(), + provider.tier(), + user_action_proof(headers), + biorouter_server::launch::expected_a_user_action_key(), + biorouter_server::launch::capability_config_moved_since_launch(), + ) { + NewChatBind::Allowed => {} + NewChatBind::NeedsUserProof => { + return Err(ErrorResponse { + message: PrivacyRefusal::TierRaiseNeedsUser { + requested: provider_name, + } + .to_string(), + status: StatusCode::CONFLICT, + }); + } + // Written for the operator, not for the model. SD-8's rule: a control the + // caller cannot pass must say what would make it passable, and here that + // is a restart — no proof exists on this daemon to offer instead. + NewChatBind::ConfigMovedSinceLaunch(key) => { + return Err(ErrorResponse { + message: format!( + "This Biorouter daemon starts new chats on the model it was launched with, \ + because nothing here can confirm a request came from you. '{key}' has \ + changed in the configuration since it started, so '{provider_name}' is not \ + the model it was launched on and starting a chat on it would be a switch \ + nobody asked for. Restart the daemon to pick up the new configuration." + ), + status: StatusCode::CONFLICT, + }); + } + NewChatBind::KeyWasExpected => { + return Err(ErrorResponse { + message: format!( + "Biorouter cannot confirm that this request came from you: the application \ + that started this daemon was meant to hand it a user-action key and none \ + arrived, so a new chat on the private model '{provider_name}' is refused \ + rather than started. Quit and reopen Biorouter. If it keeps happening, the \ + daemon log records 'no user-action key on stdin'." + ), + status: StatusCode::CONFLICT, + }); + } } let agent = state .get_agent(session.id.clone()) @@ -661,7 +809,7 @@ pub struct RestartAgentResponse { (status = 200, description = "Agent started successfully", body = Session), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized - invalid secret key"), - (status = 409, description = "The selected private provider requires user-action proof", body = ErrorResponse), + (status = 409, description = "The configured provider is private and this daemon will not bind it to a new chat as things stand (SD-12). Either the daemon holds a user-action key and the request carried no proof it came from the user; or it holds none but its launcher declared it would send one, so the missing key is a fault rather than a deployment where no proof can exist; or it holds none and a capability-deciding configuration key has changed since it started, in which case the message names the key and asks for a restart. A daemon with no user-action key, launched without that declaration, binds the provider it was launched with and needs no proof.", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ) )] @@ -749,7 +897,18 @@ async fn start_agent( }; if let Some(workflow) = original_workflow.as_ref() { - apply_workflow_knowledge_selection(&state.knowledge_service, &session.id, workflow)?; + // The siblings' shape, and for the same reason. A bare `?` here left the + // chat this function had just created sitting in the session list — a + // row the user never asked for and cannot explain. It is not a rare + // race, either: a workflow whose `default` names a base that has since + // been deleted fails here on EVERY start, so a stale workflow minted one + // orphan per press. + if let Err(error) = + apply_workflow_knowledge_selection(&state.knowledge_service, &session.id, workflow) + { + discard_failed_new_session(&state, &session.id).await; + return Err(error); + } } let workflow_extensions = original_workflow @@ -1156,6 +1315,10 @@ async fn update_from_session( responses( (status = 200, description = "Tools retrieved successfully", body = Vec), (status = 401, description = "Unauthorized - invalid secret key"), + (status = 403, description = "Refused by a privacy boundary: `session_id` names a chat \ + this caller may not reach, answered with the same refusal, \ + word for word, that `GET /sessions/{session_id}` gives \ + (body = plain text)"), (status = 408, description = "Extension timed out while loading for settings"), (status = 424, description = "Agent not initialized"), (status = 500, description = "Internal server error") @@ -1164,6 +1327,34 @@ async fn update_from_session( async fn get_tools( State(state): State>, Query(query): Query, + headers: axum::http::HeaderMap, +) -> axum::response::Response { + // Issue #56, QA 2026-09-10 M2. Naming a private chat here handed a caller + // holding only the daemon secret that chat's private-extension tool names, + // while `add_extension` on the same chat refused it — and, worse, `get_agent` + // below MINTS an agent for the named chat, loading its extensions, on that + // caller's say-so. So the read's own gate runs first. The comment further + // down, about Gate E, is about which tools a MODEL is shown; this is about + // whether the CALLER may address the chat at all, and the empty id — the + // settings page's one global extension — names no chat and is not gated. + if !query.session_id.is_empty() { + if let Err(refusal) = crate::routes::session_reach::session_reach( + state.session_manager(), + &query.session_id, + &headers, + ) + .await + { + return refusal.into_response(); + } + } + permission_editor_tools(state, query).await.into_response() +} + +/// The body of [`get_tools`], once the caller may address the named chat. +async fn permission_editor_tools( + state: Arc, + query: GetToolsQuery, ) -> Result>, StatusCode> { let config = Config::global(); let biorouter_mode = config.get_biorouter_mode().unwrap_or(BioRouterMode::Auto); @@ -1290,11 +1481,9 @@ async fn get_tools( responses( (status = 200, description = "Model-visible callable tool count", body = CallableToolCountResponse), (status = 401, description = "Unauthorized - invalid secret key"), - (status = 403, description = "Refused by a privacy boundary (issue #56 Task 58 / #47): \ - the named chat is private (or absent, and an unproven caller \ - is told the same thing for both) and the request carried \ - neither a capability that covers it nor proof it came from \ - the user"), + (status = 403, description = "Refused by a privacy boundary: the same refusal, word for \ + word, that `GET /sessions/{session_id}` gives (body = plain \ + text)"), (status = 424, description = "Agent not initialized") ) )] @@ -1304,20 +1493,44 @@ async fn get_callable_tool_count( // order the rest of this file uses. headers: axum::http::HeaderMap, Query(query): Query, -) -> Result, ErrorResponse> { - let session_id = query.session_id; - // Issue #56 Task 58 / #47. FIRST, before the agent is fetched, for the reason - // `agent_add_extension` states at length: `get_agent_for_route` CREATES an - // agent for a session that has none, so a gate below it would let an unproven - // caller materialise one for a chat it may not address — and this route's own - // 424 would then tell it what it had found. `session_id` is a request - // parameter, not a credential; see `routes::session_reach`. +) -> axum::response::Response { + // Issue #56 Task 58 / #47, and QA 2026-09-10 M2's sibling. FIRST, before the + // agent is fetched, for the reason `agent_add_extension` states at length: + // `get_agent_for_route` CREATES an agent for a session that has none, so a + // gate below it would let an unproven caller materialise one for a chat it may + // not address — and this route's own 424 would then tell it what it had found. + // `session_id` is a request parameter, not a credential; see + // `routes::session_reach`. // // ⚠ This route had NO gate of any kind, and PR #260's renderer merely stopped // calling it for a subagent's chat, which left the route exactly as open as // it was. Routing a client around an ungated route does not gate it. - crate::routes::session_reach::session_reach(state.session_manager(), &session_id, &headers) - .await?; + // + // ⚠ **The refusal is returned through `SessionOutOfReach`'s own + // `IntoResponse` — PLAIN TEXT, the bytes `GET /sessions/{session_id}` + // returns — and deliberately NOT with `?` through this route's + // `ErrorResponse`, which would wrap the same words in a JSON envelope.** One + // boundary has one body (see the module header of `routes::session_reach`), + // and that is the only reason the gate lives in this wrapper and the work + // lives in the function below rather than all in one body. + if let Err(refusal) = crate::routes::session_reach::session_reach( + state.session_manager(), + &query.session_id, + &headers, + ) + .await + { + return refusal.into_response(); + } + model_visible_tool_count(state, query).await.into_response() +} + +/// The body of [`get_callable_tool_count`], once the caller may address the chat. +async fn model_visible_tool_count( + state: Arc, + query: CallableToolCountQuery, +) -> Result, ErrorResponse> { + let session_id = query.session_id; let child_initializing = biorouter::agents::subagent_handle::is_child_initializing(&session_id); let agent = if child_initializing { state @@ -1365,7 +1578,9 @@ async fn get_callable_tool_count( a public model cannot be bound to a private chat \ (body = PrivacyBarrierBody). DR-16: the bind raises this \ chat's capability to Private and the request carried no \ - proof it came from the user (body = plain text)", + proof it came from the user; on a daemon with no \ + user-action key, any bind to a private model (SD-12) \ + (body = plain text)", body = PrivacyBarrierBody), (status = 424, description = "Agent not initialized"), (status = 500, description = "Internal server error") @@ -1438,6 +1653,10 @@ async fn update_agent_provider( // DR-16 rejected. Sideways and downward binds are untouched for every // caller, which is what keeps Gate A's path, the CLI, // `restore_provider_from_session` and every apps-runtime bind working. + // The one exception is this route on a daemon with no user-action key, + // where a move onto a private model is measured from Public however the + // chat is bound today — `raise_baseline`, SD-12. The predicate itself is + // unchanged, and none of the in-process binds above passes through here. // // An unbound session reads as Public — `Agent::provider` errors when nothing // is bound (and when Gate B' refuses what is), and the conservative reading @@ -1448,11 +1667,13 @@ async fn update_agent_provider( .await .map(|p| p.tier()) .unwrap_or(ProviderTier::Public); + // SD-12's other half — see `raise_baseline`. + let baseline = raise_baseline(current, user_action_proof(&headers)); // DR-15's master opt-out, read INSIDE the gate. A direct read, not a // `CallCapability`: a provider raise over HTTP is not a tool call and has no // admitted capability to inherit. if biorouter::privacy::privacy_tiers_enabled() - && raise_needs_user_action(current, new_provider.tier()) + && raise_needs_user_action(baseline, new_provider.tier()) && !is_user_action(&headers) { return Err(( @@ -3231,6 +3452,159 @@ mod new_session_provider_binding_tests { .await .unwrap(); } + + /// SD-12, every proof verdict against both tiers. The keyless arm cannot be + /// reached through a route in this binary — the installed digest is a + /// process-global `OnceLock` and the test above installs one — so the route + /// half lives in `tests/new_chat_no_user_key.rs`, a binary that never does. + #[test] + fn only_a_daemon_that_can_check_a_proof_asks_a_new_chat_for_one() { + use UserActionProof::{NoKeyInstalled, Proven, Unproven}; + // A daemon launched on this configuration, by a launcher that promised no + // key: the posture SD-12's exemption is for. + let launched_here = |tier, proof| new_chat_bind_decision(true, tier, proof, false, None); + assert_eq!( + launched_here(ProviderTier::Private, Proven), + NewChatBind::Allowed + ); + assert_eq!( + launched_here(ProviderTier::Private, Unproven), + NewChatBind::NeedsUserProof + ); + assert_eq!( + launched_here(ProviderTier::Private, NoKeyInstalled), + NewChatBind::Allowed, + "a keyless daemon refusing its own configured default refuses every person, always" + ); + for proof in [Proven, Unproven, NoKeyInstalled] { + // A public default raises nothing, for anyone. + assert_eq!( + launched_here(ProviderTier::Public, proof), + NewChatBind::Allowed + ); + // DR-15's master opt-out turns the gate off, not the question. + assert_eq!( + new_chat_bind_decision(false, ProviderTier::Private, proof, false, None), + NewChatBind::Allowed + ); + } + } + + /// Finding 1 (HIGH) of the 2026-09-12 review: the keyless exemption is for + /// the configuration the daemon was **launched** with, not for whatever + /// `config.yaml` — which the agent can write and `Config` reloads live — says + /// at request time. + /// + /// Both conditions bite only on the keyless arm. A daemon that can check a + /// proof already has one, and a person who edits the configuration and asks + /// for a chat in the same breath is not the case this closes. + #[test] + fn drift_since_launch_costs_the_keyless_exemption_and_nothing_else() { + use UserActionProof::{NoKeyInstalled, Proven, Unproven}; + let moved = || Some("BIOROUTER_PROVIDER".to_string()); + + assert_eq!( + new_chat_bind_decision(true, ProviderTier::Private, NoKeyInstalled, false, moved()), + NewChatBind::ConfigMovedSinceLaunch("BIOROUTER_PROVIDER".to_string()), + "a private provider written into config.yaml after launch took the exemption" + ); + // The refusal names the key, because the only person who can act on it + // needs to know which value to put back or which daemon to restart. + assert_eq!( + new_chat_bind_decision( + true, + ProviderTier::Private, + NoKeyInstalled, + false, + Some("OLLAMA_HOST".to_string()), + ), + NewChatBind::ConfigMovedSinceLaunch("OLLAMA_HOST".to_string()), + ); + // A public default is not a raise, so drift changes nothing about it — + // there is no exemption being taken to withdraw. + assert_eq!( + new_chat_bind_decision(true, ProviderTier::Public, NoKeyInstalled, false, moved()), + NewChatBind::Allowed + ); + // And a daemon that can check a proof is unaffected in both directions. + assert_eq!( + new_chat_bind_decision(true, ProviderTier::Private, Proven, true, moved()), + NewChatBind::Allowed + ); + assert_eq!( + new_chat_bind_decision(true, ProviderTier::Private, Unproven, true, moved()), + NewChatBind::NeedsUserProof + ); + } + + /// Finding 3 (LOW): `NoKeyInstalled` is two situations wearing one name, and + /// only one of them is a deployment where no proof can exist. A desktop daemon + /// whose key never arrived keeps `main`'s refusal. + /// + /// ⚠ The launcher's declaration is checked **before** the drift, so the + /// message the person gets names the fault they can act on rather than a + /// configuration key that may be perfectly fine. + #[test] + fn a_launcher_that_promised_a_key_does_not_inherit_the_keyless_exemption() { + use UserActionProof::{NoKeyInstalled, Proven, Unproven}; + assert_eq!( + new_chat_bind_decision(true, ProviderTier::Private, NoKeyInstalled, true, None), + NewChatBind::KeyWasExpected + ); + assert_eq!( + new_chat_bind_decision( + true, + ProviderTier::Private, + NoKeyInstalled, + true, + Some("BIOROUTER_PROVIDER".to_string()), + ), + NewChatBind::KeyWasExpected, + "a missing key is the actionable fault; the drift message would send the person to the \ + wrong place" + ); + // The declaration says nothing about a daemon that DID get its key: those + // two arms are decided by the proof alone. + for proof in [Proven, Unproven] { + assert_eq!( + new_chat_bind_decision(true, ProviderTier::Private, proof, true, None), + new_chat_bind_decision(true, ProviderTier::Private, proof, false, None), + ); + } + // Nor about a public default, which raises nothing. + assert_eq!( + new_chat_bind_decision(true, ProviderTier::Public, NoKeyInstalled, true, None), + NewChatBind::Allowed + ); + } + + /// SD-12's other half: a keyless daemon measures every move onto a private + /// model from Public, so its exemption for the configured default cannot be + /// carried sideways to a private model nobody configured. + #[test] + fn a_keyless_daemon_has_no_private_floor_for_a_switch_to_build_on() { + use UserActionProof::{NoKeyInstalled, Proven, Unproven}; + for current in [ProviderTier::Private, ProviderTier::Public] { + assert_eq!( + raise_baseline(current, NoKeyInstalled), + ProviderTier::Public + ); + // A daemon that can check a proof keeps measuring from the live binding. + assert_eq!(raise_baseline(current, Proven), current); + assert_eq!(raise_baseline(current, Unproven), current); + } + // The composition `update_agent_provider` asks. Sideways onto a private + // model is a raise only where no proof can be checked. + let sideways = |proof| { + raise_needs_user_action( + raise_baseline(ProviderTier::Private, proof), + ProviderTier::Private, + ) + }; + assert!(sideways(NoKeyInstalled)); + assert!(!sideways(Unproven)); + assert!(!sideways(Proven)); + } } #[cfg(test)] @@ -5711,4 +6085,79 @@ mod knowledge_selection_tests { ); assert_eq!(selection.primary_kb.as_deref(), Some("alpha")); } + + /// Every step that can fail while the new chat already exists, but before it + /// is returned, discards it. An orphan chat is a row the user never asked + /// for and cannot explain, and the knowledge apply was the one step that + /// left one: it used a bare `?` where its two siblings take the error, call + /// `discard_failed_new_session`, and only then return. + /// + /// ⚠ It was not a rare race. A workflow whose `default` names a base that + /// has since been deleted fails here on EVERY start, so a stale workflow + /// minted one orphan per press. + /// + /// **A source read, deliberately.** Reaching the real handler needs an + /// `AppState`, and `AppState::new` calls `AgentManager::instance()` and + /// `KnowledgeService::new_default()` — both of which resolve the developer's + /// own `~/.config/biorouter`. A test that creates and deletes chats there is + /// worse than no test. The shape is what the defect was, so the shape is + /// what is asserted. + /// + /// ⚠ **Scope.** The `?` sites *after* this window — the two + /// `manager.update(...)` calls and the refetch — orphan a chat too and are + /// deliberately not covered: each of those is the session store itself + /// failing, where the discard's own `delete_session` would be failing for + /// the same reason, and deciding what to do there is a separate question. + /// Named here so the next reader knows they were seen, not missed. + #[test] + fn every_failure_before_a_new_chat_is_returned_discards_it() { + let source = include_str!("agent.rs"); + // ⚠ The leading newline is load-bearing: `include_str!` reads this file + // including this test, so an anchor without it matches the copy inside + // this very string literal and slices the test instead of the handler. + let body = source + .split("\nasync fn start_agent(") + .nth(1) + .and_then(|rest| rest.split("\n}\n").next()) + .expect("start_agent production body"); + + // In source order. Each step's window runs to the next one, so the + // assertion is "between one fallible step and the next, the error path + // discards the chat" rather than a count that a fourth step could pass + // without being looked at. + const STEPS: [&str; 3] = [ + "bind_new_session_provider(", + "runtime::prepare_prompt(", + "apply_workflow_knowledge_selection(", + ]; + + let at = |needle: &str| { + body.find(needle) + .unwrap_or_else(|| panic!("`{needle}` is a step of start_agent")) + }; + for (index, step) in STEPS.iter().enumerate() { + let start = at(step); + let end = STEPS.get(index + 1).map_or(body.len(), |next| at(next)); + // `get`, not `&body[start..end]`: the workspace denies + // `clippy::string_slice`, and it is right to — a slice that is not on + // a char boundary panics. Both bounds come from `find`, so they are + // boundaries and this never fires. + let window = body + .get(start..end) + .expect("both bounds come from `find`, so both are char boundaries"); + let discarded = window + .find("discard_failed_new_session") + .unwrap_or_else(|| { + panic!( + "`{step}` can return an error without discarding the chat it leaves behind" + ) + }); + if let Some(returned) = window.find("return Err") { + assert!( + discarded < returned, + "`{step}` returns its error before discarding the chat" + ); + } + } + } } diff --git a/crates/biorouter-server/src/routes/config_management.rs b/crates/biorouter-server/src/routes/config_management.rs index 0c2861e06..ede336862 100644 --- a/crates/biorouter-server/src/routes/config_management.rs +++ b/crates/biorouter-server/src/routes/config_management.rs @@ -1020,6 +1020,32 @@ pub async fn providers() -> Result>, StatusCode> { Ok(Json(providers_response)) } +/// The models a provider declares, for the case where it has no live fetch. +/// +/// Named and separate because it is the answer the route gives most often, and +/// it used to be `Vec::new()`. +/// +/// Measured 2026-09-11 over the 23 registered builtins: **9 do not override +/// `fetch_supported_models`**, so `base.rs`'s `Ok(None)` default is what this +/// route receives for every one of them — and **all nine declare a catalog** the +/// settings grid renders on screen: `azure_openai` 12, `aws_bedrock` 7, +/// `versa_azure` 9, `versa_bedrock` 5, `xai` 9, `snowflake` 8, `zai` 8, +/// `xiaomi_mimo` 4, `sagemaker_tgi` 1. So the route reported "no models" for nine +/// providers that have between one and twelve, under a `200 Models fetched +/// successfully`. +/// +/// ⚠ Do not measure this by grepping for `with_models`. That was the first +/// instrument tried here and it gave 6 of 9, because `ProviderMetadata::new` also +/// takes a `model_names` list — `snowflake`, `zai` and `sagemaker_tgi` looked +/// catalogless and are not. Read `known_models` off the live metadata instead. +fn declared_model_names(metadata: &biorouter::providers::base::ProviderMetadata) -> Vec { + metadata + .known_models + .iter() + .map(|model| model.name.clone()) + .collect() +} + /// One row of `GET /config/providers`. async fn provider_details( metadata: ProviderMetadata, @@ -1104,7 +1130,33 @@ pub async fn get_provider_models( match models_result { Ok(Some(models)) => Ok(Json(models)), - Ok(None) => Ok(Json(Vec::new())), + // ⚠ **`None` means "this provider has no LIVE fetch", not "this provider + // has no models"** — and answering `[]` said the second. Nine of the + // twenty-three builtins do not override `fetch_supported_models`, so its + // `Ok(None)` default reached here; ALL NINE declare a catalog the + // settings grid visibly renders — measured off `known_models`, not by + // grepping `with_models`, which undercounts by three (see + // `declared_model_names`). The + // route was therefore reporting an empty model list for a provider whose + // models were on screen, under a name and a `200 Models fetched + // successfully` that both promise the model list. + // + // Answering from the declared catalog is not a new policy, it is the one + // already in force twice over. The declarative-provider branch at the top + // of this very function returns `config.models` with no live fetch at all; + // and the desktop's `fetchModelsForProviders` prefers + // `metadata.known_models` and only falls back to this route when a + // provider has none. So the fallback WAS the right answer, implemented in + // the one place that could not help the CLI, an agent, or anything reading + // the OpenAPI spec. + // + // Naming was the alternative, and it was rejected: renaming or + // redescribing the route regenerates `openapi.json` and the TS client (the + // `Generated API contract` check), and would still leave every caller + // holding an empty list for a provider that has models. The shape is + // unchanged here — same path, same params, same `Vec` body — so no + // client needs regenerating. + Ok(None) => Ok(Json(declared_model_names(&metadata))), Err(provider_error) => { let status_code = match provider_error { // Permanent misconfigurations - client should fix configuration @@ -1869,6 +1921,74 @@ mod tests { use super::*; + /// `GET /config/providers/{name}/models` is named and documented as the model + /// list, and for nine builtins it answered `[]`. + /// + /// ⚠ The cause is a default, not a failure: `fetch_supported_models` returns + /// `Ok(None)` unless a provider overrides it, and 9 of the 23 registered + /// builtins do not override it. Every one of those nine declares a catalog the + /// settings grid renders, so the route reported "no models" for a provider + /// whose models the user could see on screen — under a `200 Models fetched + /// successfully`. + /// + /// The nine are named rather than derived, because deriving them needs a live + /// instance of each: credentials, and a network call for the ones that do + /// fetch. If one grows a live fetch later it leaves the `Ok(None)` arm and its + /// row here becomes redundant rather than wrong. + #[tokio::test] + async fn a_provider_with_no_live_fetch_reports_the_models_it_declares() { + let all = biorouter::providers::providers().await; + let mut checked = 0; + for name in [ + "azure_openai", + "aws_bedrock", + "versa_azure", + "versa_bedrock", + "xai", + "xiaomi_mimo", + "snowflake", + "zai", + "sagemaker_tgi", + ] { + let Some((metadata, _)) = all.iter().find(|(m, _)| m.name == name) else { + // `aws_bedrock`, `versa_bedrock` and `sagemaker_tgi` are behind + // the `aws-providers` feature. + continue; + }; + assert!( + !metadata.known_models.is_empty(), + "{name} declares a catalog — that is what made `[]` a false answer" + ); + assert_eq!( + declared_model_names(metadata), + metadata + .known_models + .iter() + .map(|model| model.name.clone()) + .collect::>(), + "{name} must report exactly what it declares, in order" + ); + checked += 1; + } + assert!(checked >= 6, "only {checked} of the nine were reachable"); + } + + /// …and nothing is invented for a provider that declares nothing. `litellm` + /// and `ollama` are the two builtins with an empty catalog; both have a live + /// fetch, so neither reaches the arm above — but the projection has to be + /// faithful in that direction too, or the fix reads as "always non-empty". + #[tokio::test] + async fn an_empty_catalog_projects_to_an_empty_list() { + let all = biorouter::providers::providers().await; + for name in ["litellm", "ollama"] { + let Some((metadata, _)) = all.iter().find(|(m, _)| m.name == name) else { + continue; + }; + assert!(metadata.known_models.is_empty(), "{name} declares none"); + assert!(declared_model_names(metadata).is_empty(), "{name}"); + } + } + /// A recovery that could not write says so in BOTH halves of its answer. /// /// The route reported plain success for a config it had just failed to diff --git a/crates/biorouter-server/src/routes/knowledge.rs b/crates/biorouter-server/src/routes/knowledge.rs index 46251e6bf..1f15ee648 100644 --- a/crates/biorouter-server/src/routes/knowledge.rs +++ b/crates/biorouter-server/src/routes/knowledge.rs @@ -34,15 +34,16 @@ use utoipa::ToSchema; /// Build the knowledge router. The router owns an `Arc` directly so /// it can be tested without constructing a full `AppState`. +/// +/// ⚠ **Every route that names a base by `{id}` lives in `base_routes`, and +/// nothing else does.** That sub-router carries +/// `session_reach::gate_knowledge_base` as a `route_layer`, so each of its +/// routes — and any added to it later — answers a caller who may not reach the +/// named base with the same refusal before its handler runs (issue #56, QA +/// 2026-09-10 H2). A route that names a base and is registered on the outer +/// router instead is ungated: put it here. pub fn router(svc: Arc) -> Router { - Router::new() - .route("/bases", get(list_bases).post(create_base)) - .route( - "/bases/import", - post(import_brkb).layer(DefaultBodyLimit::max( - biorouter_mcp::knowledge::brkb::MAX_ARCHIVE_HTTP_BODY_BYTES, - )), - ) + let base_routes = Router::new() .route( "/bases/{id}", get(get_base).put(update_base).delete(delete_base), @@ -60,7 +61,6 @@ pub fn router(svc: Arc) -> Router { .route("/bases/{id}/history", get(list_history)) .route("/bases/{id}/preview", post(preview_state)) .route("/bases/{id}/restore", post(restore_state)) - .route("/expand-path", post(expand_path)) .route("/bases/{id}/raw", post(add_raw_source)) .route("/bases/{id}/ingest", post(ingest)) .route("/bases/{id}/ingest-conversation", post(ingest_conversation)) @@ -73,8 +73,23 @@ pub fn router(svc: Arc) -> Router { "/bases/{id}/sources/{sid}/credibility", put(override_credibility), ) + .route_layer(axum::middleware::from_fn_with_state( + svc.clone(), + crate::routes::session_reach::gate_knowledge_base, + )); + + Router::new() + .route("/bases", get(list_bases).post(create_base)) + .route( + "/bases/import", + post(import_brkb).layer(DefaultBodyLimit::max( + biorouter_mcp::knowledge::brkb::MAX_ARCHIVE_HTTP_BODY_BYTES, + )), + ) + .route("/expand-path", post(expand_path)) .route("/active", get(get_active).post(set_active)) .route("/check-model", post(check_model)) + .merge(base_routes) .with_state(svc) } @@ -440,8 +455,13 @@ pub struct LintBody { /// store already answers — and it would also appear on `kb_list_bases`, a /// model-facing tool whose payload Task 10D's metadata register governs. /// -/// This route is user-facing: the renderer is the only caller, and Task 10C -/// already removes private bases from the model's own listing entirely. +/// ⚠ **"The renderer is the only caller" was this doc's premise, and QA +/// measured it false on 2026-09-10 (H2):** a public chat's shell recovered the +/// daemon secret and read this list, private bases included. So the rows are +/// now the bases the caller could open — the desktop app, which sends the +/// user's proof, still sees every one, with its tier — and a private base is +/// OMITTED for anyone else, as Task 10C already omits it from the model's own +/// listing: a base's id and name are user-authored content. #[derive(Serialize, ToSchema)] pub struct KbListEntry { #[serde(flatten)] @@ -451,17 +471,28 @@ pub struct KbListEntry { #[utoipa::path( get, path = "/knowledge/bases", - responses((status = 200, description = "List of knowledge bases", body = Vec)) + responses((status = 200, description = "The knowledge bases this caller may open: every base \ + for the desktop app (the user-action proof) or a \ + caller stating a private provider, the public ones \ + for anyone else. A private base is omitted, never \ + redacted.", body = Vec)) )] pub async fn list_bases( State(svc): State>, + headers: HeaderMap, ) -> Result>, (StatusCode, String)> { + let caller = crate::routes::session_reach::http_caller(&headers).await; let bases = svc .list_bases() .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json( bases .into_iter() + .filter(|manifest| { + caller + .reach_knowledge_base(svc.root(), &manifest.id) + .is_ok() + }) .map(|manifest| KbListEntry { tier: tier::entry(svc.root(), &manifest.id).tier, manifest, @@ -1115,19 +1146,28 @@ pub struct GetActiveQuery { pub session_id: Option, } -fn selection_response( - svc: &KnowledgeService, - session_id: Option<&str>, -) -> Result { - let selection = svc - .selection(session_id) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?; - Ok(ActiveKbResponse { - kb_ids: selection.kb_ids, - active_kb: selection.primary_kb.clone(), - primary_kb: selection.primary_kb, - hidden_kbs: selection.hidden_kbs, - }) +/// A selection as THIS caller may see it (issue #56, QA 2026-09-10 H2). +/// +/// The second listing of base ids beside `GET /knowledge/bases`, and filtered +/// by the same gate: a base the caller cannot reach is dropped from the set and +/// from the hidden list, and a primary on one reads `null`. The result is the +/// selection the Knowledge view would hold if those bases did not exist — which +/// is exactly what the list it was given says — so nothing in it points at a +/// base the caller would then be refused. For the desktop app, which sends the +/// user's proof, nothing is dropped. +fn active_response( + selection: biorouter_mcp::knowledge::service::KbSelection, + root: &std::path::Path, + caller: &crate::routes::session_reach::HttpCaller, +) -> ActiveKbResponse { + let reachable = |id: &String| caller.reach_knowledge_base(root, id).is_ok(); + let primary_kb = selection.primary_kb.filter(reachable); + ActiveKbResponse { + kb_ids: selection.kb_ids.into_iter().filter(reachable).collect(), + active_kb: primary_kb.clone(), + primary_kb, + hidden_kbs: selection.hidden_kbs.into_iter().filter(reachable).collect(), + } } #[utoipa::path( @@ -1136,15 +1176,23 @@ fn selection_response( ("session_id" = Option, Query, description = "Optional chat session id for the session-scoped selection"), ), responses( - (status = 200, description = "The session's knowledge bases and its primary", body = ActiveKbResponse), + (status = 200, description = "The session's knowledge bases and its primary, showing only \ + the bases this caller may open: a private base is omitted \ + from both lists, and a private primary reads null, for a \ + caller without the user's proof or a private capability", body = ActiveKbResponse), (status = 403, description = "The named session is outside the caller's privacy reach") ) )] pub async fn get_active( State(svc): State>, Query(q): Query, + headers: HeaderMap, ) -> Result, (StatusCode, String)> { - Ok(Json(selection_response(&svc, q.session_id.as_deref())?)) + let caller = crate::routes::session_reach::http_caller(&headers).await; + let selection = svc + .selection(q.session_id.as_deref()) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?; + Ok(Json(active_response(selection, svc.root(), &caller))) } #[utoipa::path( @@ -1160,30 +1208,42 @@ pub async fn get_active( (status = 403, description = "Refused by a privacy boundary (issue #56 Task 58 / #47): \ `session_id` names a private chat (or an absent one, and an \ unproven caller is told the same thing for both) and the \ - request carried no proof it came from the user \ + request carried no proof it came from the user; or \ + `primary_kb` names a knowledge base this caller may not \ + reach, answered exactly as a base that does not exist \ (body = plain text)"), ) )] pub async fn set_active( State(svc): State>, + // Before `Json`, which consumes the body and must be last. + headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, String)> { let primary = body .primary_update() .map_err(|message| (StatusCode::BAD_REQUEST, message))?; + let caller = crate::routes::session_reach::http_caller(&headers).await; + // Naming a base the caller may not reach is answered by the gate, in the + // gate's words, before the service sees it — the same refusal a base that + // does not exist gets, so pinning is not a way to ask which ids are private. + if let PrimaryUpdate::Set(id) = primary { + caller + .reach_knowledge_base(svc.root(), id) + .map_err(|refusal| (refusal.status, refusal.message.to_string()))?; + } + // A caller changes only what it can see: see `set_selection_within`. For a + // caller that reaches every base this is exactly `set_selection`. + let reachable = |id: &str| caller.reach_knowledge_base(svc.root(), id).is_ok(); let selection = svc - .set_selection( + .set_selection_within( body.session_id.as_deref(), body.hidden_kbs.as_deref(), primary, + &reachable, ) .map_err(|e| (StatusCode::BAD_REQUEST, format!("{e:#}")))?; - Ok(Json(ActiveKbResponse { - kb_ids: selection.kb_ids, - active_kb: selection.primary_kb.clone(), - primary_kb: selection.primary_kb, - hidden_kbs: selection.hidden_kbs, - })) + Ok(Json(active_response(selection, svc.root(), &caller))) } #[utoipa::path( @@ -1716,6 +1776,8 @@ pub async fn ingest( pub async fn ingest_conversation( State(svc): State>, Path(id): Path, + // Before `Json`, which consumes the body and must be last. + headers: HeaderMap, Json(body): Json, ) -> Result { if body.session_ids.is_empty() { @@ -1734,6 +1796,21 @@ pub async fn ingest_conversation( // and one binding is what makes that visible instead of argued. let session_manager = std::sync::Arc::new(biorouter::session::session_manager::SessionManager::instance()); + + // Issue #56, QA 2026-09-10 H2. This route NAMES chats, and streams what the + // macro makes of them back to whoever asked — so the caller must be able to + // reach each one, by the gate `GET /sessions/{id}` uses and in its words, + // before a single transcript is read. Gate G below is a different question + // (may the MODEL read them), and a caller holding only the daemon secret can + // name a private model: without this it read a private chat through one. + // Every id is checked before any is loaded, so the refusal cannot say which + // of several named chats exist. + for sid in &body.session_ids { + crate::routes::session_reach::session_reach(&session_manager, sid, &headers) + .await + .map_err(|refusal| (refusal.status, refusal.message.to_string()))?; + } + let mut sessions = Vec::new(); for sid in &body.session_ids { match session_manager.get_session(sid, true).await { diff --git a/crates/biorouter-server/src/routes/reply.rs b/crates/biorouter-server/src/routes/reply.rs index e8fdb7783..dff83f9de 100644 --- a/crates/biorouter-server/src/routes/reply.rs +++ b/crates/biorouter-server/src/routes/reply.rs @@ -1782,7 +1782,7 @@ pub const STEER_NO_KEY: &str = /// 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 -/// [`authorize_steer`], which says why the argument below does not reach it. +/// [`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 @@ -1808,6 +1808,18 @@ pub const STEER_NO_KEY: &str = /// ⚠ **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. +/// +/// ⚠ **The two refusals' SHAPES are read by the terminal.** `biorouter session` +/// cannot ask a daemon whether it holds a key, so it sends a stop or a steer +/// without the proof and reads the answer (`commands/session_watch.rs`, +/// `key_verdict`): the `Unproven` arm's EMPTY 403 is the only thing that makes it +/// ask the person for the key, and a refusal carrying a sentence — every one the +/// keyless arm can give — is shown instead, because no key would change it. So +/// a sentence added to `Unproven` would stop the terminal asking for the key on +/// the desktop's daemon, and an empty refusal on the keyless arm would make it +/// ask a `serve` user for a key that does not exist. Both are pinned: the keyed +/// side here in `integration_tests`, the keyless side in +/// `tests/turn_control_no_user_key.rs`. async fn authorize_turn_control( state: &AppState, session_id: &str, @@ -1860,15 +1872,23 @@ async fn authorize_turn_control( /// 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. -fn authorize_steer(headers: &HeaderMap) -> Result<(), axum::response::Response> { +/// +/// 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 => Ok(()), - UserActionProof::Unproven => Err(StatusCode::FORBIDDEN.into_response()), - UserActionProof::NoKeyInstalled => Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ "message": STEER_NO_KEY })), - ) - .into_response()), + 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(), + ), } } @@ -1910,7 +1930,9 @@ pub async fn interrupt( headers: HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json), axum::response::Response> { - authorize_steer(&headers)?; + if let Some(refusal) = steer_refusal(&headers) { + return Err(refusal); + } if req.text.trim().is_empty() { return Err(StatusCode::BAD_REQUEST.into_response()); } @@ -1938,9 +1960,9 @@ pub async fn interrupt( if !state.is_turn_active(&req.session_id) { return Err(StatusCode::CONFLICT.into_response()); } - // `authorize_steer` above is the authority for this attribution, and it - // admits none but `Proven` — which is why the stamp is unconditional here on - // every daemon. Keep it independent of the session store: a live agent can + // `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 { @@ -4749,6 +4771,58 @@ mod tests { ); } + /// The keyed half of what `biorouter session` reads before it asks a + /// person for the key (`commands/session_watch.rs::key_verdict`). On a + /// daemon that holds one, turn control refuses a request without the + /// proof with an EMPTY 403 — the one refusal a daemon without a key + /// never gives (`tests/turn_control_no_user_key.rs`) — and refuses it + /// before it reads the text, so the empty steer the terminal asks with + /// is answered by the gate, not by the text check. With the proof, the + /// same empty steer is the 400 the terminal takes as "this key opens + /// the gate". None of it touches the turn or the queue. + #[tokio::test(flavor = "multi_thread")] + async fn an_unproven_stop_or_steer_is_refused_empty_before_its_text_is_read() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let token = CancellationToken::new(); + let _guard = state + .try_begin_turn_idempotent("keyed-question", token.clone(), None) + .expect("turn lock acquired"); + let agent = state.get_agent("keyed-question".to_string()).await.unwrap(); + agent.open_for_turn(biorouter::agents::TurnId::new("agent-turn-keyed-question")); + + for mut request in [ + interrupt_request("keyed-question", ""), + interrupt_request("keyed-question", "pretend the user said this"), + cancel_request("keyed-question"), + ] { + let route = request.uri().path().to_string(); + request.headers_mut().remove("X-User-Action"); + let response = routes(Arc::clone(&state)).oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{route}"); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + body.is_empty(), + "{route}: a sentence here reads to the terminal as a refusal no key can \ + change, so it would never ask for the key: {}", + String::from_utf8_lossy(&body) + ); + } + + let response = routes(Arc::clone(&state)) + .oneshot(interrupt_request("keyed-question", "")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(!token.is_cancelled(), "a refused Stop reached the turn"); + assert!( + !agent.has_soft_interrupts(), + "the terminal's question reached the agent's queue" + ); + } + /// #69: the turn lock is still held — the reply task has not unwound yet — /// but the loop has performed its final drain and committed to exiting. /// The old route read only the lock and returned 202, and the text then diff --git a/crates/biorouter-server/src/routes/schedule.rs b/crates/biorouter-server/src/routes/schedule.rs index f9500e6f7..f9f322e84 100644 --- a/crates/biorouter-server/src/routes/schedule.rs +++ b/crates/biorouter-server/src/routes/schedule.rs @@ -2,7 +2,8 @@ use std::sync::Arc; use axum::{ extract::{Path, Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, routing::{delete, get, post, put}, Json, Router, }; @@ -182,7 +183,14 @@ fn create_schedule_error( get, path = "/schedule/list", responses( - (status = 200, description = "A list of scheduled jobs", body = ListSchedulesResponse), + (status = 200, description = "A list of scheduled jobs. Every schedule is listed, \ + including idle and paused ones — but `current_session_id` \ + and `creator_session_id` are omitted from any row naming a \ + chat this caller could not open, i.e. a private chat or one \ + that cannot be read, for a caller carrying neither the \ + user-action proof nor a private capability. Fields are \ + redacted, ROWS are never dropped: a schedule is not a chat, \ + and an idle one names none", body = ListSchedulesResponse), (status = 500, description = "Internal server error") ), tag = "schedule" @@ -190,14 +198,65 @@ fn create_schedule_error( #[axum::debug_handler] async fn list_schedules( State(state): State>, + headers: HeaderMap, ) -> Result, StatusCode> { let scheduler = state.scheduler(); tracing::info!("Server: Calling scheduler.list_scheduled_jobs()"); - let jobs = scheduler.list_scheduled_jobs().await; + let mut jobs = scheduler.list_scheduled_jobs().await; + + // Issue #56: every row named the chat that created the schedule and, for a + // running one, the chat the run is in — to any holder of the daemon secret. + // + // ⚠ **REDACTION here, not the omission every other listing uses, and the + // difference is the subject.** Elsewhere a row IS its chat's content, so the + // row goes. A schedule is not a chat: it is a cron line and a workflow path + // that merely *name* chats, and an idle or paused schedule names none at + // all. Applying the sibling routes' "names no chat is unreadable" rule row + // by row would therefore drop every non-running schedule for every caller + // without a private capability — emptying the Schedules interface in order + // to close an association. So every row stays and the two chat-naming + // fields go. + // + // The two fields are asked about separately because they can name different + // chats: a schedule created from a public chat can be running in a private + // one, and the reverse. + let caller = crate::routes::session_reach::http_caller(&headers).await; + redact_unreachable_chats(&caller, state.session_manager(), &mut jobs).await; Ok(Json(ListSchedulesResponse { jobs })) } +/// Blank the chat-naming fields of every row whose chat this caller could not +/// open. See [`list_schedules`] for why this redacts rather than omits. +/// +/// Split out of the handler so the decision can be tested against real seeded +/// chats without going through the cron scheduler: `add_scheduled_job` registers +/// a task on the tokio-cron-scheduler, which is process-global while each +/// `#[tokio::test]` brings its own runtime, so seeding a schedule from one test +/// and listing it from another fails with `CantAdd` — measured. The route's own +/// wiring to this function is asserted by a body scan instead. +pub(crate) async fn redact_unreachable_chats( + caller: &crate::routes::session_reach::HttpCaller, + manager: &biorouter::session::session_manager::SessionManager, + jobs: &mut [ScheduledJob], +) { + for job in jobs.iter_mut() { + // Asked separately because the two can name DIFFERENT chats: a schedule + // created from a public chat can be running in a private one, and the + // reverse. + if let Some(chat) = job.current_session_id.clone() { + if !caller.lists_work(manager, Some(&chat)).await { + job.current_session_id = None; + } + } + if let Some(chat) = job.creator_session_id.clone() { + if !caller.lists_work(manager, Some(&chat)).await { + job.creator_session_id = None; + } + } + } +} + #[utoipa::path( delete, path = "/schedule/delete/{id}", @@ -373,7 +432,10 @@ fn classify_run_now_error(id: &str, error: &biorouter::scheduler::SchedulerError SessionsQuery // This will automatically pick up 'limit' as a query parameter ), responses( - (status = 200, description = "A list of session display info", body = Vec), + (status = 200, description = "A list of session display info, holding only the runs this \ + caller could open: a private run is omitted for a caller \ + with neither the user-action proof nor a private capability, \ + as it is from `GET /sessions`", body = Vec), (status = 500, description = "Internal server error") ), tag = "schedule" @@ -383,16 +445,23 @@ async fn sessions_handler( State(state): State>, Path(schedule_id_param): Path, // Renamed to avoid confusion with session_id Query(query_params): Query, + headers: axum::http::HeaderMap, ) -> Result>, StatusCode> { let scheduler = state.scheduler(); + // Issue #56, QA 2026-09-10 M1: a schedule's runs, by name and working + // directory — the rows `GET /sessions` lists, through another door. Filtered + // by the same rule, and BEFORE the limit, so a page of private runs does not + // leave a caller with an empty page and the impression there were none. + let caller = crate::routes::session_reach::http_caller(&headers).await; - match scheduler - .sessions(&schedule_id_param, query_params.limit) - .await - { + match scheduler.sessions(&schedule_id_param, usize::MAX).await { Ok(session_tuples) => { let mut display_infos = Vec::new(); - for (session_name, session) in session_tuples { + for (session_name, session) in session_tuples + .into_iter() + .filter(|(_, session)| caller.lists_session(session.privacy_tier)) + .take(query_params.limit) + { display_infos.push(SessionDisplayInfo { id: session_name.clone(), name: session.name, @@ -530,8 +599,25 @@ async fn update_schedule( #[utoipa::path( post, path = "/schedule/{id}/kill", + params( + ("id" = String, Path, description = "ID of the schedule whose run should be stopped") + ), responses( (status = 200, description = "Running job killed successfully"), + (status = 403, description = "The run belongs to a chat this caller could not open — a \ + private chat, or one that cannot be read — and the request \ + carried neither the user-action proof nor a private \ + capability. Plain text, byte-for-byte what `GET \ + /sessions/{session_id}` answers, and the same for a \ + schedule that is not running and one that does not exist, \ + so a refusal says nothing about the run. Nothing was \ + stopped"), + (status = 404, description = "No such schedule"), + (status = 400, description = "Nothing was stopped: the schedule is not running, its run \ + had already finished, or it has started a DIFFERENT run \ + since this request was authorized — the last of which is \ + refused rather than applied to a run the caller was not \ + admitted to. The message says which"), ), tag = "schedule" )] @@ -539,18 +625,47 @@ async fn update_schedule( pub async fn kill_running_job( State(state): State>, Path(id): Path, -) -> Result, (StatusCode, String)> { + headers: HeaderMap, +) -> Result, Response> { let scheduler = state.scheduler(); + // Issue #56: this stopped ANY chat's scheduled run for any holder of the + // daemon secret. `POST /active_work/{id}/cancel` gates the very same kill, + // reached by the very same schedule id, so leaving this open did not merely + // leave a residual — it made that gate bypassable by a one-word change of + // URL, which reads as protection while being none. + // + // Resolve the run to the chat it is in and ask the chat READ's own gate + // BEFORE anything is stopped, exactly as the cancel route does: the same + // status and the same bytes, and the same answer for a schedule that is not + // running and for one that does not exist, so a refusal says nothing about + // which it was. + let owner = scheduler + .get_running_job_info(&id) + .await + .ok() + .flatten() + .map(|(session_id, _)| session_id); + crate::routes::session_reach::work_reach(state.session_manager(), owner.as_deref(), &headers) + .await + .map_err(IntoResponse::into_response)?; + // ⚠ The success message below is only true because `kill_running_job` now // FAILS when there was nothing to cancel. It used to return `Ok(())` whenever // the cancel-token registry held no token for the schedule, so this route // reported "Successfully killed running job" for a Stop that stopped // nothing — the #148 cancel complaint. - scheduler.kill_running_job(&id).await.map_err(|e| { - eprintln!("Error killing running job '{}': {:?}", id, e); - classify_kill_error(&e) - })?; + // The session-CHECKED kill: `owner` is the run this request was authorized + // against, and the scheduler holds its `jobs` lock across the check and the + // cancel, so a run that changed between the gate above and here is refused + // rather than stopped. See `Scheduler::kill_running_job_in_session`. + scheduler + .kill_running_job_in_session(&id, owner.as_deref()) + .await + .map_err(|e| { + eprintln!("Error killing running job '{}': {:?}", id, e); + classify_kill_error(&e).into_response() + })?; Ok(Json(KillJobResponse { message: format!("Successfully killed running job '{}'", id), @@ -585,6 +700,11 @@ fn classify_kill_error(error: &biorouter::scheduler::SchedulerError) -> (StatusC ), responses( (status = 200, description = "Running job information", body = InspectJobResponse), + (status = 403, description = "The run belongs to a chat this caller could not open, and \ + the request carried neither the user-action proof nor a \ + private capability. Identical to the answer for a schedule \ + that is not running and for one that does not exist, so a \ + refusal says nothing about the run"), (status = 404, description = "Scheduled job not found"), (status = 500, description = "Internal server error") ), @@ -594,10 +714,27 @@ fn classify_kill_error(error: &biorouter::scheduler::SchedulerError) -> (StatusC pub async fn inspect_running_job( State(state): State>, Path(id): Path, -) -> Result, StatusCode> { + headers: HeaderMap, +) -> Result, Response> { let scheduler = state.scheduler(); - match scheduler.get_running_job_info(&id).await { + // Issue #56: this named the chat a schedule is running in, plus when the + // run started, to any holder of the daemon secret — precisely the + // association `GET /active_work` omits and `GET /schedule/list` now + // redacts. Resolved once, gated on that chat, and the resolved value is + // what the answer is built from: asking the scheduler a second time after + // the gate would be a second read of a fact that can change. + let info = scheduler.get_running_job_info(&id).await; + let owner = info + .as_ref() + .ok() + .and_then(|i| i.as_ref()) + .map(|(session_id, _)| session_id.clone()); + crate::routes::session_reach::work_reach(state.session_manager(), owner.as_deref(), &headers) + .await + .map_err(IntoResponse::into_response)?; + + match info { Ok(info) => { if let Some((session_id, start_time)) = info { let duration = chrono::Utc::now().signed_duration_since(start_time); @@ -617,8 +754,10 @@ pub async fn inspect_running_job( Err(e) => { eprintln!("Error inspecting running job '{}': {:?}", id, e); match e { - biorouter::scheduler::SchedulerError::JobNotFound(_) => Err(StatusCode::NOT_FOUND), - _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + biorouter::scheduler::SchedulerError::JobNotFound(_) => { + Err(StatusCode::NOT_FOUND.into_response()) + } + _ => Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()), } } } diff --git a/crates/biorouter-server/src/routes/session.rs b/crates/biorouter-server/src/routes/session.rs index f9a6dffeb..2f0ed7e5a 100644 --- a/crates/biorouter-server/src/routes/session.rs +++ b/crates/biorouter-server/src/routes/session.rs @@ -337,7 +337,10 @@ fn is_valid_session_id(id: &str) -> bool { ("include_subagents" = Option, Query, description = "Include sub_agent sessions (grouped under parent_session_id); default false") ), responses( - (status = 200, description = "List of available sessions retrieved successfully", body = SessionListResponse), + (status = 200, description = "The sessions this caller could open. A private session is \ + omitted — never redacted — for a caller that carries neither \ + the user-action proof nor a private capability, exactly as \ + `GET /sessions/{session_id}` would refuse it", body = SessionListResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 500, description = "Internal server error") ), @@ -349,12 +352,18 @@ fn is_valid_session_id(id: &str) -> bool { async fn list_sessions( State(state): State>, Query(query): Query, + headers: axum::http::HeaderMap, ) -> Result, StatusCode> { - let sessions = state + // Issue #56, QA 2026-09-10 M1: this returned every row on the machine — + // title, working directory, privacy reason — to a caller the singular read + // refuses. It now returns the rows that read would admit, and nothing else. + let caller = crate::routes::session_reach::http_caller(&headers).await; + let mut sessions = state .session_manager() .list_sessions_by_types(listed_session_types(query.include_subagents)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + sessions.retain(|session| caller.lists_session(session.privacy_tier)); Ok(Json(SessionListResponse { sessions })) } @@ -368,7 +377,13 @@ async fn list_sessions( ("include_subagents" = Option, Query, description = "Include sub_agent sessions (grouped under parent_session_id); default false") ), responses( - (status = 200, description = "Paginated lightweight session summaries for the sidebar", body = SidebarSessionListResponse), + (status = 200, description = "Paginated lightweight session summaries for the sidebar, \ + holding only the sessions this caller could open (see \ + `GET /sessions`). `next_offset` is where the next page \ + starts; for a caller shown every session it is `offset + \ + limit` as before, and for one shown a filtered view it is a \ + position in the underlying ordering, so pass it back as \ + given rather than computing it", body = SidebarSessionListResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 500, description = "Internal server error") ), @@ -380,26 +395,88 @@ async fn list_sessions( async fn list_sidebar_sessions( State(state): State>, Query(query): Query, + headers: axum::http::HeaderMap, ) -> Result, StatusCode> { let limit = query.limit.clamp(1, MAX_SIDEBAR_SESSION_LIMIT); - let mut sessions = state - .session_manager() - .list_session_summaries( - limit.saturating_add(1), - query.offset, - query.include_subagents, - false, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let caller = crate::routes::session_reach::http_caller(&headers).await; - let has_more = sessions.len() > limit as usize; - sessions.truncate(limit as usize); - let next_offset = has_more.then(|| query.offset.saturating_add(limit)); + // A caller shown every row — the desktop app, a private-capability program, + // or any caller with tiers switched off — pages exactly as it always did, + // one query per page. + if caller.lists_session(SessionClassification::Private) { + let mut sessions = state + .session_manager() + .list_session_summaries( + limit.saturating_add(1), + query.offset, + query.include_subagents, + false, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let has_more = sessions.len() > limit as usize; + sessions.truncate(limit as usize); + let next_offset = has_more.then(|| query.offset.saturating_add(limit)); + + return Ok(Json(SidebarSessionListResponse { + sessions, + has_more, + next_offset, + })); + } + + // Issue #56, QA 2026-09-10 M1: every other caller is shown the public rows + // only, so the page is assembled by SCANNING the ordering rather than by + // filtering one `LIMIT` window — a window filtered after the fact hands back + // short, ragged pages, and a `has_more` counted before the filter would + // report the private rows it hid, which is the count oracle omission exists + // to close. `workspace_list` pages a filtered view the same way. + // + // `offset` and `next_offset` are therefore positions in the UNFILTERED + // ordering: the next page starts exactly where this one stopped, so a walk + // that passes `next_offset` back sees every visible row once. + // + // The scan is bounded per request. Hitting the bound is not the end of the + // list: the page says where to resume, so a machine whose history is mostly + // private is walked in several requests rather than silently cut short. + const SCAN_CHUNK: u32 = 200; + const MAX_SCANNED_ROWS: u32 = 20_000; + let manager = state.session_manager(); + let mut sessions = Vec::with_capacity(limit as usize); + let mut next_offset = None; + let mut position = query.offset; + 'scan: loop { + if position.saturating_sub(query.offset) >= MAX_SCANNED_ROWS { + next_offset = Some(position); + break; + } + let chunk = manager + .list_session_summaries(SCAN_CHUNK, position, query.include_subagents, false) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let fetched = chunk.len() as u32; + for (index, summary) in chunk.into_iter().enumerate() { + if !caller.lists_session(summary.privacy_tier) { + continue; + } + if sessions.len() == limit as usize { + // A visible row beyond this page exists, so there is a next + // page, and it starts at this row. + next_offset = Some(position.saturating_add(index as u32)); + break 'scan; + } + sessions.push(summary); + } + if fetched < SCAN_CHUNK { + break; + } + position = position.saturating_add(fetched); + } Ok(Json(SidebarSessionListResponse { sessions, - has_more, + has_more: next_offset.is_some(), next_offset, })) } @@ -548,6 +625,9 @@ pub struct SessionModelUsageResponse { (status = 200, description = "Per-model usage for the session", body = SessionModelUsageResponse), (status = 400, description = "Invalid session id"), (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 403, description = "Refused by a privacy boundary: the same refusal, word for \ + word, that `GET /sessions/{session_id}` gives (body = plain \ + text)"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), @@ -559,22 +639,30 @@ pub struct SessionModelUsageResponse { async fn get_session_usage( State(state): State>, Path(session_id): Path, -) -> Result, StatusCode> { + headers: axum::http::HeaderMap, +) -> Response { if !is_valid_session_id(&session_id) { - return Err(StatusCode::BAD_REQUEST); + return StatusCode::BAD_REQUEST.into_response(); + } + // Issue #56, QA 2026-09-10: a named chat's metadata, and a 200/404 that told + // an unproven caller whether the id existed. The read's own gate, first. + if let Err(refusal) = + crate::routes::session_reach::session_reach(state.session_manager(), &session_id, &headers) + .await + { + return refusal.into_response(); } - let models = state + match state .session_manager() .get_session_model_usage(&session_id) .await - .map_err(|error| { - if error.to_string().contains("not found") { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - } - })?; - Ok(Json(SessionModelUsageResponse { models })) + { + Ok(models) => Json(SessionModelUsageResponse { models }).into_response(), + Err(error) if error.to_string().contains("not found") => { + StatusCode::NOT_FOUND.into_response() + } + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } } #[utoipa::path( @@ -588,6 +676,9 @@ async fn get_session_usage( (status = 200, description = "Session name updated successfully"), (status = 400, description = "Bad request - Name too long (max 200 characters)"), (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 403, description = "Refused by a privacy boundary: the same refusal, word for \ + word, that `GET /sessions/{session_id}` gives (body = plain \ + text)"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), @@ -599,28 +690,36 @@ async fn get_session_usage( async fn update_session_name( State(state): State>, Path(session_id): Path, + // Before `Json`, which consumes the body and must be last. + headers: axum::http::HeaderMap, Json(request): Json, -) -> Result { +) -> Response { if !is_valid_session_id(&session_id) { - return Err(StatusCode::BAD_REQUEST); + return StatusCode::BAD_REQUEST.into_response(); } - let name = request.name.trim(); - if name.is_empty() { - return Err(StatusCode::BAD_REQUEST); + // Issue #56, QA 2026-09-10 (F0's sweep): renaming a chat is a write into it, + // and a write may never be cheaper than the read. + if let Err(refusal) = + crate::routes::session_reach::session_reach(state.session_manager(), &session_id, &headers) + .await + { + return refusal.into_response(); } - if name.len() > MAX_NAME_LENGTH { - return Err(StatusCode::BAD_REQUEST); + let name = request.name.trim(); + if name.is_empty() || name.len() > MAX_NAME_LENGTH { + return StatusCode::BAD_REQUEST.into_response(); } - state + match state .session_manager() .update(&session_id) .user_provided_name(name.to_string()) .apply() .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(StatusCode::OK) + { + Ok(_) => StatusCode::OK.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } } #[utoipa::path( @@ -633,6 +732,9 @@ async fn update_session_name( responses( (status = 200, description = "Session user workflow values updated successfully", body = UpdateSessionUserWorkflowValuesResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 403, description = "Refused by a privacy boundary: the same refusal, word for \ + word, that `GET /sessions/{session_id}` gives (body = plain \ + text)"), (status = 404, description = "Session not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), @@ -645,14 +747,41 @@ async fn update_session_name( async fn update_session_user_workflow_values( State(state): State>, Path(session_id): Path, + // Before `Json`, which consumes the body and must be last. + headers: axum::http::HeaderMap, Json(request): Json, -) -> Result, ErrorResponse> { +) -> Response { if !is_valid_session_id(&session_id) { - return Err(ErrorResponse { + return ErrorResponse { message: "Invalid session ID".to_string(), status: StatusCode::BAD_REQUEST, - }); + } + .into_response(); } + // Issue #56, QA 2026-09-10 (F0's sweep): this rewrites the chat's workflow + // values and re-applies the workflow to its live agent — a write into the + // chat — so it asks the read's gate before it touches the row or the agent. + // The refusal is the read's plain text, not this route's JSON envelope, so + // a client recognises one boundary by one body. + if let Err(refusal) = + crate::routes::session_reach::session_reach(state.session_manager(), &session_id, &headers) + .await + { + return refusal.into_response(); + } + apply_user_workflow_values(&state, &session_id, request) + .await + .into_response() +} + +/// The body of [`update_session_user_workflow_values`] once the caller may +/// address the chat. +async fn apply_user_workflow_values( + state: &Arc, + session_id: &str, + request: UpdateSessionUserWorkflowValuesRequest, +) -> Result, ErrorResponse> { + let session_id = session_id.to_string(); state .session_manager() .update(&session_id) @@ -730,6 +859,10 @@ async fn update_session_user_workflow_values( responses( (status = 200, description = "Session deleted successfully"), (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 403, description = "Refused by a privacy boundary (issue #56, QA 2026-09-10 \ + F0): the same refusal, word for word, that `GET \ + /sessions/{session_id}` gives — including for a chat that \ + does not exist (body = plain text)"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), @@ -741,9 +874,23 @@ async fn update_session_user_workflow_values( async fn delete_session( State(state): State>, Path(session_id): Path, -) -> Result { + headers: axum::http::HeaderMap, +) -> Response { if !is_valid_session_id(&session_id) { - return Err(StatusCode::BAD_REQUEST); + return StatusCode::BAD_REQUEST.into_response(); + } + // Issue #56, QA 2026-09-10 F0. A caller holding nothing but the daemon + // secret was refused this chat's transcript and could delete it — four of + // four, measured — so the delete now asks the read's own gate, FIRST: before + // the turn is cancelled and before anything parked on a person is released, + // because each of those is itself an effect on the chat. The refusal is the + // read's, byte for byte, so it no more confirms the chat exists than the read + // does — where the old 200/404 pair confirmed it and then destroyed it. + if let Err(refusal) = + crate::routes::session_reach::session_reach(state.session_manager(), &session_id, &headers) + .await + { + return refusal.into_response(); } // Deleting a chat stops its turn. This used to happen by accident and the @@ -778,19 +925,11 @@ async fn delete_session( ); } - state - .session_manager() - .delete_session(&session_id) - .await - .map_err(|e| { - if e.to_string().contains("not found") { - StatusCode::NOT_FOUND - } else { - StatusCode::INTERNAL_SERVER_ERROR - } - })?; - - Ok(StatusCode::OK) + match state.session_manager().delete_session(&session_id).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) if e.to_string().contains("not found") => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } } #[utoipa::path( @@ -971,7 +1110,27 @@ async fn edit_message( } } } - EditType::Edit => edit_in_place(&state, &session_id, &request).await, + EditType::Edit => { + // Issue #56, QA 2026-09-10 (F0's sweep). The in-place arm TRUNCATES + // this chat's history, and it asked nothing of the caller — so a + // caller the read refuses could cut a private transcript it could + // not see. It asks the read's gate now, before the turn lock (whose + // 409 would say the chat is busy) and before the snapshot. + // + // The `Diverge` arm is left on DR-19's gate above, which is strictly + // stronger for a private source (the proof, not merely reach) and + // already answers an unreadable one as private. + if let Err(refusal) = crate::routes::session_reach::session_reach( + state.session_manager(), + &session_id, + &headers, + ) + .await + { + return refusal.into_response(); + } + edit_in_place(&state, &session_id, &request).await + } } } @@ -1486,6 +1645,9 @@ pub struct SessionExtensionsResponse { responses( (status = 200, description = "Session extensions retrieved successfully", body = SessionExtensionsResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 403, description = "Refused by a privacy boundary: the same refusal, word for \ + word, that `GET /sessions/{session_id}` gives (body = plain \ + text)"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") ), @@ -1497,13 +1659,35 @@ pub struct SessionExtensionsResponse { async fn get_session_extensions( State(state): State>, Path(session_id): Path, -) -> Result, StatusCode> { + headers: axum::http::HeaderMap, +) -> Response { if !is_valid_session_id(&session_id) { - return Err(StatusCode::BAD_REQUEST); + return StatusCode::BAD_REQUEST.into_response(); + } + // Issue #56, QA 2026-09-10 — M2's sibling. A private chat's enabled + // extensions name, by name, the private connectors Gate E hides from a + // public model's own tool list (`cdwagent`, `ucsfomopagent`), so this asks + // the read's gate before it reads the row. + if let Err(refusal) = + crate::routes::session_reach::session_reach(state.session_manager(), &session_id, &headers) + .await + { + return refusal.into_response(); } + match session_extensions(&state, &session_id).await { + Ok(extensions) => Json(SessionExtensionsResponse { extensions }).into_response(), + Err(status) => status.into_response(), + } +} + +/// The enabled extension list of a chat the caller may address. +async fn session_extensions( + state: &Arc, + session_id: &str, +) -> Result, StatusCode> { let session = state .session_manager() - .get_session(&session_id, false) + .get_session(session_id, false) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -1521,7 +1705,7 @@ async fn get_session_extensions( .unwrap_or_else(biorouter::config::get_enabled_extensions) }; - Ok(Json(SessionExtensionsResponse { extensions })) + Ok(extensions) } /// BR-71: the sessions holding a turn right now. @@ -1998,12 +2182,30 @@ pub(crate) mod diverge_tests { assert_eq!(status, axum::http::StatusCode::BAD_REQUEST); } + /// The person at the keyboard is told a missing chat is missing. A caller + /// holding only the daemon secret is told what the read tells it — the same + /// refusal it gets for a private chat — since QA measured this route's + /// 200/404 pair to be an oracle for which ids exist (2026-09-10). #[tokio::test(flavor = "multi_thread")] #[serial] async fn usage_route_returns_not_found_for_missing_session() { + install_test_user_action_key(); let state = AppState::new().await.unwrap(); + let res = routes(state.clone()) + .oneshot( + Request::builder() + .method("GET") + .uri("/sessions/29990101_99999/usage") + .header("X-User-Action", TEST_USER_ACTION_KEY) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::NOT_FOUND); + let (status, _) = get_usage(state, "29990101_99999").await; - assert_eq!(status, axum::http::StatusCode::NOT_FOUND); + assert_eq!(status, axum::http::StatusCode::FORBIDDEN); } /// `days` is attacker-controlled; the server clamps it rather than building a diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index c3eebc0f8..7ead215a7 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -26,16 +26,30 @@ //! [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::authorize_steer` says +//! 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 -//! /active_work/{id}/cancel` — which name no session id in their path and so -//! enumerate, in the manner of `GET /sessions` below, but carry a `title` and -//! `detail` holding the SHELL COMMAND or TASK PROMPT of every running job. -//! That is content rather than metadata, and it is the one row here that a -//! reader should not file mentally beside "titles and directories". +//! `DELETE /sessions/{id}` were open until QA's 2026-09-10 sweep, which +//! measured the last one deleting a private chat the read refused (F0), and +//! they are on the list now. `GET /active_work` and `POST +//! /active_work/{id}/cancel` were open until 2026-09-11. They name no session +//! id in their path and so enumerate, and every row carries a `title` and +//! `detail` holding a running job's SHELL COMMAND or TASK PROMPT, which is +//! content rather than metadata. The list now shows a row only to a caller +//! that could open the row's chat ([`HttpCaller::lists_work`]). The cancel +//! resolves its id to that chat and asks [`work_reach`]. A row that names no +//! chat is answered as a private one. Their SCHEDULED half is closed too, and +//! had to be: `POST /schedule/{id}/kill` reaches the same kill by the schedule +//! id, so an ungated twin made the cancel's `sched:` arm bypassable by a +//! one-word change of URL. It and `GET /schedule/{id}/inspect` now ask +//! [`work_reach`]; `GET /schedule/list` keeps every row and redacts the +//! chat-naming FIELDS, because a schedule names chats rather than being one +//! and an idle schedule names none. `GET /sessions/running` (ids +//! only, and `biorouter session list` needs it whole to report liveness +//! truthfully), `GET /sessions/changes` (a watched row's provider, model and +//! tier columns), `GET /sessions/insights` and `GET /sessions/activity` +//! (aggregates) remain open too. //! ⚠ **This bullet listed `POST /agent/resume` as open until 2026-09-04, and //! it was wrong** — measured against a live private session, `/agent/resume` //! answers 403 without the capability header and 200 with it, because @@ -58,16 +72,32 @@ //! * both read and write halves of `/knowledge/active` are gated when they name //! a session. Machine-wide selection requests name no chat and remain outside //! the session boundary; -//! * **`GET /sessions` and `GET /sessions/sidebar` are still open, and they -//! enumerate wholesale.** `SessionSummary` carries `id`, `name`, `working_dir` -//! and `privacy_tier`, so one unproven request returns every private chat on -//! the machine, titled, with the directory it runs in. This does not weaken the -//! gate — none of those rows carries a transcript — but it does undercut the -//! *reason* [`SESSION_OUT_OF_REACH`] is worded as one sentence for two -//! answers. That wording closes an oracle that enumerates private chats one id -//! at a time; the bigger one, which returns them all at once, is still there. -//! Closing it is a listing-route decision (what a caller with no proof may be -//! shown), not a reach decision, and it is not made here; +//! * ~~**`GET /sessions` and `GET /sessions/sidebar` are still open, and they +//! enumerate wholesale.**~~ **ANSWERED 2026-09-11 (QA M1): they FILTER.** QA +//! measured `GET /sessions` returning all 5,543 rows — 792 private, each with +//! id, title, working directory and privacy reason — to a caller the singular +//! read refuses, which undercut the whole reason [`SESSION_OUT_OF_REACH`] is +//! one sentence for two answers. The decision this bullet left open is now +//! made: a listing shows a caller exactly the rows this gate would admit it to +//! ([`HttpCaller::lists_session`]), so the list is the union of what per-id +//! probing could learn and nothing more. **Filter, not refuse**: a refused +//! list would break every client on the public chats the gate is deliberately +//! inert on. The sidebar pages its filtered view by scanning, so `has_more` +//! cannot count the rows it hid. `GET /schedule/{id}/sessions` takes the same +//! filter, and so, since 2026-09-11, does `GET /active_work`. A row of running +//! work is not a chat, so [`HttpCaller::lists_work`] resolves the chat it +//! belongs to, and a row that names no chat, or one that cannot be read, is +//! answered as a private chat's row; +//! * **knowledge bases take the same decision** since the same sweep (QA H2): +//! every `/knowledge/bases/{id}…` route sits behind [`gate_knowledge_base`], +//! and `GET /knowledge/bases` and `/knowledge/active` omit what the caller +//! cannot reach. The target is the base's tier, an absent or malformed id is +//! [`TargetTier::Unreadable`], and the words are [`KNOWLEDGE_BASE_OUT_OF_REACH`]; +//! * a `biorouter serve` daemon's own interface — a request carrying the served +//! document's cookie — is given its operator's configured tier on those +//! listing and knowledge-base surfaces, which were open to it before they +//! were gated, and on NOTHING this function decides ([`HttpCaller`], +//! `docs/deployment/serve-decisions.md` SD-10); //! * **`workspace_read_conversation` was open too, and it is CLOSED — but by a //! different instrument, and a reader must not credit this module for it.** //! That MCP tool (`crates/biorouter/src/agents/workspace_extension.rs`) used @@ -149,21 +179,39 @@ //! | `POST /agent/add_extension` | Attaches tools to the session. | //! | `GET|POST /knowledge/active` | Reads or repoints the session's knowledge bases and write target. | //! | `POST /agent/resume` | Loads the session's stored conversation into a live agent. Gates directly, like the rows above. | -//! | `GET /agent/callable_tool_count` | Counts the named session's MODEL-FACING tools, and answers through `get_or_create_agent` — so it CREATES an agent for a session that has none. Added 2026-09-12 by the SD-8 review of #260, which found it with no gate of any kind; the renderer had merely stopped calling it. ⚠ Its sibling `GET /agent/tools` is **deliberately** not here: that one is the unfiltered permission-editor surface, so a person can administer private tools a public model cannot see. | +//! | `GET /agent/callable_tool_count` | Counts the named session's MODEL-FACING tools, and answers through `get_or_create_agent` — so it CREATES an agent for a session that has none. Added 2026-09-12 by the SD-8 review of #260, which found it with no gate of any kind; the renderer had merely stopped calling it. ⚠ Its sibling `GET /agent/tools` has a row of its own below — QA's M2 sweep gated it for REACH. What stays **deliberate** there is the other question: once a caller is admitted, the list it returns is UNFILTERED, so a person can administer private tools a public model cannot see. | //! | `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::authorize_steer`. | +//! | `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`. | +//! | `DELETE /sessions/{session_id}` | QA 2026-09-10 F0: deleted a private chat the read refused, four of four. Gated before the turn is cancelled or anything parked is released. | +//! | `PUT /sessions/{session_id}/name` · `user_workflow_values` | Writes into the chat; the second re-applies its workflow to the live agent. | +//! | `POST /sessions/{session_id}/edit_message`, `editType: edit` | Truncates the chat in place. (`diverge` keeps DR-19's stricter proof gate.) | +//! | `GET /sessions/{session_id}/extensions` · `usage` | The chat's extensions by name (M2's sibling); its usage, whose 200/404 was an existence oracle. | +//! | `GET /agent/tools` | QA M2: a private chat's private-connector tool names, handed to a secret-only caller while `add_extension` on the same chat refused. Like the `callable_tool_count` row above it mints an agent for the chat, so the gate runs first. The empty `session_id` of the settings page names no chat and is not gated. | +//! | `POST /workflows/create` | Loads the chat's whole transcript and returns what a model makes of it. | +//! | `POST /skills/session` | Writes a skill's instructions into the chat's next turn. | +//! | `POST /knowledge/bases/{id}/ingest-conversation` | Every chat the request names, checked before any is loaded. | +//! | `POST /active_work/{id}/cancel` | Stops one chat's running work: a shell command's process group, a subagent, a detached turn or a scheduled run. The id names the work, so the route resolves it to the owning chat and asks [`work_reach`], which is `session_reach` on that chat. A handle that names nothing, work that names no chat and a schedule that is not running are all refused as a private chat is. | //! -//! ⚠ **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. +//! Every row since the 2026-09-10 sweep answers with [`SESSION_OUT_OF_REACH`] +//! as PLAIN TEXT — the bytes `GET /sessions/{session_id}` returns — rather than +//! through the route's own error envelope, so one boundary has one body. +//! +//! ⚠ **Two spellings, one list.** THREE of the rows above reach the gate through +//! a helper rather than by naming it — the `authorize_agent_control` row, the +//! `/agent/cancel` row beside it (through `authorize_turn_control`, into that +//! same helper), and `POST /active_work/{id}/cancel` (through [`work_reach`]) — +//! which is why a scan for the literal `session_reach(` reports those **seven** +//! routes as ungated, and why the ordering test below uses handlers from those +//! files as over-read controls. They are NOT exempt: measured live, each of the +//! `authorize_agent_control` four answers 403 without the capability header and +//! proceeds with it, `tests/turn_control_no_user_key.rs` measures the other two +//! on a keyless daemon, and the `active_work` ordering rows below name +//! `work_reach(`. A future sweep that greps for the call must follow +//! `authorize_agent_control` and `work_reach` too, or it will "discover" seven +//! 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 //! @@ -181,10 +229,13 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use biorouter::privacy::{ProviderTier, SessionClassification}; use biorouter::session::session_manager::SessionManager; +use biorouter_mcp::knowledge::service::KnowledgeService; // Issue #56 DR-16. `src/routes/` is compiled into the `biorouterd` binary as // well as the lib and cannot name `crate::auth`, so this is the shared // direction — the same import `routes::session` and `routes::knowledge` use. -use biorouter_server::auth::{user_action_proof, UserActionProof}; +use biorouter_server::auth::{served_operator_capability, user_action_proof, UserActionProof}; +use std::path::Path; +use std::sync::Arc; /// The header a Biorouter client names the model it is running under. /// @@ -306,6 +357,51 @@ pub const SESSION_REACH_NO_KEY: &str = Nothing was read and nothing was changed. This control is unavailable on this daemon; use \ the desktop app."; +/// [`SESSION_OUT_OF_REACH`] for a knowledge base the caller named — the same +/// decision, from the same function, with the subject's noun changed and +/// nothing else (issue #56, QA 2026-09-10 H2). +/// +/// ⚠ **ONE sentence for "that base is private" and for "there is no such +/// base"**, for the reason the chat constant gives. A base's id and name are +/// user-authored content — the plan's Task 10D ruled that directly enumerating +/// them is the content crossing, not a side channel — so a refusal that told a +/// private base from an absent one would enumerate the machine's private bases +/// one guess at a time. The existence oracle AR-5 accepts is a different door +/// (`create_base`'s "already exists") and nothing here widens it. +/// +/// ⚠ Every constraint on [`SESSION_OUT_OF_REACH`] binds this one, and the leak +/// guards below are run against both: it names no base, no page and no path; it +/// is fixed text; it signposts the operator page without naming the header; and +/// its last words are the stop. +/// +/// ⚠ **The KB tool path says something different, deliberately.** A model +/// calling `kb_read_page` is told [`biorouter_mcp::knowledge::tier::KB_PRIVATE_REFUSAL`] +/// ("switch this chat to a private model"), which is the remedy for a chat. An +/// HTTP caller has no chat to switch; what it has is this daemon's reach rule, +/// the one [`SESSION_OUT_OF_REACH`] states for a chat. +pub const KNOWLEDGE_BASE_OUT_OF_REACH: &str = + "That knowledge base is private, or there is no knowledge base with that id. This request was \ + made on a public model and carried no proof it came from the person at the keyboard, and the \ + two answers are deliberately the same so that nothing about the knowledge base is disclosed. \ + Nothing was read and nothing was changed. Do not retry as you are; the same call will be \ + refused again, and no setting, hook or permission mode changes it. A private knowledge base \ + is reachable from a session running a private model, one the institution hosts or one that \ + runs on this machine, or from the desktop app when the person at the keyboard acts. Pointing \ + a program that already runs under such a model at this daemon is a setup decision for \ + whoever operates it, and the Biorouter documentation covers it under 'Reaching a private chat \ + from a script'. If this task genuinely needs that knowledge base, stop and ask the user to \ + open it for you."; + +/// …and [`SESSION_REACH_NO_KEY`]'s sibling, for a daemon that was handed no +/// user-action key at all — a `biorouter serve` daemon among them (SD-7), whose +/// browser reads this when it is pointed at a private base its operator's tier +/// does not cover. +pub const KNOWLEDGE_BASE_REACH_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 reaching into a private knowledge base requires that \ + proof. Nothing was read and nothing was changed. This control is unavailable on this daemon; \ + use the desktop app."; + /// The named session, reduced to the one bit this gate turns on. /// /// Three states rather than two because the third has to be *represented* in @@ -349,6 +445,35 @@ impl From for super::errors::ErrorResponse { } } +impl SessionOutOfReach { + /// The same refusal, worded for a knowledge base. + /// + /// A mapping between the constant pairs rather than a second decision: the + /// verdict — which of the two arms, and that it refused at all — is + /// [`refuse_unless_reachable`]'s, and this changes only the noun. Private, + /// because nothing outside this module should be choosing a refusal's words + /// apart from the decision that produced it. + fn for_knowledge_base(self) -> Self { + let message = if self.message == SESSION_REACH_NO_KEY { + KNOWLEDGE_BASE_REACH_NO_KEY + } else { + KNOWLEDGE_BASE_OUT_OF_REACH + }; + Self { message, ..self } + } +} + +impl From for TargetTier { + /// A row the caller already holds — a listing's — is readable by + /// construction, so it is never [`TargetTier::Unreadable`]. + fn from(classification: SessionClassification) -> Self { + match classification { + SessionClassification::Private => Self::Private, + SessionClassification::Public => Self::Public, + } + } +} + /// May a caller in this credential state reach a session in this state? /// /// ⚠ **Extracted so the claim is asserted rather than grepped for.** None of the @@ -432,10 +557,11 @@ pub fn refuse_unless_reachable( /// ⚠ **Deliberately NOT `pub`.** This module is one of `COMPLETE_MODULES` in the /// wiring census (`crates/biorouter/tests/privacy_guard_wiring.rs`): every /// public function in it must carry a census row classifying it as a reach -/// decision. This is not one — it resolves an input to [`session_reach`], which -/// is the guard and which does carry a row — and its only caller is that -/// function, three lines below. Making it public to save an import would either -/// break the census or add a row that misdescribes what it is. +/// decision. This is not one — it resolves an input to [`session_reach`], +/// [`work_reach`] and [`http_caller`], which are the guards and which do carry +/// rows — and those three, all in this file, are its only callers. Making it +/// public to save an import would either break the census or add a row that +/// misdescribes what it is. async fn caller_capability(headers: &HeaderMap) -> ProviderTier { let Some(name) = headers .get(CALLER_PROVIDER_HEADER) @@ -494,6 +620,210 @@ pub async fn session_reach( ) } +/// The gate for RUNNING WORK a caller named by its own handle — `POST +/// /active_work/{id}/cancel` — rather than by its chat's id. +/// +/// `owner` is the chat that owns the work, which the route resolves from the +/// registry entry or the scheduler BEFORE anything is stopped. +/// +/// * **`Some(id)` is [`session_reach`] on that chat, by the same call**, so the +/// same status, the same words, and the same answer for a chat that is gone. +/// Stopping a chat's work is never easier than reading the chat. +/// * **`None` is [`TargetTier::Unreadable`]**: work that names no chat, a +/// handle that names nothing, and a schedule that is not running. Refused +/// exactly as a private chat is, to a caller with neither the capability nor +/// the proof — for the reason [`HttpCaller::lists_work`] omits such a row, +/// and so that a refusal cannot tell a caller which of the four it hit. A +/// caller that IS admitted is let through to the route, which tells it the +/// truth (404 for a handle that names nothing). +/// +/// ⚠ **Never the served-operator standing**, for the reason [`session_reach`] +/// never reads it: a stop names one chat's work, and SD-10 gives a `biorouter +/// serve` browser its operator's reach on listings and knowledge bases only. So +/// that browser can be listed a private chat's work it cannot stop — as it is +/// listed private chats it cannot open or delete. +pub async fn work_reach( + manager: &SessionManager, + owner: Option<&str>, + headers: &HeaderMap, +) -> Result<(), SessionOutOfReach> { + if let Some(session_id) = owner { + return session_reach(manager, session_id, headers).await; + } + let enforced = biorouter::privacy::privacy_tiers_enabled(); + if !enforced { + return Ok(()); + } + refuse_unless_reachable( + enforced, + TargetTier::Unreadable, + caller_capability(headers).await, + user_action_proof(headers), + ) +} + +/// Who is asking, resolved ONCE per request and threaded through every decision +/// that request needs — the HTTP counterpart of `CallCapability`, and for the +/// same reason: a listing that re-read the master switch or re-resolved the +/// caller per row could half-believe two answers. +/// +/// It carries the two facts [`session_reach`] turns on — the capability the +/// request states ([`CALLER_PROVIDER_HEADER`]) and the user-action proof — and a +/// third that only a `biorouter serve` daemon ever sets: +/// `auth::served_operator_capability`, the operator's configured tier, earned by +/// presenting the served document's cookie. +/// +/// ⚠ **The third input is read by the surfaces this type serves, and never by +/// [`session_reach`].** Listings and knowledge bases were fully open to a serve +/// daemon's browser before they were gated, so honouring the operator's tier +/// there keeps that browser's reach exactly where it was. The transcript gate +/// refused that browser every private chat before this type existed, and +/// feeding the operator's tier into it would admit what it refused — the one +/// thing this change may not do. Whether a serve operator on a private provider +/// should reach a private transcript is a decision still to be made, and it is +/// recorded as open in `docs/deployment/serve-decisions.md` SD-10, not taken here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HttpCaller { + /// DR-15's master opt-out, sampled with everything else. + enforced: bool, + /// What the request states it runs under, resolved by this daemon's + /// registry — [`caller_capability`]. + stated: ProviderTier, + /// A serve daemon's operator tier, for a request from its served document. + /// `Public` on every other daemon and for every other request. + served_operator: ProviderTier, + proof: UserActionProof, +} + +/// Resolve the caller behind one request. See [`HttpCaller`]. +pub async fn http_caller(headers: &HeaderMap) -> HttpCaller { + HttpCaller { + enforced: biorouter::privacy::privacy_tiers_enabled(), + stated: caller_capability(headers).await, + served_operator: served_operator_capability(headers), + proof: user_action_proof(headers), + } +} + +impl HttpCaller { + /// Private if either capability input is: a program stating a private + /// provider, or a serve daemon's own interface on a private one. + fn capability(&self) -> ProviderTier { + if self.stated.is_private() || self.served_operator.is_private() { + ProviderTier::Private + } else { + ProviderTier::Public + } + } + + /// May this caller be shown a chat of this classification in a listing? + /// + /// Exactly [`refuse_unless_reachable`]'s answer for the row, so a listing is + /// the union of what the singular gate admits one id at a time and cannot + /// tell a caller anything per-id probing is worded to withhold. **Omission, + /// not redaction**: a row carries an LLM-written title and a working + /// directory, both content (§11.4), which is the rule `workspace_list` + /// already applies to a model. + pub fn lists_session(&self, classification: SessionClassification) -> bool { + self.admits(TargetTier::from(classification)) + } + + /// May this caller be shown a chat's id that a row merely NAMES — the chat a + /// row of running work belongs to (`GET /active_work`), or the chat a + /// schedule was created from or is running in (`GET /schedule/list`)? + /// + /// Two subjects, ONE decision, deliberately: a second predicate for "may + /// this caller be told this chat exists" is exactly the drift the census + /// exists to stop. What differs between the two callers is what they do with + /// a `false` — `/active_work` drops the row, because the row is the chat's + /// own command; `/schedule/list` keeps the row and drops the field, because + /// a schedule is not a chat and an idle one names none. + /// + /// [`lists_session`](Self::lists_session) for a row the listing does not + /// hold a chat for. A row of running work carries its chat's id and a title + /// and detail holding that chat's shell command or task prompt, which is + /// content, so it is shown exactly when the chat would be: omitted, never + /// redacted. The chat has to be resolved to get there, and resolution can + /// fail, so the target is [`target_tier`]'s answer — `Unreadable` for a chat + /// this daemon cannot read. + /// + /// ⚠ **`owner: None` — work that names no chat — is `Unreadable` too, and + /// so is answered exactly as a private chat's row.** Its command came from + /// SOME chat and nothing says which; shown to a public caller, it would + /// carry a private chat's commands out past this gate. + /// Such a row is listed to the desktop app, to a program stating a private + /// capability, and with tiers switched off; to nobody else. [`work_reach`] + /// answers its cancel the same way. Registrants that know their chat say so + /// (`ActiveWorkItem::session_id`), which keeps this arm for work that + /// genuinely has none. + pub async fn lists_work(&self, manager: &SessionManager, owner: Option<&str>) -> bool { + // A caller admitted to a chat this daemon cannot even read is admitted + // to every chat — `refuse_unless_reachable` answers `Unreadable` as it + // answers `Private`, and `Public` always — so it is answered without a + // store read: the sidebar's one-query fast path, row by row. Pinned by + // `the_listing_fast_path_admits_nothing_the_row_check_refuses`. + if self.admits(TargetTier::Unreadable) { + return true; + } + let target = match owner { + Some(session_id) => target_tier(manager, session_id).await, + None => TargetTier::Unreadable, + }; + self.admits(target) + } + + /// The one spelling of this caller's listing decision, which both + /// listing predicates above are. + fn admits(&self, target: TargetTier) -> bool { + refuse_unless_reachable(self.enforced, target, self.capability(), self.proof).is_ok() + } + + /// The reach gate for a knowledge base the caller named — the same pure + /// decision a chat gets, with the base's tier as the target and + /// [`KNOWLEDGE_BASE_OUT_OF_REACH`] as its words. + /// + /// An id that is not well-formed, and one that names no base, are + /// [`TargetTier::Unreadable`] and so are refused exactly as a private base + /// is — to a caller that proves nothing. A caller that does prove it is the + /// user is let through to the handler, which tells them the truth (400 or + /// 404). DR-15's opt-out is inert all the way down, including for the + /// absent id, so a user who turned tiers off still gets their 404. + pub fn reach_knowledge_base(&self, root: &Path, kb_id: &str) -> Result<(), SessionOutOfReach> { + if !self.enforced { + return Ok(()); + } + refuse_unless_reachable( + self.enforced, + knowledge_base_tier(root, kb_id), + self.capability(), + self.proof, + ) + .map_err(SessionOutOfReach::for_knowledge_base) + } +} + +/// A named knowledge base, reduced to the bit the gate turns on. +/// +/// ⚠ **Absent is not public here**, though it is in +/// [`biorouter_mcp::knowledge::tier::is_private`], and both are right for their +/// callers. The tier store reads an absent base as public because "nothing is +/// there to leak" and refusing would stop a public chat creating one. At this +/// gate the question is what a REFUSAL says, and a caller told "private" for one +/// id and "not found" for another has been handed an oracle; so an absent (or +/// malformed) id is answered as a private one. Creating a base is `POST +/// /knowledge/bases`, which names no existing id and is not behind this gate. +fn knowledge_base_tier(root: &Path, kb_id: &str) -> TargetTier { + use biorouter_mcp::knowledge::{paths, tier}; + if paths::validate_kb_id(kb_id).is_err() || !paths::kb_root(root, kb_id).is_dir() { + return TargetTier::Unreadable; + } + if tier::is_private(root, kb_id) { + TargetTier::Private + } else { + TargetTier::Public + } +} + /// `GET|POST /knowledge/active` — the gated route whose router does not have an /// [`AppState`](crate::state::AppState) to resolve a tier with. /// @@ -576,6 +906,53 @@ pub async fn gate_knowledge_active( .await } +/// Every `/knowledge/bases/{id}…` route, behind ONE layer (issue #56, QA +/// 2026-09-10 H2). +/// +/// The tool path refused a public caller a private base at +/// `KnowledgeServer::call_tool`; these routes called the service directly and +/// handed the same base's pages, graph, history, location and a `.brkb` of the +/// whole tree to a caller holding nothing but the daemon secret. The plan had +/// left them ungated on the premise that "the Knowledge view is the user, not a +/// model" — true of the renderer, and false of the secret, which a public chat's +/// own shell recovered with `ps eww` (AR-11). The user is now told apart the +/// way every other private surface tells them apart: by the proof the desktop +/// sends, or by the private capability a program states. +/// +/// ⚠ **A layer on a sub-router of exactly the routes that name a base, not a +/// list of routes.** `knowledge::router` puts every `{id}` route in one router +/// and `route_layer`s this onto it, so the gate reads the `id` the router +/// itself matched — percent-decoded exactly as each handler's `Path` sees it — +/// and a route added there later is gated by construction. Reads and writes +/// alike: a caller that may not read a base may not rewrite, restore, merge or +/// delete it either, which is F0's lesson applied here before anyone measured +/// it. +/// +/// It runs before the handler's own extractors, so a refused request never has +/// its body parsed, its model constructed or its base looked up. +pub async fn gate_knowledge_base( + axum::extract::State(svc): axum::extract::State>, + params: axum::extract::RawPathParams, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + let kb_id = params + .iter() + .find(|(key, _)| *key == "id") + .map(|(_, value)| value.to_owned()); + // Unreachable through `knowledge::router`, where every route this layer + // wraps captures `{id}`. Refused rather than waved through, so that a route + // moved in here without the capture fails closed instead of open. + let Some(kb_id) = kb_id else { + return (StatusCode::FORBIDDEN, KNOWLEDGE_BASE_OUT_OF_REACH).into_response(); + }; + let caller = http_caller(request.headers()).await; + if let Err(refusal) = caller.reach_knowledge_base(svc.root(), &kb_id) { + return refusal.into_response(); + } + next.run(request).await +} + #[cfg(test)] mod tests { use super::*; @@ -1088,6 +1465,11 @@ mod tests { let agent_rs = include_str!("agent.rs"); let events_rs = include_str!("session_events.rs"); let status_rs = include_str!("status.rs"); + let workflow_rs = include_str!("workflow.rs"); + let skills_rs = include_str!("skills.rs"); + let knowledge_rs = include_str!("knowledge.rs"); + let active_work_rs = include_str!("active_work.rs"); + let schedule_rs = include_str!("schedule.rs"); for (src, func, gate_call, first_touch, what) in [ ( reply_rs, @@ -1171,6 +1553,122 @@ mod tests { "try_begin_turn_idempotent(", "the turn lock, whose 409 says whether this chat is busy", ), + // ── QA 2026-09-10: F0, M2, and the sweep F0 asked for ── + ( + session_rs, + "async fn delete_session(", + "session_reach(", + "cancel_turn(", + "the turn cancel and the parked-card release, each an effect on the chat, \ + ahead of the delete itself", + ), + ( + session_rs, + "async fn update_session_name(", + "session_reach(", + ".user_provided_name(", + "the rename", + ), + ( + session_rs, + "async fn update_session_user_workflow_values(", + "session_reach(", + "apply_user_workflow_values(", + "the row write and the workflow re-applied to the live agent", + ), + ( + session_rs, + "async fn edit_message(", + "session_reach(", + "edit_in_place(", + "the in-place truncation", + ), + ( + session_rs, + "async fn get_session_extensions(", + "session_reach(", + "session_extensions(", + "the row read that names the chat's extensions", + ), + ( + session_rs, + "async fn get_session_usage(", + "session_reach(", + "get_session_model_usage(", + "the usage read, whose 200/404 said whether the id existed", + ), + ( + agent_rs, + "async fn get_tools(", + "session_reach(", + "permission_editor_tools(", + "the agent fetch, which mints an agent for the chat", + ), + ( + agent_rs, + "async fn get_callable_tool_count(", + "session_reach(", + "model_visible_tool_count(", + "the agent fetch, which mints an agent for the chat", + ), + ( + workflow_rs, + "async fn create_workflow(", + "session_reach(", + "workflow_from_session(", + "the transcript load and the model that summarises it", + ), + ( + skills_rs, + "pub async fn set_session_skills(", + "session_reach(", + "session_skills::apply(", + "the per-chat skill write", + ), + ( + knowledge_rs, + "pub async fn ingest_conversation(", + "session_reach(", + ".get_session(sid, true)", + "the transcript load", + ), + // ── Running work: named by its own handle, gated on its chat ── + ( + active_work_rs, + "async fn cancel_active_work(", + "work_reach(", + "kill_running_job_in_session(", + "the scheduler's kill of the run", + ), + ( + active_work_rs, + "async fn cancel_active_work(", + "work_reach(", + "active_work().cancel(", + "the registry's cancel action, which kills a process group or trips a turn", + ), + // ── The same run, named by its SCHEDULE id instead of its work handle ── + // + // ⚠ Without this row the gate above protects nothing for the + // `sched:` arm: `POST /schedule/{id}/kill` reaches the identical + // `Scheduler` by the identical schedule id, so a caller refused at + // `/active_work/{id}/cancel` re-issued the request one URL over and + // stopped the run anyway. A gate a one-word change of URL routes + // around reads as protection while being none. + ( + schedule_rs, + "pub async fn kill_running_job(", + "work_reach(", + "kill_running_job_in_session(", + "the scheduler's kill of the run", + ), + ( + schedule_rs, + "pub async fn inspect_running_job(", + "work_reach(", + "InspectJobResponse {", + "the response naming the chat the run is in, and when it started", + ), ] { let handler = body_of(src, func); let gate = handler.find(gate_call).unwrap_or_else(|| { @@ -1197,13 +1695,16 @@ mod tests { // reads the row — and measured live against a private session each // answers 403 without the capability header and proceeds with it. They // 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 — on a keyless daemon too, which is + // comment here said otherwise until 2026-09-04. `interrupt` requires + // the user's proof instead of reach — on a keyless daemon too, which is // the one way it differs from the Stop beside it (SD-11a, - // `reply::authorize_steer`) — 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. + // `reply::steer_refusal`) — so it is a genuinely ungated control, and + // `pub fn routes(` sits on the far side of the rows reply.rs + // contributes. `get_session_extensions` was this file's other ungated + // control until QA's 2026-09-10 sweep gated it, which is why it can no + // longer serve as one; `get_session_insights` and `running_sessions` + // replace it — machine-wide aggregates that name no chat — on the two + // sides of this file's gated handlers. // // BOTH sides in `agent.rs`: `agent_remove_extension` sits after the two // gated handlers' neighbourhood and `update_agent_provider` before it, @@ -1213,7 +1714,8 @@ mod tests { (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"), + (session_rs, "async fn get_session_insights("), + (session_rs, "async fn running_sessions("), (agent_rs, "async fn agent_remove_extension"), (agent_rs, "async fn update_agent_provider"), // BOTH sides in the two files this sweep added, for the same reason: @@ -1224,6 +1726,10 @@ mod tests { (events_rs, "pub fn routes("), (status_rs, "async fn system_info("), (status_rs, "pub fn routes("), + // BOTH sides in `schedule.rs` too: `pause_schedule` sits before its + // two gated handlers and `routes` after them. + (schedule_rs, "async fn pause_schedule("), + (schedule_rs, "pub fn routes("), ] { // Both spellings the rows above use, so a control is a control for // every row it could be over-reading into. @@ -1238,109 +1744,575 @@ mod tests { } } - /// The knowledge route's gate is a middleware, so the scan above cannot see - /// it — but the wiring can still be lost in a refactor of `configure`, and a - /// layer that is never applied is a security control that silently does - /// nothing. - /// - /// `the_knowledge_active_gate_is_actually_wired` is what proves it FIRES; - /// this is the cheap tripwire that survives a move of that test. + // ─── QA 2026-09-10: the caller, the knowledge-base target, the words ─── + + fn caller( + stated: ProviderTier, + served_operator: ProviderTier, + proof: UserActionProof, + ) -> HttpCaller { + HttpCaller { + enforced: true, + stated, + served_operator, + proof, + } + } + + /// A listing admits exactly what the singular gate admits, at every corner + /// — so it can never tell a caller more than per-id probing does, and never + /// less than the desktop app and a private program are owed. #[test] - fn the_knowledge_router_carries_the_reach_gate() { - let mod_rs = include_str!("mod.rs"); - let configure = body_of(mod_rs, "pub fn configure"); - assert!( - configure.contains("session_reach::gate_knowledge_active"), - "the knowledge router no longer carries the session-reach gate" + fn a_listing_is_the_singular_gate_applied_row_by_row() { + for stated in CAPABILITIES { + for proof in PROOFS { + let who = caller(stated, ProviderTier::Public, proof); + for classification in [ + SessionClassification::Public, + SessionClassification::Private, + ] { + assert_eq!( + who.lists_session(classification), + refuse_unless_reachable( + true, + TargetTier::from(classification), + stated, + proof + ) + .is_ok(), + "{stated:?} {proof:?} {classification:?}" + ); + } + } + } + // The two shapes QA cares about, spelled out. + let secret_only = caller( + ProviderTier::Public, + ProviderTier::Public, + UserActionProof::Unproven, + ); + assert!(secret_only.lists_session(SessionClassification::Public)); + assert!(!secret_only.lists_session(SessionClassification::Private)); + let desktop = caller( + ProviderTier::Public, + ProviderTier::Public, + UserActionProof::Proven, ); + assert!(desktop.lists_session(SessionClassification::Private)); } -} -#[cfg(test)] -mod bypass_tests { - use super::*; - use crate::routes::session::diverge_tests::{ - install_test_user_action_key, TEST_USER_ACTION_KEY, - }; - use crate::state::AppState; - use axum::body::{to_bytes, Body}; - use axum::http::Request; - use biorouter::conversation::message::Message; - use biorouter::model::ModelConfig; - use biorouter::session::SessionType; - use serial_test::serial; - use std::path::PathBuf; - use std::sync::Arc; - use tower::ServiceExt; + /// A serve daemon's own interface keeps the reach its operator's provider + /// implies on the surfaces this type serves — and a serve daemon on a public + /// provider gives it none, which is the same answer as a secret-only caller. + #[test] + fn the_served_operator_standing_is_a_capability_and_only_that() { + let private_operator = caller( + ProviderTier::Public, + ProviderTier::Private, + UserActionProof::NoKeyInstalled, + ); + assert!(private_operator.lists_session(SessionClassification::Private)); + let public_operator = caller( + ProviderTier::Public, + ProviderTier::Public, + UserActionProof::NoKeyInstalled, + ); + assert!(!public_operator.lists_session(SessionClassification::Private)); + assert!(public_operator.lists_session(SessionClassification::Public)); + } - /// A string that appears in the seeded chat and nowhere else, so "the - /// transcript came back" is an assertion rather than an impression. - /// - /// ⚠ **Unmistakably a fixture, and deliberately not shaped like a record.** - /// These tests seed into the developer's REAL session database — - /// `AppState::new()` opens it — so a row that ever escapes [`SeededChat`]'s - /// cleanup lands in their own sidebar. A marker that read like a patient - /// identifier would then be a privacy incident invented by the test suite of - /// the privacy feature. - const MARKER_IN_THE_TRANSCRIPT: &str = "task58-transcript-marker-not-real-data"; + // ─── Running work (`GET /active_work`, `POST /active_work/{id}/cancel`) ─── - async fn get_session_with( - state: Arc, - session_id: &str, - user_action: Option<&str>, - ) -> (StatusCode, String) { - let app = crate::routes::session::routes(state); - let mut builder = Request::builder() - .method("GET") - .uri(format!("/sessions/{session_id}")); - if let Some(key) = user_action { - builder = builder.header("X-User-Action", key); + /// A store of this test's own with one public and one private chat, so the + /// corners below cannot meet a row another test left in the binary's + /// shared sandbox store, and no `AppState` has to be built. + async fn store_with_a_public_and_a_private_chat( + ) -> (tempfile::TempDir, SessionManager, String, String) { + let dir = tempfile::tempdir().unwrap(); + let manager = SessionManager::new(dir.path().to_path_buf()); + let mut ids = Vec::new(); + for label in ["public", "private"] { + let session = manager + .create_session( + std::path::PathBuf::from("/tmp/session_reach_running_work"), + format!("Running work {label} (test fixture)"), + biorouter::session::SessionType::User, + ) + .await + .unwrap(); + ids.push(session.id); } - let res = app - .oneshot(builder.body(Body::empty()).unwrap()) + manager + .update(&ids[1]) + .provider_name("versa_azure") + .model_config(biorouter::model::ModelConfig::new("gpt-4o").unwrap()) + .raise_privacy(SessionClassification::Private, "turn:versa_azure") + .apply() .await .unwrap(); - let status = res.status(); - let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); - (status, String::from_utf8_lossy(&bytes).into_owned()) + let private = ids.pop().unwrap(); + let public = ids.pop().unwrap(); + (dir, manager, public, private) } - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn session_metadata_read_omits_history_without_weakening_private_reach() { - install_test_user_action_key(); - let state = AppState::new().await.unwrap(); - let private = seed_private_chat(&state, "Metadata-only synthetic fixture").await; - let mut session = state - .session_manager() - .get_session(private.id(), false) - .await - .unwrap(); - session.extension_data.set_extension_state( - "todo", - "v1", - serde_json::json!({ - "items": [{"id":"1", "text":"Inspect synthetic summary", "status":"pending"}] - }), - ); - state - .session_manager() - .update(private.id()) - .extension_data(session.extension_data) - .apply() - .await - .unwrap(); + /// `lists_work` answers a caller admitted to an `Unreadable` target without + /// reading the store. That is only sound if being admitted there means + /// being admitted to every target, so it is checked at every corner rather + /// than argued — including the served-operator standing and the switch. + #[test] + fn the_listing_fast_path_admits_nothing_the_row_check_refuses() { + for enforced in [true, false] { + for stated in CAPABILITIES { + for served_operator in CAPABILITIES { + for proof in PROOFS { + let who = HttpCaller { + enforced, + stated, + served_operator, + proof, + }; + if !who.admits(TargetTier::Unreadable) { + continue; + } + for tier in [ + TargetTier::Public, + TargetTier::Private, + TargetTier::Unreadable, + ] { + assert!( + who.admits(tier), + "the fast path would show {who:?} a {tier:?} row the row check \ + refuses" + ); + } + } + } + } + } + } - for query in ["", "?metadata_only=false", "?metadata_only=true"] { - let path = format!("{}{query}", private.id()); - let (status, body) = - get_session_with(state.clone(), &path, Some(TEST_USER_ACTION_KEY)).await; - assert_eq!(status, StatusCode::OK); - assert_eq!( - body.contains(MARKER_IN_THE_TRANSCRIPT), - query != "?metadata_only=true" - ); - let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + /// A row of running work is listed exactly when its chat would be, at + /// every (switch, capability, served standing, proof) corner, resolved + /// against a real store: its chat's tier when the chat reads, `Unreadable` + /// when it does not — and `Unreadable` when the row names no chat at all. + /// + /// ⚠ The last pair is the decision this test exists for, asserted as an + /// EQUALITY: work that names no chat is listed to exactly the callers that + /// would be shown a chat that is not there, which is to say the callers + /// shown private chats. A vaguer assertion ("a secret-only caller does not + /// see it") passes against an implementation that shows it to one more. + #[tokio::test] + async fn a_row_of_running_work_is_listed_exactly_as_its_chat_would_be() { + let (_dir, manager, public, private) = store_with_a_public_and_a_private_chat().await; + let owners = [ + (Some(public.as_str()), TargetTier::Public), + (Some(private.as_str()), TargetTier::Private), + (Some("29990101_99999"), TargetTier::Unreadable), + (None, TargetTier::Unreadable), + ]; + for enforced in [true, false] { + for stated in CAPABILITIES { + for served_operator in CAPABILITIES { + for proof in PROOFS { + let who = HttpCaller { + enforced, + stated, + served_operator, + proof, + }; + for (owner, tier) in owners { + assert_eq!( + who.lists_work(&manager, owner).await, + refuse_unless_reachable(enforced, tier, who.capability(), proof) + .is_ok(), + "{who:?} {owner:?}" + ); + } + assert_eq!( + who.lists_work(&manager, None).await, + who.lists_work(&manager, Some("29990101_99999")).await, + "{who:?}: work that names no chat is listed differently from work \ + whose chat is not there" + ); + } + } + } + } + // The shape QA measured, spelled out. + let secret_only = caller( + ProviderTier::Public, + ProviderTier::Public, + UserActionProof::Unproven, + ); + assert!( + secret_only + .lists_work(&manager, Some(public.as_str())) + .await + ); + assert!( + !secret_only + .lists_work(&manager, Some(private.as_str())) + .await + ); + assert!(!secret_only.lists_work(&manager, None).await); + } + + /// The cancel's gate IS the read's: for a chat, the same call; for work + /// that names no chat (and a handle that names nothing), the answer the + /// read gives a chat that is not there — byte for byte, status and words, + /// for every header a caller can send. + #[tokio::test] + async fn stopping_running_work_is_refused_exactly_as_reading_its_chat_is() { + use crate::routes::session::diverge_tests::{ + install_test_user_action_key, TEST_USER_ACTION_KEY, + }; + install_test_user_action_key(); + let (_dir, manager, public, private) = store_with_a_public_and_a_private_chat().await; + let header_sets: [&[(&str, &str)]; 4] = [ + &[], + &[("X-User-Action", TEST_USER_ACTION_KEY)], + &[(CALLER_PROVIDER_HEADER, "versa_azure")], + &[(CALLER_PROVIDER_HEADER, "anthropic")], + ]; + for pairs in header_sets { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.insert( + axum::http::HeaderName::try_from(*name).unwrap(), + value.parse().unwrap(), + ); + } + for chat in [&public, &private] { + assert_eq!( + work_reach(&manager, Some(chat.as_str()), &headers).await, + session_reach(&manager, chat, &headers).await, + "{pairs:?}: stopping a chat's work is not gated exactly as reading it" + ); + } + assert_eq!( + work_reach(&manager, None, &headers).await, + session_reach(&manager, "29990101_99999", &headers).await, + "{pairs:?}: work that names no chat is not refused as a chat that is not there" + ); + } + // …which refuses a secret-only caller in the read's own words. + assert_eq!( + work_reach(&manager, None, &HeaderMap::new()).await, + Err(SessionOutOfReach { + status: StatusCode::FORBIDDEN, + message: SESSION_OUT_OF_REACH, + }) + ); + assert!( + work_reach(&manager, Some(public.as_str()), &HeaderMap::new()) + .await + .is_ok() + ); + } + + /// ⚠ **The transcript gate never reads the served-operator standing**, and + /// this is the assertion that keeps it so: feeding it there would admit a + /// serve daemon's browser to private transcripts it has always been refused + /// — the one direction this change may not move. `session_reach` resolves + /// its capability from the header alone; the served input is read by + /// `http_caller`, which `session_reach` does not call. + #[test] + fn the_transcript_gate_does_not_read_the_served_operator_standing() { + let session_reach_body = crate::routes::body_of( + include_str!("session_reach.rs"), + "pub async fn session_reach(", + ); + assert!( + !session_reach_body.contains("served_operator") + && !session_reach_body.contains("http_caller("), + "the transcript gate now reads the serve operator's standing, which would admit a \ + browser to private transcripts it was always refused" + ); + assert!(session_reach_body.contains("caller_capability(headers)")); + } + + /// A knowledge base's target, at each of its corners: a private base; a + /// public one; one that does not exist; and an id that could not name one. + /// The last two are answered as the first, to a caller that proves nothing. + #[test] + fn a_knowledge_base_target_answers_absent_and_malformed_as_private() { + let root = tempfile::tempdir().unwrap(); + let svc = + biorouter_mcp::knowledge::service::KnowledgeService::new(root.path().to_path_buf()); + svc.create_base("notes", "Notes", None).unwrap(); + svc.create_base("omop", "OMOP", None).unwrap(); + biorouter_mcp::knowledge::tier::raise_unlocked(root.path(), "omop", true).unwrap(); + + assert_eq!( + knowledge_base_tier(root.path(), "notes"), + TargetTier::Public + ); + assert_eq!( + knowledge_base_tier(root.path(), "omop"), + TargetTier::Private + ); + assert_eq!( + knowledge_base_tier(root.path(), "no-such-base"), + TargetTier::Unreadable + ); + for malformed in ["../sessions", "Bad--Id", "", "a/b"] { + assert_eq!( + knowledge_base_tier(root.path(), malformed), + TargetTier::Unreadable, + "{malformed:?}" + ); + } + + let secret_only = caller( + ProviderTier::Public, + ProviderTier::Public, + UserActionProof::Unproven, + ); + let private_refusal = secret_only + .reach_knowledge_base(root.path(), "omop") + .unwrap_err(); + assert_eq!(private_refusal.message, KNOWLEDGE_BASE_OUT_OF_REACH); + assert_eq!(private_refusal.status, StatusCode::FORBIDDEN); + for other in ["no-such-base", "../sessions"] { + assert_eq!( + secret_only.reach_knowledge_base(root.path(), other), + Err(private_refusal), + "{other:?} was answered differently from a private base" + ); + } + assert!(secret_only + .reach_knowledge_base(root.path(), "notes") + .is_ok()); + + // The person at the keyboard reaches all of them; the handler then tells + // them the truth about the absent and malformed ones. + let desktop = caller( + ProviderTier::Public, + ProviderTier::Public, + UserActionProof::Proven, + ); + for id in ["omop", "notes", "no-such-base", "../sessions"] { + assert!( + desktop.reach_knowledge_base(root.path(), id).is_ok(), + "{id}" + ); + } + + // A keyless daemon says so in the knowledge base's words. + let keyless = caller( + ProviderTier::Public, + ProviderTier::Public, + UserActionProof::NoKeyInstalled, + ); + assert_eq!( + keyless + .reach_knowledge_base(root.path(), "omop") + .unwrap_err() + .message, + KNOWLEDGE_BASE_REACH_NO_KEY + ); + + // DR-15: with tiers off nothing is refused — not even the absent id, so a + // user who opted out still gets their 404 from the handler. + let off = HttpCaller { + enforced: false, + ..secret_only + }; + for id in ["omop", "no-such-base"] { + assert!(off.reach_knowledge_base(root.path(), id).is_ok(), "{id}"); + } + } + + /// The knowledge-base refusals obey every rule the chat ones do, checked by + /// the same predicates: fixed text, no digit, quote or path, the stop + /// clause last, the operator page named without the header, and neither + /// renderer marker. + #[test] + fn the_knowledge_base_refusals_keep_every_rule_the_chat_refusals_keep() { + for message in [KNOWLEDGE_BASE_OUT_OF_REACH, KNOWLEDGE_BASE_REACH_NO_KEY] { + assert!(!message.chars().any(|c| c.is_ascii_digit()), "{message}"); + assert!( + !message.contains('"') && !message.contains('\u{201c}'), + "{message}" + ); + assert!( + !message.contains('/') && !message.contains('\\'), + "{message}" + ); + assert!(!message.contains(CALLER_PROVIDER_HEADER), "{message}"); + assert!(!message.contains("versa_azure"), "{message}"); + assert!( + !message.contains(biorouter::privacy::refusal::USER_ACTION_REFUSAL_MARKER), + "{message}" + ); + assert!( + !message.contains(crate::routes::session::COPY_OF_PRIVATE_REFUSAL_MARKER), + "{message}" + ); + // It may call a base private only while offering "no such base". + assert!( + !message.contains("base is private") + || message.contains("or there is no knowledge base with that id"), + "{message}" + ); + } + let doc = include_str!("../../../../docs/deployment/programmatic-session-access.md"); + let title = doc + .lines() + .next() + .and_then(|l| l.strip_prefix("# ")) + .unwrap(); + assert!(KNOWLEDGE_BASE_OUT_OF_REACH.contains(title)); + assert!(KNOWLEDGE_BASE_OUT_OF_REACH.contains( + "Do not retry as you are; the same call will be refused again, and no setting, hook \ + or permission mode changes it." + )); + assert!(KNOWLEDGE_BASE_OUT_OF_REACH + .trim_end() + .ends_with("stop and ask the user to open it for you.")); + // "Started without a user-action key" is what the keyless knowledge-base + // tier binary keys on, and what a serve operator's browser reads. + assert!(KNOWLEDGE_BASE_REACH_NO_KEY.contains("started without a user-action key")); + assert_ne!(KNOWLEDGE_BASE_OUT_OF_REACH, SESSION_OUT_OF_REACH); + assert_ne!(KNOWLEDGE_BASE_REACH_NO_KEY, SESSION_REACH_NO_KEY); + } + + /// The knowledge route's gate is a middleware, so the scan above cannot see + /// it — but the wiring can still be lost in a refactor of `configure`, and a + /// layer that is never applied is a security control that silently does + /// nothing. + /// + /// `the_knowledge_active_gate_is_actually_wired` is what proves it FIRES; + /// this is the cheap tripwire that survives a move of that test. + #[test] + fn the_knowledge_router_carries_the_reach_gate() { + let mod_rs = include_str!("mod.rs"); + let configure = body_of(mod_rs, "pub fn configure"); + assert!( + configure.contains("session_reach::gate_knowledge_active"), + "the knowledge router no longer carries the session-reach gate" + ); + } + + /// Every route that names a base by `{id}` sits in `base_routes`, behind + /// `gate_knowledge_base`, and none sits on the outer router. The HTTP tests + /// in `tests/knowledge_routes.rs` prove the layer FIRES on the routes that + /// exist today; this is what stops a route added tomorrow landing on the + /// wrong router, where it would be ungated and nothing would say so. + #[test] + fn every_route_that_names_a_base_sits_behind_the_knowledge_base_gate() { + let router = body_of(include_str!("knowledge.rs"), "pub fn router("); + let (gated, outer) = router + .split_once(".route_layer(") + .expect("the knowledge router no longer layers the base-reach gate"); + assert!( + outer.contains("session_reach::gate_knowledge_base"), + "the knowledge router's route layer is no longer the base-reach gate" + ); + let (layer, outer) = outer + .split_once("Router::new()") + .expect("the outer knowledge router moved"); + assert!(layer.contains("gate_knowledge_base")); + assert!( + gated.matches("\"/bases/{id}").count() >= 20, + "fewer routes than expected sit behind the gate:\n{gated}" + ); + assert!( + !outer.contains("{id}"), + "a route naming a base by `{{id}}` is registered on the ungated outer router:\n{outer}" + ); + assert!( + !gated.contains("\"/bases\"") && !gated.contains("\"/active\""), + "a route that names no base was put behind the base gate" + ); + } +} + +#[cfg(test)] +mod bypass_tests { + use super::*; + use crate::routes::session::diverge_tests::{ + install_test_user_action_key, TEST_USER_ACTION_KEY, + }; + use crate::state::AppState; + use axum::body::{to_bytes, Body}; + use axum::http::Request; + use biorouter::conversation::message::Message; + use biorouter::model::ModelConfig; + use biorouter::session::SessionType; + use serial_test::serial; + use std::path::PathBuf; + use std::sync::Arc; + use tower::ServiceExt; + + /// A string that appears in the seeded chat and nowhere else, so "the + /// transcript came back" is an assertion rather than an impression. + /// + /// ⚠ **Unmistakably a fixture, and deliberately not shaped like a record.** + /// These tests seed into the developer's REAL session database — + /// `AppState::new()` opens it — so a row that ever escapes [`SeededChat`]'s + /// cleanup lands in their own sidebar. A marker that read like a patient + /// identifier would then be a privacy incident invented by the test suite of + /// the privacy feature. + const MARKER_IN_THE_TRANSCRIPT: &str = "task58-transcript-marker-not-real-data"; + + async fn get_session_with( + state: Arc, + session_id: &str, + user_action: Option<&str>, + ) -> (StatusCode, String) { + let app = crate::routes::session::routes(state); + let mut builder = Request::builder() + .method("GET") + .uri(format!("/sessions/{session_id}")); + if let Some(key) = user_action { + builder = builder.header("X-User-Action", key); + } + let res = app + .oneshot(builder.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = res.status(); + let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn session_metadata_read_omits_history_without_weakening_private_reach() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let private = seed_private_chat(&state, "Metadata-only synthetic fixture").await; + let mut session = state + .session_manager() + .get_session(private.id(), false) + .await + .unwrap(); + session.extension_data.set_extension_state( + "todo", + "v1", + serde_json::json!({ + "items": [{"id":"1", "text":"Inspect synthetic summary", "status":"pending"}] + }), + ); + state + .session_manager() + .update(private.id()) + .extension_data(session.extension_data) + .apply() + .await + .unwrap(); + + for query in ["", "?metadata_only=false", "?metadata_only=true"] { + let path = format!("{}{query}", private.id()); + let (status, body) = + get_session_with(state.clone(), &path, Some(TEST_USER_ACTION_KEY)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body.contains(MARKER_IN_THE_TRANSCRIPT), + query != "?metadata_only=true" + ); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(json["id"], private.id()); assert_eq!( json["extension_data"]["todo.v1"]["items"][0]["text"], @@ -2215,40 +3187,1503 @@ mod bypass_tests { // Step 4.1's other half, for this route: a PUBLIC chat is untouched by // the layer and reaches the handler, which answers on its own terms. - let (status, body) = post_knowledge_active( - state.clone(), - serde_json::json!({ "session_id": public.id(), "primary_kb": NO_SUCH_KB }), - None, - ) - .await; - assert_eq!( - status, - StatusCode::BAD_REQUEST, - "the layer refused an unproven caller on a PUBLIC chat: {body}" - ); - assert!( - body.contains(NO_SUCH_KB), - "this 400 did not come from `set_selection`: only it echoes the kb id: {body}" - ); + // + // ⚠ Since QA's 2026-09-10 sweep the handler's own terms, for an + // unproven caller naming a base that does not exist, are the + // KNOWLEDGE-BASE refusal — the one it gives for a private base, so that + // pinning is not a way to ask which ids exist. That body is still one + // only the handler can produce (the layer's is `SESSION_OUT_OF_REACH`), + // so it proves the layer let the request through as well as the old + // 400 did. The person at the keyboard still gets the 400 that names + // the id, from `set_selection`. + for session in [Some(public.id()), None] { + let mut body = serde_json::json!({ "primary_kb": NO_SUCH_KB }); + if let Some(id) = session { + body["session_id"] = serde_json::json!(id); + } + let (status, answer) = post_knowledge_active(state.clone(), body.clone(), None).await; + assert_eq!( + (status, answer.as_str()), + (StatusCode::FORBIDDEN, KNOWLEDGE_BASE_OUT_OF_REACH), + "{session:?}: the layer refused an unproven caller the session gate should have \ + let through, or the handler told it whether the base exists" + ); + let (status, answer) = + post_knowledge_active(state.clone(), body, Some(TEST_USER_ACTION_KEY)).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{session:?}: {answer}"); + assert!( + answer.contains(NO_SUCH_KB), + "this 400 did not come from `set_selection`: only it echoes the kb id: {answer}" + ); + } + } - // A body naming NO session addresses the machine-wide scope, not a - // chat, so the gate has nothing to resolve and must let it through to - // the handler that owns it. - let (status, body) = post_knowledge_active( - state.clone(), - serde_json::json!({ "primary_kb": NO_SUCH_KB }), - None, - ) - .await; - assert_eq!( - status, - StatusCode::BAD_REQUEST, - "the gate refused a request that names no chat at all: {body}" - ); - assert!( - body.contains(NO_SUCH_KB), - "this 400 did not come from `set_selection`: only it echoes the kb id: {body}" - ); + // ─── QA 2026-09-10 (H2 / M1 / M2 / F0): the rest of the chat surface ─── + // + // Every test below drives the REAL router tree with the headers each + // caller really sends. "Secret only" is the caller QA measured: a public + // chat's shell that recovered the daemon secret with `ps eww`. The daemon + // cannot tell it from any other client, so it is a public model. + + /// The proof-of-user header, exactly as the desktop app sends it. + const PROOF: (&str, &str) = ("X-User-Action", TEST_USER_ACTION_KEY); + + /// A caller stating that it runs under an institution-hosted model — the + /// CLI's shape, and the capability half of the gate. + const PRIVATE_CAPABILITY: (&str, &str) = (CALLER_PROVIDER_HEADER, "versa_azure"); + + /// One request through `routes::configure`, the tree `commands::agent` + /// serves, so a gate wired onto the wrong router is measured rather than + /// assumed. `check_token` is layered outside `configure`, so every request + /// here already holds the daemon secret — which is the whole premise. + async fn call( + state: Arc, + method: &str, + uri: &str, + body: Option, + headers: &[(&str, &str)], + ) -> (StatusCode, String) { + let app = crate::routes::configure(state, "qa-h2-f0-sweep-secret".to_string()); + let mut builder = Request::builder().method(method).uri(uri); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let body = match body { + Some(json) => { + builder = builder.header("content-type", "application/json"); + Body::from(serde_json::to_vec(&json).unwrap()) + } + None => Body::empty(), + }; + let res = app.oneshot(builder.body(body).unwrap()).await.unwrap(); + let status = res.status(); + let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) + } + + /// Every route that names ONE chat and answered a secret-only caller when + /// QA measured it, as `(method, uri, body)` for a given id. + /// + /// ⚠ **Destructive last.** Before this change the first row deleted the + /// chat outright, which would turn every later row into a probe of an + /// absent id and hide what each of them did to a real one. + fn chat_addressing_routes(id: &str) -> Vec<(&'static str, String, Option)> { + vec![ + ("GET", format!("/sessions/{id}/extensions"), None), + ("GET", format!("/sessions/{id}/usage"), None), + ("GET", format!("/agent/tools?session_id={id}"), None), + ( + "GET", + format!("/agent/callable_tool_count?session_id={id}"), + None, + ), + ( + "POST", + "/workflows/create".to_string(), + Some(serde_json::json!({ "session_id": id })), + ), + ( + "PUT", + format!("/sessions/{id}/name"), + Some(serde_json::json!({ "name": "renamed by an unproven caller" })), + ), + ( + "PUT", + format!("/sessions/{id}/user_workflow_values"), + Some(serde_json::json!({ "userWorkflowValues": {} })), + ), + ( + "POST", + "/skills/session".to_string(), + Some(serde_json::json!({ "sessionId": id, "add": ["qa-h2-probe-skill"] })), + ), + ( + "POST", + format!("/sessions/{id}/edit_message"), + Some(serde_json::json!({ "timestamp": 0, "editType": "edit" })), + ), + ("DELETE", format!("/sessions/{id}"), None), + ] + } + + /// **F0, and the sweep it asked for.** QA held nothing but the daemon + /// secret and was refused a private chat's transcript — then deleted the + /// same chat, four of four. Every route that names a chat now asks the + /// read's own gate, so each one answers an unproven caller exactly as + /// `GET /sessions/{id}` does: the same status, the same bytes, and the same + /// answer for a chat that does not exist. + /// + /// Mismatches are collected rather than asserted one at a time, so a + /// regression reports every door it reopened instead of the first. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn every_route_that_names_a_private_chat_refuses_it_exactly_as_the_read_does() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let private = seed_private_chat(&state, "QA F0 sweep (test fixture)").await; + // Syntactically a session id, and not a row on this machine. + let absent = "29990101_424242"; + + let before = state + .session_manager() + .get_session(private.id(), true) + .await + .unwrap(); + + let (read_status, read_body) = get_session_with(state.clone(), private.id(), None).await; + assert_eq!(read_status, StatusCode::FORBIDDEN); + assert_eq!( + read_body, SESSION_OUT_OF_REACH, + "the read path's refusal is what every route below is compared against" + ); + + let mut leaks = Vec::new(); + for target in [private.id(), absent] { + for (method, uri, body) in chat_addressing_routes(target) { + let (status, got) = call(state.clone(), method, &uri, body, &[]).await; + if status != read_status || got != read_body { + leaks.push(format!("{method} {uri} -> {status}: {got:.160}")); + } + } + } + assert!( + leaks.is_empty(), + "a caller holding nothing but the daemon secret was answered differently from \ + `GET /sessions/{{id}}` by {} route(s):\n {}", + leaks.len(), + leaks.join("\n ") + ); + + // …and nothing moved: the chat is still there, under its own name, with + // its transcript and its extension state. + let after = state + .session_manager() + .get_session(private.id(), true) + .await + .expect("an unproven caller removed a private chat"); + assert_eq!( + after.name, before.name, + "an unproven caller renamed a private chat" + ); + assert_eq!( + serde_json::to_value(&after.conversation).unwrap(), + serde_json::to_value(&before.conversation).unwrap(), + "an unproven caller changed a private chat's transcript" + ); + assert_eq!( + serde_json::to_value(&after.extension_data).unwrap(), + serde_json::to_value(&before.extension_data).unwrap(), + "an unproven caller wrote into a private chat's per-chat state" + ); + } + + /// The other half, which "refuse the unproven caller" alone would satisfy + /// by refusing everyone: the person at the keyboard (the proof) and a + /// program running under a private model (the capability) both still get + /// through. Each route is driven to a status only its own body can produce, + /// chosen so nothing expensive or irreversible runs: the turn lock (409), a + /// queued child (424), a chat with no workflow (404) or no transcript (an + /// `error` field). + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn the_person_at_the_keyboard_and_a_private_caller_still_reach_each_one() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + + for credential in [PROOF, PRIVATE_CAPABILITY] { + let private = seed_private_chat(&state, "QA F0 admitted arm (test fixture)").await; + let id = private.id(); + let headers = [credential]; + + let (status, body) = call( + state.clone(), + "GET", + &format!("/sessions/{id}/extensions"), + None, + &headers, + ) + .await; + assert_eq!(status, StatusCode::OK, "{credential:?} extensions: {body}"); + let (status, body) = call( + state.clone(), + "GET", + &format!("/sessions/{id}/usage"), + None, + &headers, + ) + .await; + assert_eq!(status, StatusCode::OK, "{credential:?} usage: {body}"); + + let (status, body) = call( + state.clone(), + "PUT", + &format!("/sessions/{id}/name"), + Some(serde_json::json!({ "name": "renamed by the user" })), + &headers, + ) + .await; + assert_eq!(status, StatusCode::OK, "{credential:?} rename: {body}"); + + // No workflow was ever attached, so the handler's own 404 is the + // proof it ran. + let (status, body) = call( + state.clone(), + "PUT", + &format!("/sessions/{id}/user_workflow_values"), + Some(serde_json::json!({ "userWorkflowValues": {} })), + &headers, + ) + .await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "{credential:?} workflow values: {body}" + ); + + let (status, body) = call( + state.clone(), + "POST", + "/skills/session", + Some(serde_json::json!({ "sessionId": id, "add": ["qa-h2-probe-skill"] })), + &headers, + ) + .await; + assert_eq!(status, StatusCode::OK, "{credential:?} skills: {body}"); + + // Held so an admitted in-place edit stops at the lock instead of + // truncating the chat. + let turn_guard = state + .try_begin_turn_idempotent(id, tokio_util::sync::CancellationToken::new(), None) + .expect("no turn is running in a session created a moment ago"); + let (status, body) = call( + state.clone(), + "POST", + &format!("/sessions/{id}/edit_message"), + Some(serde_json::json!({ "timestamp": 0, "editType": "edit" })), + &headers, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{credential:?} edit: {body}"); + drop(turn_guard); + + // DELETE last: admitted, it removes the row, which is the point. + let (status, body) = call( + state.clone(), + "DELETE", + &format!("/sessions/{id}"), + None, + &headers, + ) + .await; + assert_eq!(status, StatusCode::OK, "{credential:?} delete: {body}"); + assert!( + state + .session_manager() + .get_session(id, false) + .await + .is_err(), + "an admitted delete left the row behind" + ); + } + + // `/workflows/create` on a chat whose provider cannot be built here + // (no credentials in the sandbox) answers with a 200 whose `error` + // field is the handler's own — measured before this change as + // "Failed to create workflow: Provider not set". Nothing reaches a + // model, and the gate cannot produce that body. + let empty = seed_private_chat_without_messages(&state, "QA F0 empty (test fixture)").await; + let (status, body) = call( + state.clone(), + "POST", + "/workflows/create", + Some(serde_json::json!({ "session_id": empty.id() })), + &[PROOF], + ) + .await; + assert_eq!(status, StatusCode::OK, "workflows/create: {body}"); + let answer: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!( + answer["error"].is_string() && !body.contains(SESSION_OUT_OF_REACH), + "workflows/create did not reach its own handler: {body}" + ); + // The admitted request built an agent for the chat. Dropped here: this + // database recycles `YYYYMMDD_N` ids once a row is deleted, and a + // cached agent left under this id would be found by the next test's + // fresh chat and read as something that test's request created. + let _ = state.agent_manager.remove_session(empty.id()).await; + + // The two tool routes, on a QUEUED child: admitted, each reaches the + // not-ready answer (424) rather than minting an agent for the chat. + let child = seed_queued_private_child(&state).await; + for uri in [ + format!("/agent/tools?session_id={}", child.chat.id()), + format!("/agent/callable_tool_count?session_id={}", child.chat.id()), + ] { + let (status, body) = call(state.clone(), "GET", &uri, None, &[PROOF]).await; + assert_eq!(status, StatusCode::FAILED_DEPENDENCY, "{uri}: {body}"); + } + } + + /// **M2, as QA measured it.** `GET /agent/tools?session_id=` + /// handed a secret-only caller the private chat's tool names while + /// `add_extension` on the same chat refused. Asserted on the queued-child + /// shape so the admitted arm is observable without an agent being built. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn a_private_chats_tool_surface_is_refused_as_its_transcript_is() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let child = seed_queued_private_child(&state).await; + // Measure THIS request: an agent cached under a recycled id by an + // earlier test is not one this request created. + let _ = state.agent_manager.remove_session(child.chat.id()).await; + assert!(state.peek_agent(child.chat.id()).await.is_none()); + for uri in [ + format!("/agent/tools?session_id={}", child.chat.id()), + format!("/agent/callable_tool_count?session_id={}", child.chat.id()), + ] { + let (status, body) = call(state.clone(), "GET", &uri, None, &[]).await; + assert_eq!( + (status, body.as_str()), + (StatusCode::FORBIDDEN, SESSION_OUT_OF_REACH), + "{uri} answered a secret-only caller" + ); + } + assert!( + state.peek_agent(child.chat.id()).await.is_none(), + "a refused caller still materialised an agent for the chat" + ); + } + + /// A public chat is untouched on every one of these routes, for a caller + /// that proves nothing — the gate is a condition on the target, never a + /// wall in front of the client. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn a_public_chat_is_untouched_by_the_sweep() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let public = seed_chat( + &state, + "QA F0 public (test fixture)", + SessionClassification::Public, + ) + .await; + let id = public.id(); + for (method, uri, expected) in [ + ("GET", format!("/sessions/{id}/extensions"), StatusCode::OK), + ("GET", format!("/sessions/{id}/usage"), StatusCode::OK), + ("DELETE", format!("/sessions/{id}"), StatusCode::OK), + ] { + let (status, body) = call(state.clone(), method, &uri, None, &[]).await; + assert_eq!(status, expected, "{method} {uri}: {body}"); + } + } + + /// **M1.** `GET /sessions` returned every row — 5,543 of them, 792 private, + /// each with its title, directory and privacy reason — to a caller the + /// singular read refuses. A listing now shows a caller exactly the rows the + /// singular gate would admit it to, so it cannot learn from the list what + /// per-id probing is worded not to tell it. + /// + /// Answered here is `session_reach.rs`'s open question: **filter, not + /// refuse.** A refused list would break every client for the public chats + /// the gate is deliberately inert on. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn every_listing_shows_a_caller_only_the_chats_it_could_open() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let private = seed_private_chat(&state, "QA M1 private (test fixture)").await; + let public = seed_chat( + &state, + "QA M1 public (test fixture)", + SessionClassification::Public, + ) + .await; + + for (headers, sees_private) in [ + (&[][..], false), + (&[PROOF][..], true), + (&[PRIVATE_CAPABILITY][..], true), + ] { + for uri in ["/sessions", "/sessions?include_subagents=true"] { + let (status, body) = call(state.clone(), "GET", uri, None, headers).await; + assert_eq!(status, StatusCode::OK, "{uri}: {body}"); + assert!( + body.contains(public.id()), + "{uri} {headers:?} lost a public chat" + ); + assert_eq!( + body.contains(private.id()), + sees_private, + "{uri} {headers:?}: private chat listed = {}", + body.contains(private.id()) + ); + if !sees_private { + assert!( + !body.contains("QA M1 private"), + "{uri} leaked the private chat's title without its id" + ); + } + } + let ids = sidebar_ids(&state, 50, headers).await; + assert!(ids.contains(&public.id().to_string())); + assert_eq!(ids.contains(&private.id().to_string()), sees_private); + } + } + + /// Paging a FILTERED sidebar must still walk every visible row exactly + /// once: a filter applied after `LIMIT` would hand back short, ragged pages + /// and let `has_more` count the rows it hid. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn a_filtered_sidebar_pages_through_every_visible_chat_exactly_once() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let mut seeded = Vec::new(); + for i in 0..4 { + seeded.push( + seed_chat( + &state, + &format!("QA M1 paging public {i} (test fixture)"), + SessionClassification::Public, + ) + .await, + ); + seeded.push( + seed_private_chat(&state, &format!("QA M1 paging private {i} (test fixture)")) + .await, + ); + } + let rows = sidebar_ids(&state, 3, &[]).await; + let mut deduped = rows.clone(); + deduped.sort(); + deduped.dedup(); + assert_eq!( + rows.len(), + deduped.len(), + "a filtered page repeated a row: {rows:?}" + ); + for chat in &seeded { + let tier = state + .session_manager() + .get_session(chat.id(), false) + .await + .unwrap() + .privacy_tier; + assert_eq!( + rows.contains(&chat.id().to_string()), + tier == SessionClassification::Public, + "{} ({tier:?}) was {} the unproven sidebar", + chat.id(), + if rows.contains(&chat.id().to_string()) { + "in" + } else { + "missing from" + } + ); + } + } + + /// `GET /schedule/list` named, for every schedule on the machine, the chat + /// that created it and the chat each running one is running in — to any + /// holder of the daemon secret. + /// + /// ⚠ **Redaction, not omission, and this is the one listing where that is + /// right.** Everywhere else a row IS its chat's content, so the row goes. + /// A schedule is not a chat: it is a cron line and a workflow path that + /// merely *name* chats, and an idle or paused schedule names none at all. + /// Dropping rows here by the "names no chat is unreadable" rule the sibling + /// routes use would therefore hide every non-running schedule from every + /// ordinary caller — emptying the Schedules interface in order to close an + /// association. So the rows all stay and the two chat-naming FIELDS go. + /// + /// The decision is exercised here rather than over HTTP because seeding a + /// schedule needs `add_scheduled_job`, which registers a task on the + /// process-global tokio-cron-scheduler while every `#[tokio::test]` brings + /// its own runtime: it succeeds alone and fails with `CantAdd` in the suite + /// — measured, not assumed. The chats, their tiers and the caller are all + /// real; only the rows are hand-built. The route's wiring to the function + /// under test is asserted separately, below. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn a_schedule_listing_names_only_the_chats_the_caller_could_open() { + use biorouter::scheduler::ScheduledJob; + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let private = seed_private_chat(&state, "Schedule list private (test fixture)").await; + let public = seed_chat( + &state, + "Schedule list public (test fixture)", + SessionClassification::Public, + ) + .await; + + let row = |id: &str, chat: Option<&str>, running: bool| ScheduledJob { + id: id.to_string(), + source: format!("/tmp/{id}.yaml"), + cron: "0 0 0 1 1 *".to_string(), + last_run: None, + currently_running: running, + paused: false, + current_session_id: running.then(|| chat.unwrap().to_string()), + process_start_time: running.then(chrono::Utc::now), + run_count: 0, + max_runs: None, + creator_session_id: chat.map(str::to_owned), + last_error: None, + owns_source: None, + }; + + for (headers, sees_private) in [(Vec::new(), false), (vec![PROOF], true)] { + let mut map = HeaderMap::new(); + for (name, value) in &headers { + map.insert( + axum::http::HeaderName::from_static("x-user-action"), + value.parse().unwrap(), + ); + let _ = name; + } + let caller = http_caller(&map).await; + let mut jobs = vec![ + row("sched-in-private", Some(private.id()), true), + row("sched-in-public", Some(public.id()), true), + row("sched-made-by-private", Some(private.id()), false), + // The row my objection to a row-level rule was about: it names + // no chat at all, and it must survive for EVERY caller. + row("sched-idle-nameless", None, false), + ]; + crate::routes::schedule::redact_unreachable_chats( + &caller, + state.session_manager(), + &mut jobs, + ) + .await; + + assert_eq!(jobs.len(), 4, "a row was dropped; rows must never be"); + assert!( + jobs.iter().any(|j| j.id == "sched-idle-nameless"), + "the idle schedule that names no chat was dropped — the exact regression \ + redaction exists to avoid" + ); + + let private_named = jobs.iter().any(|j| { + j.current_session_id.as_deref() == Some(private.id()) + || j.creator_session_id.as_deref() == Some(private.id()) + }); + assert_eq!( + private_named, + sees_private, + "a private chat was {} the schedule listing (proof: {sees_private})", + if private_named { + "named in" + } else { + "missing from" + } + ); + + // The gate is inert on public chats, here as everywhere. + assert!( + jobs.iter().any(|j| { + j.current_session_id.as_deref() == Some(public.id()) + && j.creator_session_id.as_deref() == Some(public.id()) + }), + "a public chat's schedule stopped naming it" + ); + } + } + + /// The route is actually wired to the redaction the test above exercises. + /// Without this, that test would keep passing while `list_schedules` handed + /// the unredacted rows straight out. + #[test] + fn the_schedule_listing_route_redacts_before_it_answers() { + let schedule_rs = include_str!("schedule.rs"); + let handler = crate::routes::body_of(schedule_rs, "async fn list_schedules("); + let redact = handler + .find("redact_unreachable_chats(") + .expect("`GET /schedule/list` does not redact the chats it names"); + let answer = handler + .find("Ok(Json(ListSchedulesResponse") + .expect("`list_schedules` no longer answers with ListSchedulesResponse"); + assert!( + redact < answer, + "`list_schedules` answers before it redacts the chat-naming fields" + ); + } + + /// `GET /schedule/{id}/sessions` lists a schedule's runs by name and + /// directory — the same rows, through a different door. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn a_schedules_run_list_is_filtered_like_every_other_listing() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + const SCHEDULE: &str = "qa-m1-probe-schedule"; + let private = seed_private_chat(&state, "QA M1 scheduled private (test fixture)").await; + let public = seed_chat( + &state, + "QA M1 scheduled public (test fixture)", + SessionClassification::Public, + ) + .await; + for chat in [&private, &public] { + state + .session_manager() + .update(chat.id()) + .schedule_id(Some(SCHEDULE.to_string())) + .apply() + .await + .unwrap(); + } + for (headers, sees_private) in [(&[][..], false), (&[PROOF][..], true)] { + let (status, body) = call( + state.clone(), + "GET", + &format!("/schedule/{SCHEDULE}/sessions?limit=50"), + None, + headers, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body.contains(public.id())); + assert_eq!( + body.contains(private.id()), + sees_private, + "{headers:?}: {body}" + ); + } + } + + /// A marker-carrying row in the process-wide active-work registry, with a + /// cancel action that records whether it fired. + /// + /// ⚠ The registry is process-global and other tests in this binary register + /// into it concurrently, so every assertion below is about THESE ids and + /// markers and never about the size or shape of the whole list. + struct RunningWork { + guard: biorouter_mcp::active_work::ActiveWorkGuard, + stopped: Arc, + } + + impl RunningWork { + fn register( + kind: biorouter_mcp::active_work::ActiveWorkKind, + marker: &str, + owner: Option<&str>, + ) -> Self { + let stopped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = stopped.clone(); + let guard = biorouter_mcp::active_work::ActiveWorkGuard::register( + kind, + format!("{marker}-title"), + Some(format!("{marker}-detail")), + owner.map(str::to_string), + Some(Arc::new(move || { + flag.store(true, std::sync::atomic::Ordering::SeqCst) + })), + ); + Self { guard, stopped } + } + + fn id(&self) -> &str { + self.guard.id() + } + + fn stopped(&self) -> bool { + self.stopped.load(std::sync::atomic::Ordering::SeqCst) + } + } + + /// The ids `GET /active_work` hands this caller, compared EXACTLY — a + /// substring test would read `sub-1` as present whenever `sub-12` is. + fn active_work_ids(body: &str) -> std::collections::HashSet { + let json: serde_json::Value = + serde_json::from_str(body).unwrap_or_else(|e| panic!("{e}: {body}")); + json["items"] + .as_array() + .unwrap_or_else(|| panic!("no `items` array: {body}")) + .iter() + .map(|item| item["id"].as_str().unwrap().to_string()) + .collect() + } + + /// `GET /active_work` lists every running background job, subagent, + /// detached turn and scheduled run, and each row carries its chat's id and a + /// title and detail holding the SHELL COMMAND or TASK PROMPT — the chat's + /// content, handed to a caller holding nothing but the daemon secret. It is + /// filtered now exactly as `GET /sessions` is: a row is shown to a caller + /// that could open its chat, omitted (never redacted) for any other. + /// + /// ⚠ **A row that names no chat is answered as a private chat's row**, and so + /// is one whose chat this daemon cannot read. Its command came from SOME + /// chat, and the registry cannot say which; showing it to a public caller + /// would be the one way left to read a private chat's commands. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn running_work_is_listed_only_to_a_caller_that_could_open_its_chat() { + use biorouter_mcp::active_work::ActiveWorkKind; + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let private = seed_private_chat(&state, "Active work private (test fixture)").await; + let public = seed_chat( + &state, + "Active work public (test fixture)", + SessionClassification::Public, + ) + .await; + + let in_public = RunningWork::register( + ActiveWorkKind::BackgroundJob, + "awl-public-marker", + Some(public.id()), + ); + let withheld = [ + ( + RunningWork::register( + ActiveWorkKind::Subagent, + "awl-private-marker", + Some(private.id()), + ), + "awl-private-marker", + "a private chat's work", + ), + ( + RunningWork::register( + ActiveWorkKind::ForegroundCommand, + "awl-unowned-marker", + None, + ), + "awl-unowned-marker", + "work that names no chat", + ), + ( + RunningWork::register( + ActiveWorkKind::DetachedTurn, + "awl-dangling-marker", + Some("29990101_99999"), + ), + "awl-dangling-marker", + "work whose chat this daemon cannot read", + ), + ]; + + for (headers, sees_all) in [ + (&[][..], false), + (&[PROOF][..], true), + (&[PRIVATE_CAPABILITY][..], true), + ] { + let (status, body) = call(state.clone(), "GET", "/active_work", None, headers).await; + assert_eq!(status, StatusCode::OK, "{headers:?}: {body}"); + let ids = active_work_ids(&body); + assert!( + ids.contains(in_public.id()) && body.contains("awl-public-marker-detail"), + "{headers:?} lost a public chat's work: {body}" + ); + for (work, marker, what) in &withheld { + assert_eq!( + ids.contains(work.id()), + sees_all, + "{headers:?}: {what} listed = {}", + ids.contains(work.id()) + ); + if !sees_all { + assert!( + !body.contains(marker), + "{headers:?} leaked {what}'s command or prompt without its id: {body}" + ); + } + } + } + } + + /// `POST /active_work/{id}/cancel` stopped any of those rows by its registry + /// id, for a caller holding nothing but the daemon secret. The id is + /// resolved to the chat that owns the work and the READ's own gate is + /// applied, so stopping a chat's work is never easier than reading the chat: + /// the same status, the same bytes — and the same answer for work that names + /// no chat, for a handle that names nothing, and for a schedule that is not + /// running, so a refusal says nothing about which it was. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn running_work_is_cancelled_only_by_a_caller_that_could_open_its_chat() { + use biorouter_mcp::active_work::ActiveWorkKind; + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let private = seed_private_chat(&state, "Active work cancel private (test fixture)").await; + let public = seed_chat( + &state, + "Active work cancel public (test fixture)", + SessionClassification::Public, + ) + .await; + let in_private = RunningWork::register( + ActiveWorkKind::Subagent, + "awc-private-marker", + Some(private.id()), + ); + let unowned = RunningWork::register( + ActiveWorkKind::ForegroundCommand, + "awc-unowned-marker", + None, + ); + let in_public = RunningWork::register( + ActiveWorkKind::BackgroundJob, + "awc-public-marker", + Some(public.id()), + ); + let cancel = |id: &str| format!("/active_work/{id}/cancel"); + + // The words the read itself answers this caller with. + let read = call( + state.clone(), + "GET", + &format!("/sessions/{}", private.id()), + None, + &[], + ) + .await; + assert_eq!( + read, + (StatusCode::FORBIDDEN, SESSION_OUT_OF_REACH.to_string()) + ); + + for (id, what) in [ + (in_private.id(), "a private chat's work"), + (unowned.id(), "work that names no chat"), + ("bg-999999999", "a handle that names nothing"), + ( + "sched:awc-no-such-schedule", + "a schedule that is not running", + ), + ] { + let answer = call(state.clone(), "POST", &cancel(id), None, &[]).await; + assert_eq!( + answer, read, + "{what} was not refused exactly as the private chat's read is" + ); + } + assert!( + !in_private.stopped(), + "a caller holding only the daemon secret stopped a private chat's work" + ); + assert!( + !unowned.stopped(), + "a caller holding only the daemon secret stopped work that names no chat" + ); + + // A public chat's work is still anyone's to stop, as it always was. + let (status, body) = call(state.clone(), "POST", &cancel(in_public.id()), None, &[]).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(in_public.stopped()); + + // The person at the keyboard, and a program on a private model, stop + // anything — and are told the truth about a handle that names nothing. + let (status, body) = call( + state.clone(), + "POST", + &cancel(in_private.id()), + None, + &[PROOF], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(in_private.stopped()); + let (status, body) = call( + state.clone(), + "POST", + &cancel(unowned.id()), + None, + &[PRIVATE_CAPABILITY], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(unowned.stopped()); + let (status, _) = call( + state.clone(), + "POST", + &cancel("bg-999999999"), + None, + &[PROOF], + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// The knowledge-base layer, through the tree the daemon SERVES: nested + /// under `/knowledge` by `configure`, beneath `gate_knowledge_active`. Every + /// other test of it drives `knowledge::router` bare, and a layer that reads + /// its `{id}` from the matched route is exactly the kind of thing `nest` can + /// change underneath it. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn the_knowledge_base_gate_fires_under_the_served_router_tree() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let kb = format!("qa-h2-served-{}", std::process::id()); + let root = state.knowledge_service.root().to_path_buf(); + state + .knowledge_service + .create_base(&kb, "QA H2", None) + .unwrap(); + let page = root.join(&kb).join("knowledge").join("x.md"); + std::fs::create_dir_all(page.parent().unwrap()).unwrap(); + std::fs::write(&page, "# x\n\nqa-h2-served-marker\n").unwrap(); + biorouter_mcp::knowledge::tier::raise_unlocked(&root, &kb, true).unwrap(); + + let uri = format!("/knowledge/bases/{kb}/page?path=knowledge/x.md"); + let (status, body) = call(state.clone(), "GET", &uri, None, &[]).await; + assert_eq!( + (status, body.as_str()), + (StatusCode::FORBIDDEN, KNOWLEDGE_BASE_OUT_OF_REACH), + "the served tree handed a secret-only caller a private base's page" + ); + let (status, body) = call(state.clone(), "GET", &uri, None, &[PROOF]).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body.contains("qa-h2-served-marker")); + let (status, body) = call(state.clone(), "GET", "/knowledge/bases", None, &[]).await; + assert_eq!(status, StatusCode::OK); + assert!( + !body.contains(&kb), + "the served list named a private base: {body}" + ); + + let _ = state.knowledge_service.delete_base_async(&kb, None).await; + } + + /// Appears in the seeded knowledge pages and nowhere else. + const KB_SENTINEL: &str = "qa-h2-lib-sweep-marker-not-real-data"; + + /// Two bases in the served tree's knowledge store, each with one page and + /// one commit; the first is then ratcheted private the way a private chat's + /// ingest leaves it. Deleted on drop — including when an assertion fails — + /// because every test in this binary shares that store. + struct SeededBases { + state: Arc, + private: String, + public: String, + /// The private base's newest commit, for the history-shaped routes. + sha: String, + } + + impl Drop for SeededBases { + fn drop(&mut self) { + let root = self.state.knowledge_service.root().to_path_buf(); + for id in [&self.private, &self.public] { + let _ = self.state.knowledge_service.delete_base(id); + let _ = std::fs::remove_dir_all(root.join(id)); + } + } + } + + async fn seed_bases(state: &Arc, label: &str) -> SeededBases { + let pid = std::process::id(); + let mut seeded = SeededBases { + state: state.clone(), + private: format!("qa-{label}-private-{pid}"), + public: format!("qa-{label}-public-{pid}"), + sha: String::new(), + }; + for (id, name) in [ + (seeded.private.clone(), "QA private base (test fixture)"), + (seeded.public.clone(), "QA public base (test fixture)"), + ] { + let (status, body) = call( + state.clone(), + "POST", + "/knowledge/bases", + Some(serde_json::json!({ "id": id, "name": name })), + &[PROOF], + ) + .await; + assert_eq!(status, StatusCode::OK, "creating {id}: {body}"); + let (status, body) = call( + state.clone(), + "PUT", + &format!("/knowledge/bases/{id}/pages/knowledge/x.md"), + Some(serde_json::json!({ + "content": biorouter_mcp::knowledge::page_fixtures::valid_page( + "note", + "X", + &format!("# X\n\n{KB_SENTINEL} in {id}"), + ), + "commit_message": "seed", + })), + &[PROOF], + ) + .await; + assert_eq!(status, StatusCode::OK, "seeding {id}: {body}"); + } + let root = state.knowledge_service.root().to_path_buf(); + biorouter_mcp::knowledge::tier::raise_unlocked(&root, &seeded.private, true).unwrap(); + let (status, body) = call( + state.clone(), + "GET", + &format!("/knowledge/bases/{}/history", seeded.private), + None, + &[PROOF], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let history: serde_json::Value = serde_json::from_str(&body).unwrap(); + seeded.sha = history[0]["commit_sha"].as_str().unwrap().to_string(); + seeded + } + + /// Every route under `/knowledge/bases/{id}` in the served tree, as + /// `(method, uri, body)`. Macros name a provider the registry does not + /// know, so an admitted one stops with a 400 long before any model. + /// + /// ⚠ **Destructive last**, for the reason the chat sweep gives. + fn base_addressing_routes( + id: &str, + sha: &str, + other: &str, + ) -> Vec<(&'static str, String, Option)> { + let model = serde_json::json!({ "provider": "qa-h2-no-such-provider", "model": "m" }); + let page = biorouter_mcp::knowledge::page_fixtures::valid_page( + "note", + "X", + "overwritten by an unproven caller", + ); + let base = format!("/knowledge/bases/{id}"); + vec![ + ("GET", base.clone(), None), + ("GET", format!("{base}/tier"), None), + ("GET", format!("{base}/graph"), None), + ("GET", format!("{base}/location"), None), + ("GET", format!("{base}/page?path=knowledge/x.md"), None), + ("GET", format!("{base}/pages"), None), + ("GET", format!("{base}/pages/knowledge/x.md"), None), + ("GET", format!("{base}/history"), None), + ( + "POST", + format!("{base}/preview"), + Some(serde_json::json!({ "commit_sha": sha, "path": "knowledge/x.md" })), + ), + ("GET", format!("{base}/export"), None), + ( + "POST", + format!("{base}/query"), + Some(serde_json::json!({ "question": "what is in it?", "model": model })), + ), + ( + "POST", + format!("{base}/lint"), + Some(serde_json::json!({ "model": model })), + ), + ("POST", format!("{base}/sources/s1/reclassify"), None), + ( + "POST", + format!("{base}/tier"), + Some(serde_json::json!({ "tier": "public" })), + ), + ( + "POST", + format!("{base}/merge"), + Some(serde_json::json!({ "source_kb_id": other })), + ), + ( + "PUT", + format!("{base}/sources/s1/credibility"), + Some(serde_json::json!({})), + ), + ( + "PUT", + base.clone(), + Some(serde_json::json!({ "name": "renamed by an unproven caller" })), + ), + ( + "PUT", + format!("{base}/default-model"), + Some(serde_json::json!({ "model": model })), + ), + ( + "PUT", + format!("{base}/pages/knowledge/x.md"), + Some(serde_json::json!({ "content": page, "commit_message": "overwrite" })), + ), + ( + "POST", + format!("{base}/raw"), + Some(serde_json::json!({ "text": "an unproven raw source", "title": "t" })), + ), + ( + "POST", + format!("{base}/ingest"), + Some(serde_json::json!({ "source": { "text": "t" }, "model": model })), + ), + ( + "POST", + format!("{base}/ingest-conversation"), + Some(serde_json::json!({ "session_ids": ["29990101_1"], "model": model })), + ), + ( + "POST", + format!("{base}/restore"), + Some(serde_json::json!({ "commit_sha": sha })), + ), + ("DELETE", base, None), + ] + } + + /// **H2, through the tree the daemon serves and in the binary CI runs.** + /// Every route that names a knowledge base answers a caller holding only + /// the daemon secret, on a private base, exactly as the page read does — + /// the same status and the same bytes — and answers a base that does not + /// exist the same way. The person at the keyboard still reads all of it, + /// and a public base is untouched. + /// + /// `tests/knowledge_routes.rs` (`h2_http_barrier`) sweeps the bare router + /// as well, but CI runs `cargo test --workspace --lib --bins`, so that + /// binary is not what keeps this door shut; this test is. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn every_route_that_names_a_private_base_refuses_it_exactly_as_the_read_does() { + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let bases = seed_bases(&state, "h2-sweep").await; + let absent = format!("qa-h2-sweep-absent-{}", std::process::id()); + let page_read = |id: &str| format!("/knowledge/bases/{id}/page?path=knowledge/x.md"); + + let (read_status, read_body) = + call(state.clone(), "GET", &page_read(&bases.private), None, &[]).await; + assert_eq!( + (read_status, read_body.as_str()), + (StatusCode::FORBIDDEN, KNOWLEDGE_BASE_OUT_OF_REACH), + "the read path's refusal is what every route below is compared against" + ); + + let mut leaks = Vec::new(); + for id in [bases.private.as_str(), absent.as_str()] { + for (method, uri, body) in base_addressing_routes(id, &bases.sha, &bases.public) { + let (status, got) = call(state.clone(), method, &uri, body, &[]).await; + if status != read_status || got != read_body { + leaks.push(format!("{method} {uri} -> {status}: {got:.160}")); + } + } + } + assert!( + leaks.is_empty(), + "a caller holding nothing but the daemon secret was answered differently from the \ + page read by {} route(s):\n {}", + leaks.len(), + leaks.join("\n ") + ); + + // …and nothing moved: still there, still private, same page. + let root = state.knowledge_service.root().to_path_buf(); + assert!(biorouter_mcp::knowledge::tier::is_private( + &root, + &bases.private + )); + let on_disk = + std::fs::read_to_string(root.join(&bases.private).join("knowledge/x.md")).unwrap(); + assert!( + on_disk.contains(KB_SENTINEL), + "an unproven caller rewrote a private page" + ); + + // The listing omits the private base — its id and its name — from the + // same caller, and shows it to the user. + let (status, body) = call(state.clone(), "GET", "/knowledge/bases", None, &[]).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body.contains(&bases.public), "{body}"); + assert!( + !body.contains(&bases.private) && !body.contains("QA private base"), + "the served list named a private base to a secret-only caller: {body}" + ); + let (status, body) = call(state.clone(), "GET", "/knowledge/bases", None, &[PROOF]).await; + assert_eq!(status, StatusCode::OK); + assert!(body.contains(&bases.private), "{body}"); + + // The other half: "refuse everyone" would pass everything above. + for (method, uri, body) in base_addressing_routes(&bases.private, &bases.sha, "") + .into_iter() + .filter(|(method, _, _)| *method == "GET") + { + let (status, got) = call(state.clone(), method, &uri, body, &[PROOF]).await; + assert_eq!(status, StatusCode::OK, "{uri}: {got:.200}"); + } + let (status, got) = call( + state.clone(), + "GET", + &page_read(&bases.private), + None, + &[PROOF], + ) + .await; + assert_eq!(status, StatusCode::OK, "{got}"); + assert!(got.contains(KB_SENTINEL), "{got}"); + let (status, _) = call( + state.clone(), + "GET", + &format!("/knowledge/bases/{absent}"), + None, + &[PROOF], + ) + .await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "the user is entitled to know the base is not there" + ); + let (status, got) = call(state.clone(), "GET", &page_read(&bases.public), None, &[]).await; + assert_eq!(status, StatusCode::OK, "a public base was refused: {got}"); + assert!(got.contains(KB_SENTINEL)); + } + + /// The browser token a `biorouter serve` launch would have minted. Distinct + /// from every other cookie value in this binary's tests. + const SERVED_TOKEN: &str = "5d0c9b8a7f6e5d4c3b2a19f8e7d6c5b4"; + + /// **SD-10, in the binary CI runs.** A `serve` daemon's own interface — + /// told apart by the served document's cookie — keeps the listing and + /// knowledge-base reach its operator's private provider implies; the same + /// request without the cookie, or with the wrong one, is a public caller; + /// and the cookie opens no transcript — `GET /sessions/{id}` and `DELETE` + /// refuse it exactly as they refuse the secret alone. + /// + /// ⚠ It installs the operator standing into this test binary for good (a + /// `OnceLock`, as in the daemon). That is harmless to every other test here + /// because the standing is earned only by a request carrying this exact + /// cookie, and none of them sends it. The keyless arm — how `serve` really + /// starts its daemon — needs a binary with no user-action key, and is + /// `tests/serve_operator_reach.rs`. + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn a_served_interface_keeps_its_listing_reach_and_gains_no_transcript() { + install_test_user_action_key(); + // `biorouter_server::`, not `crate::`: this module is also compiled into + // the `biorouterd` bin, which has no `auth` module of its own and reads + // the library's — the same static `http_caller` reads in either binary. + biorouter_server::auth::install_served_operator( + SERVED_TOKEN.to_string(), + ProviderTier::Private, + ); + let cookie = format!("biorouter_session={SERVED_TOKEN}"); + let mut probe = HeaderMap::new(); + probe.insert(axum::http::header::COOKIE, cookie.parse().unwrap()); + assert_eq!( + served_operator_capability(&probe), + ProviderTier::Private, + "a different serve operator was installed into this binary first; this test's \ + premise does not hold" + ); + + let state = AppState::new().await.unwrap(); + let private = seed_private_chat(&state, "SD-10 served private (test fixture)").await; + let bases = seed_bases(&state, "sd10").await; + let work = RunningWork::register( + biorouter_mcp::active_work::ActiveWorkKind::Subagent, + "sd10-work-marker", + Some(private.id()), + ); + let served = [("cookie", cookie.as_str())]; + let wrong = [( + "cookie", + "biorouter_session=00000000000000000000000000000000", + )]; + + for (headers, operator) in [(&served[..], true), (&[][..], false), (&wrong[..], false)] { + let (status, body) = call(state.clone(), "GET", "/sessions", None, headers).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body.contains(private.id()), + operator, + "GET /sessions {headers:?}" + ); + let ids = sidebar_ids(&state, 50, headers).await; + assert_eq!(ids.contains(&private.id().to_string()), operator); + + // Running work is a listing, so it keeps the operator's reach too. + let (status, body) = call(state.clone(), "GET", "/active_work", None, headers).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + active_work_ids(&body).contains(work.id()), + operator, + "GET /active_work {headers:?}" + ); + + let (status, body) = + call(state.clone(), "GET", "/knowledge/bases", None, headers).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body.contains(&bases.private), + operator, + "GET /knowledge/bases {headers:?}" + ); + let (status, body) = call( + state.clone(), + "GET", + &format!( + "/knowledge/bases/{}/page?path=knowledge/x.md", + bases.private + ), + None, + headers, + ) + .await; + if operator { + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body.contains(KB_SENTINEL), "{body}"); + } else { + assert_eq!( + (status, body.as_str()), + (StatusCode::FORBIDDEN, KNOWLEDGE_BASE_OUT_OF_REACH), + "{headers:?}" + ); + } + } + + // The cookie earns nothing at the transcript gate: the read and the + // delete refuse the served interface exactly as the secret alone. + for method in ["GET", "DELETE"] { + let uri = format!("/sessions/{}", private.id()); + let (status, body) = call(state.clone(), method, &uri, None, &served).await; + assert_eq!( + (status, body.as_str()), + (StatusCode::FORBIDDEN, SESSION_OUT_OF_REACH), + "{method} {uri} with the served cookie" + ); + } + assert!( + state + .session_manager() + .get_session(private.id(), false) + .await + .is_ok(), + "the served cookie deleted a private chat" + ); + + // …nor at the cancel, which names ONE chat's work: the interface is + // listed the private chat's work and cannot stop it, as it is listed the + // private chat and cannot open or delete it. + let (status, body) = call( + state.clone(), + "POST", + &format!("/active_work/{}/cancel", work.id()), + None, + &served, + ) + .await; + assert_eq!( + (status, body.as_str()), + (StatusCode::FORBIDDEN, SESSION_OUT_OF_REACH), + "POST /active_work/{{id}}/cancel with the served cookie" + ); + assert!( + !work.stopped(), + "the served cookie stopped a private chat's work" + ); + } + + /// Every id the sidebar hands this caller, walking `next_offset` to the end. + async fn sidebar_ids( + state: &Arc, + limit: u32, + headers: &[(&str, &str)], + ) -> Vec { + let mut ids = Vec::new(); + let mut offset = 0u64; + for _ in 0..10_000 { + let (status, body) = call( + state.clone(), + "GET", + &format!("/sessions/sidebar?limit={limit}&offset={offset}"), + None, + headers, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let page: serde_json::Value = serde_json::from_str(&body).unwrap(); + for row in page["sessions"].as_array().unwrap() { + ids.push(row["id"].as_str().unwrap().to_string()); + } + if page["has_more"] != serde_json::Value::Bool(true) { + return ids; + } + offset = page["next_offset"] + .as_u64() + .expect("has_more without next_offset"); + } + panic!("the sidebar never reported its last page"); + } + + /// A private chat with no message at all — `/workflows/create` answers such + /// a chat before it builds an agent. + async fn seed_private_chat_without_messages(state: &Arc, label: &str) -> SeededChat { + let manager = state.session_manager(); + let session = manager + .create_session( + PathBuf::from("/tmp/task58_session_reach"), + label.to_string(), + SessionType::User, + ) + .await + .unwrap(); + 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(); + SeededChat { + state: state.clone(), + id: session.id, + } + } + + /// A private subagent registered as still initializing: its tool routes, + /// once admitted, answer 424 without building an agent. + struct QueuedChild { + chat: SeededChat, + handle: Arc, + } + + impl Drop for QueuedChild { + fn drop(&mut self) { + self.handle + .complete(biorouter::agents::SubagentResult::from_error( + "QA M2 queued-child fixture cleaned up", + )); + } + } + + async fn seed_queued_private_child(state: &Arc) -> QueuedChild { + let manager = state.session_manager(); + let session = manager + .create_session( + PathBuf::from("/tmp/task58_session_reach"), + "QA M2 queued child (test fixture)".to_string(), + SessionType::SubAgent, + ) + .await + .unwrap(); + 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(); + let handle = biorouter::agents::subagent_handle::BackgroundSubagent::register_initializing( + "qa-m2-parent", + session.id.clone(), + "QA M2 queued child", + tokio_util::sync::CancellationToken::new(), + ); + QueuedChild { + chat: SeededChat { + state: state.clone(), + id: session.id, + }, + handle, + } } /// `DELETE /knowledge/bases/{id}` through the real router tree, with the diff --git a/crates/biorouter-server/src/routes/skills.rs b/crates/biorouter-server/src/routes/skills.rs index 08aac5f7e..85706f3ae 100644 --- a/crates/biorouter-server/src/routes/skills.rs +++ b/crates/biorouter-server/src/routes/skills.rs @@ -167,6 +167,10 @@ pub async fn skill_catalog_handler( responses( (status = 200, description = "Applied", body = SessionSkillsResponse), (status = 401, description = "Unauthorized - invalid or missing secret key"), + (status = 403, description = "Refused by a privacy boundary: `sessionId` names a chat \ + this caller may not reach, answered with the same refusal, \ + word for word, that `GET /sessions/{session_id}` gives \ + (body = plain text)"), (status = 404, description = "No such conversation"), (status = 500, description = "The override could not be persisted"), ), @@ -175,8 +179,21 @@ pub async fn skill_catalog_handler( )] pub async fn set_session_skills( State(state): State>, + // Before `Json`, which consumes the body and must be last. + headers: axum::http::HeaderMap, Json(request): Json, ) -> Result, (StatusCode, String)> { + // Issue #56, QA 2026-09-10 (F0's sweep). Enabling a skill in a chat puts its + // instructions into that chat's next turn — a write into the chat — so a + // caller the read refuses may not do it. Asked first, before the request is + // validated against anything the chat holds. + crate::routes::session_reach::session_reach( + state.session_manager(), + &request.session_id, + &headers, + ) + .await + .map_err(|refusal| (refusal.status, refusal.message.to_string()))?; if request.add.is_empty() && request.remove.is_empty() { return Err(( StatusCode::BAD_REQUEST, diff --git a/crates/biorouter-server/src/routes/web_ui.rs b/crates/biorouter-server/src/routes/web_ui.rs index 110fa984c..dac814668 100644 --- a/crates/biorouter-server/src/routes/web_ui.rs +++ b/crates/biorouter-server/src/routes/web_ui.rs @@ -40,11 +40,21 @@ //! second browser, a bookmark, and a browser that has dropped its cookie. //! Decision SD-9 in `docs/deployment/serve-decisions.md` has the reasoning. //! -//! **The cookie gates the document and nothing else.** It is not accepted as -//! authentication on any API route. Accepting it there would make every API -//! route reachable by a credential the browser attaches automatically, which is -//! a cross-site request forgery surface the header scheme does not have. Keeping -//! the cookie's authority to one request is why `check_token` needed no change. +//! **The cookie gates the document, and authenticates nothing else.** It is not +//! accepted as authentication on any API route. Accepting it there would make +//! every API route reachable by a credential the browser attaches automatically, +//! which is a cross-site request forgery surface the header scheme does not +//! have. Keeping the cookie's authority to one request is why `check_token` +//! needed no change. +//! +//! It has one other reader, and it is a narrowing rather than an admission: an +//! API request that already passed `check_token` and ALSO carries this cookie +//! came from the document this daemon served, so `auth::served_operator_capability` +//! gives it the operator's configured tier on the listing and knowledge-base +//! gates (`routes::session_reach`). A request holding only the secret is a +//! public caller there. `SameSite=Strict` keeps the cookie off every cross-site +//! request, and a forged request still needs the secret, so no CSRF surface +//! appears. See `docs/deployment/serve-decisions.md` SD-10. //! //! # Why there is no brute-force throttle here //! @@ -192,6 +202,19 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { .map(|(_, v)| v.trim()) } +/// The session cookie the token exchange set, if the request carries one. +/// +/// The one reader of [`SESSION_COOKIE`]: the shell below asks it whether to +/// serve the document, and `auth::served_operator_capability` asks it whether a +/// request came from that document — which earns a serve daemon's own interface +/// the operator's tier on the listing and knowledge-base gates, and nothing +/// else. It is never accepted as authentication on an API route: `check_token` +/// still demands `X-Secret-Key`, so the cookie can only narrow a caller that +/// already holds the secret, never admit one that does not. +pub(crate) fn session_cookie(headers: &HeaderMap) -> Option<&str> { + cookie_value(headers, SESSION_COOKIE) +} + /// The application shell, and the token-for-cookie exchange that gates it. /// /// This handler also serves every unmatched path, so a deep link into the @@ -220,7 +243,7 @@ async fn index( return unauthorized(); } - if !ui.token_matches(cookie_value(&headers, SESSION_COOKIE)) { + if !ui.token_matches(session_cookie(&headers)) { return unauthorized(); } diff --git a/crates/biorouter-server/src/routes/workflow.rs b/crates/biorouter-server/src/routes/workflow.rs index acac229ab..9afe3bfee 100644 --- a/crates/biorouter-server/src/routes/workflow.rs +++ b/crates/biorouter-server/src/routes/workflow.rs @@ -150,8 +150,13 @@ pub struct WorkflowToYamlResponse { path = "/workflows/create", request_body = CreateWorkflowRequest, responses( - (status = 200, description = "Workflow created successfully", body = CreateWorkflowResponse), + (status = 200, description = "Workflow created successfully. Its `knowledge_bases` names \ + only the bases this caller may open", body = CreateWorkflowResponse), (status = 400, description = "Bad request"), + (status = 403, description = "Refused by a privacy boundary: `session_id` names a chat \ + this caller may not reach, answered with the same refusal, \ + word for word, that `GET /sessions/{session_id}` gives \ + (body = plain text)"), (status = 412, description = "Precondition failed - Agent not available"), (status = 500, description = "Internal server error") ), @@ -159,7 +164,58 @@ pub struct WorkflowToYamlResponse { )] async fn create_workflow( State(state): State>, + // Before `Json`, which consumes the body and must be last. + headers: axum::http::HeaderMap, Json(request): Json, +) -> axum::response::Response { + use axum::response::IntoResponse; + // Issue #56, QA 2026-09-10 (F0's sweep). This loads the named chat's WHOLE + // transcript and hands back a workflow a model wrote from it — the + // transcript again, summarised — so it asks the read's gate first, before + // the chat is loaded or an agent is built for it. + if let Err(refusal) = crate::routes::session_reach::session_reach( + state.session_manager(), + &request.session_id, + &headers, + ) + .await + { + return refusal.into_response(); + } + let caller = crate::routes::session_reach::http_caller(&headers).await; + match workflow_from_session(&state, request).await { + Ok(Json(mut response)) => { + // The enrichment records the chat's visible knowledge bases, which + // can include a private base even for a public chat. Named only as + // far as this caller may open them — the rule `GET + // /knowledge/active` applies to the same list. + if let Some(bases) = response + .workflow + .as_mut() + .and_then(|workflow| workflow.knowledge_bases.as_mut()) + { + let root = state.knowledge_service.root(); + bases + .visible + .retain(|id| caller.reach_knowledge_base(root, id).is_ok()); + if bases + .default + .as_deref() + .is_some_and(|id| caller.reach_knowledge_base(root, id).is_err()) + { + bases.default = None; + } + } + Json(response).into_response() + } + Err(status) => status.into_response(), + } +} + +/// The body of [`create_workflow`], once the caller may address the chat. +async fn workflow_from_session( + state: &Arc, + request: CreateWorkflowRequest, ) -> Result, StatusCode> { tracing::info!( "Workflow creation request received for session_id: {}", diff --git a/crates/biorouter-server/tests/knowledge_routes.rs b/crates/biorouter-server/tests/knowledge_routes.rs index 7ac850c46..1fcc001b5 100644 --- a/crates/biorouter-server/tests/knowledge_routes.rs +++ b/crates/biorouter-server/tests/knowledge_routes.rs @@ -20,7 +20,15 @@ fn build_test_router() -> (tempfile::TempDir, Router) { (dir, router) } +/// `POST /active` as the Knowledge view sends it — carrying the user's proof. +/// +/// ⚠ Since QA's 2026-09-10 H2 sweep the selection is filtered for a caller +/// WITHOUT that proof (private and absent bases dropped, a write unable to move +/// what it cannot see), so these mechanics tests speak as the user, which is who +/// the renderer is. What an unproven caller sees and may change is +/// `h2_http_barrier`'s subject. async fn post_active(app: &Router, body: serde_json::Value) -> (u16, serde_json::Value) { + tier_route::install_test_user_action_key(); let res = app .clone() .oneshot( @@ -28,6 +36,7 @@ async fn post_active(app: &Router, body: serde_json::Value) -> (u16, serde_json: .method("POST") .uri("/active") .header("content-type", "application/json") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) @@ -43,14 +52,22 @@ async fn post_active(app: &Router, body: serde_json::Value) -> (u16, serde_json: ) } +/// `GET /active`, with the user's proof — see [`post_active`]. async fn get_active(app: &Router, session_id: Option<&str>) -> serde_json::Value { + tier_route::install_test_user_action_key(); let uri = match session_id { Some(sid) => format!("/active?session_id={sid}"), None => "/active".to_string(), }; let res = app .clone() - .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri(uri) + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_eq!(res.status(), 200); @@ -115,17 +132,33 @@ async fn get_location_returns_kb_path() { #[tokio::test] async fn get_location_404_for_unknown_kb() { + // The person at the keyboard is told the base is not there. A caller + // without the proof is told what it is told for a private base (403) — + // QA 2026-09-10 H2 — so the 404 is not an oracle for which ids exist. + tier_route::install_test_user_action_key(); let (_d, app) = build_test_router(); let res = app + .clone() .oneshot( Request::builder() .uri("/bases/nope/location") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), 404); + let res = app + .oneshot( + Request::builder() + .uri("/bases/nope/location") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), 403); } // ────────────────────────────────────────────────────────────────────────────── @@ -302,10 +335,14 @@ async fn update_base_metadata_roundtrip() { assert_eq!(manifest["name"], "Renamed Knowledge Base"); assert_eq!(manifest["color"], "#123456"); + // Asked as the user: an unproven caller is told nothing about an id that + // names no base (QA 2026-09-10 H2), so only the user can see the 404. + tier_route::install_test_user_action_key(); let res = app .oneshot( Request::builder() .uri("/bases/rename") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -1880,10 +1917,18 @@ async fn read_page_rejects_invalid_kb_id_with_400() { // "INVALID--KB" violates both the lowercase rule and the `--` rule. We do // not need to create the KB; validation fires before any filesystem touch. + // + // Asked as the user, who is owed the handler's 400. A caller without the + // proof never reaches the handler: a malformed id is answered as a private + // one is (QA 2026-09-10 H2), which also keeps a `..` out of every path + // join below the gate for that caller. + tier_route::install_test_user_action_key(); let res = app + .clone() .oneshot( Request::builder() .uri("/bases/INVALID--KB/page?path=knowledge/x.md") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -1894,6 +1939,16 @@ async fn read_page_rejects_invalid_kb_id_with_400() { 400, "invalid kb-id must return 400, not 500 (regression test)" ); + let res = app + .oneshot( + Request::builder() + .uri("/bases/INVALID--KB/page?path=knowledge/x.md") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), 403); } #[tokio::test] @@ -1953,6 +2008,8 @@ async fn fresh_selection_reports_soul_as_the_default_primary_when_bootstrapped() #[tokio::test] async fn active_kb_roundtrip() { + // The Knowledge view, which sends the user's proof; see `post_active`. + tier_route::install_test_user_action_key(); let (_d, app) = build_test_router(); // Empty initially. @@ -1961,6 +2018,7 @@ async fn active_kb_roundtrip() { .oneshot( Request::builder() .uri("/active") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -2009,6 +2067,7 @@ async fn active_kb_roundtrip() { Request::builder() .method("POST") .uri("/active") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .header("content-type", "application/json") .body(Body::from(set_body)) .unwrap(), @@ -2027,6 +2086,7 @@ async fn active_kb_roundtrip() { .oneshot( Request::builder() .uri("/active") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -2058,6 +2118,7 @@ async fn active_kb_roundtrip() { Request::builder() .method("POST") .uri("/active") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .header("content-type", "application/json") .body(Body::from(clear_body)) .unwrap(), @@ -2071,6 +2132,7 @@ async fn active_kb_roundtrip() { .oneshot( Request::builder() .uri("/active") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -2097,6 +2159,7 @@ async fn active_kb_roundtrip() { Request::builder() .method("POST") .uri("/active") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .header("content-type", "application/json") .body(Body::from(bad_body)) .unwrap(), @@ -2513,10 +2576,16 @@ async fn hiding_the_primary_promotes_for_an_inheriting_chat_too() { /// down, in `KnowledgeService::export_brkb`, would change this route too — the /// user would stop being able to download a private base from their own /// Knowledge view. So assert the bytes come back. +/// +/// ⚠ "The user" is the request carrying the user's proof, which the desktop's +/// Knowledge view sends. Until QA's 2026-09-10 H2 sweep this test's export +/// carried nothing and was served — the same request a public chat's shell makes +/// with a recovered daemon secret, which is now refused (`h2_http_barrier`). #[tokio::test] async fn the_users_own_export_route_is_not_subject_to_the_models_location_rule() { use axum::http::header; + tier_route::install_test_user_action_key(); let (_d, root, app) = build_test_router_with_root(); let create_body = serde_json::to_vec(&serde_json::json!({"id": "omop", "name": "Omop"})).unwrap(); @@ -2543,6 +2612,7 @@ async fn the_users_own_export_route_is_not_subject_to_the_models_location_rule() .oneshot( Request::builder() .uri("/bases/omop/export") + .header("X-User-Action", tier_route::TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -2742,7 +2812,15 @@ mod privacy_ratchet { // ── Issue #56, Task 10C: the barrier at CP2, over HTTP ─────────────────── + /// A macro run as the Knowledge view starts one: with the user's proof. + /// + /// ⚠ Since QA's 2026-09-10 H2 sweep a private base answers a caller WITHOUT + /// that proof before the macro route runs at all (`gate_knowledge_base`), + /// so the tests below — which are about CP2, the MODEL's capability — speak + /// as the user in order to reach it. The two gates ask different questions: + /// may this caller address the base, and may this model read it. async fn post_json_raw(app: &Router, uri: &str, body: serde_json::Value) -> (u16, String) { + super::tier_route::install_test_user_action_key(); let res = app .clone() .oneshot( @@ -2750,6 +2828,7 @@ mod privacy_ratchet { .method("POST") .uri(uri) .header("content-type", "application/json") + .header("X-User-Action", super::tier_route::TEST_USER_ACTION_KEY) .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(), ) @@ -2796,12 +2875,17 @@ mod privacy_ratchet { ); assert!(body.contains("private"), "{body}"); - // And the GUI's own read routes are untouched: the user is not a model. + // And the Knowledge view still reads the page: the user is not a model. + // ⚠ "The user" is now the request carrying the user's proof, which is + // what the desktop sends. Until QA's 2026-09-10 H2 sweep this read + // carried nothing at all and was served anyway — which is the same + // request a public chat's shell makes with a recovered daemon secret. let res = app .clone() .oneshot( Request::builder() .uri("/bases/omop/page?path=knowledge/x.md") + .header("X-User-Action", super::tier_route::TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -3125,12 +3209,15 @@ mod tier_route { let (_d, root, app) = guarded_router(); seed(&root, &app).await; + // The Knowledge view's listing — with the user's proof, which is what + // lists a private base at all since QA's 2026-09-10 H2 sweep. let res = app .clone() .oneshot( Request::builder() .uri("/bases") .header("X-Secret-Key", TEST_SECRET) + .header("X-User-Action", TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -3170,12 +3257,15 @@ mod tier_route { ) .unwrap(); + // As the publicize dialog asks it: with the user's proof. A caller + // without it is refused this private base's tier and counts outright. let res = app .clone() .oneshot( Request::builder() .uri("/bases/omop/tier") .header("X-Secret-Key", TEST_SECRET) + .header("X-User-Action", TEST_USER_ACTION_KEY) .body(Body::empty()) .unwrap(), ) @@ -3353,8 +3443,21 @@ mod okf_surface { !root.join("lit").exists(), "a refused create must not leave a half-scaffolded base on disk" ); - let (status, _) = get_json(&app, "/bases/lit").await; - assert_eq!(status, 404, "and the base must not be readable"); + // Asked as the user, who is told it is not there; a caller without the + // proof gets the refusal it gets for a private base (QA 2026-09-10 H2). + super::tier_route::install_test_user_action_key(); + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/bases/lit") + .header("X-User-Action", super::tier_route::TEST_USER_ACTION_KEY) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), 404, "and the base must not be readable"); } /// The typed graph, on the wire. @@ -3752,3 +3855,550 @@ mod merge_route { ); } } + +// ────────────────────────────────────────────────────────────────────────────── +// QA 2026-09-10, H2 — a private knowledge base over HTTP +// +// The tool path refused a public caller (`kb_read_page`, `kb_search`, +// `kb_list_pages`, `kb_export`; `kb_list_bases` omits the base), while every +// `/knowledge/bases/{id}/…` route handed the same base's pages, graph, history +// and a `.brkb` of the whole tree to a caller holding nothing but the daemon +// secret — which a public chat's own shell recovered with `ps eww`. These tests +// are that caller, and the person at the keyboard beside it. +// ────────────────────────────────────────────────────────────────────────────── +mod h2_http_barrier { + use super::tier_route::{install_test_user_action_key, TEST_USER_ACTION_KEY}; + use super::*; + use biorouter_mcp::knowledge::tier; + + /// Appears in the seeded pages and nowhere else, so "the content came + /// back" is an assertion rather than an impression. + const SENTINEL: &str = "qa-h2-private-page-marker-not-real-data"; + const PRIVATE_KB: &str = "omop"; + const PUBLIC_KB: &str = "notes"; + /// A well-formed id that names no base on this machine. + const ABSENT_KB: &str = "no-such-base"; + + async fn call( + app: &Router, + method: &str, + uri: &str, + body: Option, + proof: bool, + ) -> (u16, String) { + let mut builder = Request::builder().method(method).uri(uri); + if proof { + builder = builder.header("X-User-Action", TEST_USER_ACTION_KEY); + } + let body = match body { + Some(json) => { + builder = builder.header("content-type", "application/json"); + Body::from(serde_json::to_vec(&json).unwrap()) + } + None => Body::empty(), + }; + let res = app + .clone() + .oneshot(builder.body(body).unwrap()) + .await + .unwrap(); + let status = res.status().as_u16(); + let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) + } + + /// Two bases through the real routes, each with one page and one commit; + /// the first is then ratcheted private the way a private chat's ingest + /// leaves it. Returns that base's commit for the history-shaped routes. + async fn seed(app: &Router, root: &std::path::Path) -> String { + for (id, name) in [(PRIVATE_KB, "OMOP"), (PUBLIC_KB, "Notes")] { + create_kb(app.clone(), id, name).await; + let (status, body) = call( + app, + "PUT", + &format!("/bases/{id}/pages/knowledge/x.md"), + Some(serde_json::json!({ + "content": valid_page("note", "X", &format!("# X\n\n{SENTINEL} in {id}")), + "commit_message": "seed", + })), + false, + ) + .await; + assert_eq!(status, 200, "seeding {id}: {body}"); + } + tier::raise_unlocked(root, PRIVATE_KB, true).unwrap(); + let (status, body) = call( + app, + "GET", + &format!("/bases/{PRIVATE_KB}/history"), + None, + true, + ) + .await; + assert_eq!(status, 200, "{body}"); + let history: serde_json::Value = serde_json::from_str(&body).unwrap(); + history[0]["commit_sha"].as_str().unwrap().to_string() + } + + fn model() -> serde_json::Value { + // Unknown to the registry: an admitted macro stops at `build_completer` + // with a 400, long before any model is reached. + serde_json::json!({ "provider": "qa-h2-no-such-provider", "model": "m" }) + } + + /// Every route under `/bases/{id}`, as `(method, uri, body)`. + /// + /// ⚠ **Destructive last**, for the reason the chat sweep gives: before this + /// change `DELETE` removed the base outright, and every row after it would + /// then have been probing an absent id. + fn base_addressing_routes( + id: &str, + sha: &str, + ) -> Vec<(&'static str, String, Option)> { + vec![ + ("GET", format!("/bases/{id}"), None), + ("GET", format!("/bases/{id}/tier"), None), + ("GET", format!("/bases/{id}/graph"), None), + ("GET", format!("/bases/{id}/location"), None), + ("GET", format!("/bases/{id}/page?path=knowledge/x.md"), None), + ("GET", format!("/bases/{id}/pages"), None), + ("GET", format!("/bases/{id}/pages/knowledge/x.md"), None), + ("GET", format!("/bases/{id}/history"), None), + ( + "POST", + format!("/bases/{id}/preview"), + Some(serde_json::json!({ "commit_sha": sha, "path": "knowledge/x.md" })), + ), + ("GET", format!("/bases/{id}/export"), None), + ( + "POST", + format!("/bases/{id}/query"), + Some(serde_json::json!({ "question": "what is in it?", "model": model() })), + ), + ( + "POST", + format!("/bases/{id}/lint"), + Some(serde_json::json!({ "model": model() })), + ), + ("POST", format!("/bases/{id}/sources/s1/reclassify"), None), + ( + "PUT", + format!("/bases/{id}/sources/s1/credibility"), + Some(serde_json::json!({})), + ), + ( + "POST", + format!("/bases/{id}/tier"), + Some(serde_json::json!({ "tier": "public" })), + ), + ( + "POST", + format!("/bases/{id}/merge"), + Some(serde_json::json!({ "source_kb_id": PUBLIC_KB })), + ), + ( + "PUT", + format!("/bases/{id}"), + Some(serde_json::json!({ "name": "renamed by an unproven caller" })), + ), + ( + "PUT", + format!("/bases/{id}/default-model"), + Some(serde_json::json!({ "model": model() })), + ), + ( + "PUT", + format!("/bases/{id}/pages/knowledge/x.md"), + Some(serde_json::json!({ + "content": valid_page("note", "X", "overwritten by an unproven caller"), + "commit_message": "overwrite", + })), + ), + ( + "POST", + format!("/bases/{id}/raw"), + Some(serde_json::json!({ "text": "an unproven raw source", "title": "t" })), + ), + ( + "POST", + format!("/bases/{id}/ingest"), + Some(serde_json::json!({ "source": { "text": "t" }, "model": model() })), + ), + ( + "POST", + format!("/bases/{id}/ingest-conversation"), + Some(serde_json::json!({ "session_ids": ["29990101_1"], "model": model() })), + ), + ( + "POST", + format!("/bases/{id}/restore"), + Some(serde_json::json!({ "commit_sha": sha })), + ), + ("DELETE", format!("/bases/{id}"), None), + ] + } + + /// **H2.** Every route under `/bases/{id}` answers an unproven caller on a + /// private base exactly as the page read does — the same status and the + /// same bytes — and answers a base that does not exist the same way, so the + /// refusal is not an oracle for which ids name a private base. + /// + /// Collected rather than asserted row by row, so a regression names every + /// door it reopened. + #[tokio::test] + async fn every_route_that_names_a_private_base_refuses_an_unproven_caller_as_the_read_does() { + install_test_user_action_key(); + let (_d, root, app) = build_test_router_with_root(); + let sha = seed(&app, &root).await; + + let (read_status, read_body) = call( + &app, + "GET", + &format!("/bases/{PRIVATE_KB}/page?path=knowledge/x.md"), + None, + false, + ) + .await; + assert_eq!(read_status, 403, "the private page was served: {read_body}"); + assert!( + !read_body.contains(SENTINEL), + "the refusal carried the page" + ); + + let mut leaks = Vec::new(); + for id in [PRIVATE_KB, ABSENT_KB] { + for (method, uri, body) in base_addressing_routes(id, &sha) { + let (status, got) = call(&app, method, &uri, body, false).await; + if status != read_status || got != read_body { + leaks.push(format!("{method} {uri} -> {status}: {got:.160}")); + } + } + } + assert!( + leaks.is_empty(), + "a caller holding nothing but the daemon secret was answered differently from the \ + page read by {} route(s):\n {}", + leaks.len(), + leaks.join("\n ") + ); + + // …and nothing moved: the base is still there, still private, and its + // page still says what it said. + assert!(root.join(PRIVATE_KB).join("knowledge/x.md").exists()); + assert!(tier::is_private(&root, PRIVATE_KB)); + let page = std::fs::read_to_string(root.join(PRIVATE_KB).join("knowledge/x.md")).unwrap(); + assert!( + page.contains(SENTINEL), + "an unproven caller rewrote a private page" + ); + } + + /// The other half — "refuse the unproven caller" is satisfied by "refuse + /// everyone", and the Knowledge view must keep working. The person at the + /// keyboard reads the private base in full, and gets the honest 404 for a + /// base that is not there. + #[tokio::test] + async fn the_person_at_the_keyboard_still_reads_their_own_private_base() { + install_test_user_action_key(); + let (_d, root, app) = build_test_router_with_root(); + let sha = seed(&app, &root).await; + + for uri in [ + format!("/bases/{PRIVATE_KB}/page?path=knowledge/x.md"), + format!("/bases/{PRIVATE_KB}/pages/knowledge/x.md"), + ] { + let (status, body) = call(&app, "GET", &uri, None, true).await; + assert_eq!(status, 200, "{uri}: {body}"); + assert!( + body.contains(SENTINEL), + "{uri} came back without the page: {body}" + ); + } + for uri in [ + format!("/bases/{PRIVATE_KB}"), + format!("/bases/{PRIVATE_KB}/tier"), + format!("/bases/{PRIVATE_KB}/graph"), + format!("/bases/{PRIVATE_KB}/location"), + format!("/bases/{PRIVATE_KB}/pages"), + format!("/bases/{PRIVATE_KB}/history"), + format!("/bases/{PRIVATE_KB}/export"), + ] { + let (status, body) = call(&app, "GET", &uri, None, true).await; + assert_eq!(status, 200, "{uri}: {body:.200}"); + } + let (status, body) = call( + &app, + "POST", + &format!("/bases/{PRIVATE_KB}/preview"), + Some(serde_json::json!({ "commit_sha": sha, "path": "knowledge/x.md" })), + true, + ) + .await; + assert_eq!(status, 200, "{body}"); + assert!(body.contains(SENTINEL)); + + let (status, _) = call(&app, "GET", &format!("/bases/{ABSENT_KB}"), None, true).await; + assert_eq!( + status, 404, + "the user is entitled to know the base is not there" + ); + } + + /// A public base is untouched for a caller that proves nothing. + #[tokio::test] + async fn a_public_base_is_untouched_for_an_unproven_caller() { + install_test_user_action_key(); + let (_d, root, app) = build_test_router_with_root(); + seed(&app, &root).await; + let (status, body) = call( + &app, + "GET", + &format!("/bases/{PUBLIC_KB}/page?path=knowledge/x.md"), + None, + false, + ) + .await; + assert_eq!(status, 200, "{body}"); + assert!(body.contains(SENTINEL)); + let (status, body) = call( + &app, + "GET", + &format!("/bases/{PUBLIC_KB}/export"), + None, + false, + ) + .await; + assert_eq!(status, 200, "{body:.200}"); + } + + /// `GET /knowledge/bases` OMITS a private base from an unproven caller — + /// omission, not a 404 for the list and not a redacted row, because a + /// base's id and name are user-authored content (the tool path's + /// `kb_list_bases` makes the same choice). + #[tokio::test] + async fn the_bases_listing_omits_a_private_base_from_an_unproven_caller() { + install_test_user_action_key(); + let (_d, root, app) = build_test_router_with_root(); + seed(&app, &root).await; + + let (status, body) = call(&app, "GET", "/bases", None, false).await; + assert_eq!(status, 200, "{body}"); + let ids: Vec = serde_json::from_str::(&body) + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|row| row["id"].as_str().unwrap().to_string()) + .collect(); + assert!(ids.contains(&PUBLIC_KB.to_string()), "{ids:?}"); + assert!( + !ids.contains(&PRIVATE_KB.to_string()), + "an unproven caller was listed a private base: {ids:?}" + ); + assert!( + !body.contains("OMOP"), + "the private base's name leaked: {body}" + ); + + let (status, body) = call(&app, "GET", "/bases", None, true).await; + assert_eq!(status, 200); + assert!( + body.contains(PRIVATE_KB) && body.contains(PUBLIC_KB), + "{body}" + ); + } + + /// `/knowledge/active` is the second listing of base ids, and the one the + /// Knowledge view hydrates from. An unproven caller sees only what it can + /// reach — and may not change what it cannot see: its writes leave a + /// private base's hidden state and a private primary exactly where they + /// were. Without that, a renderer that prunes ids missing from its + /// (filtered) list would silently rewrite the machine-wide selection. + #[tokio::test] + async fn the_selection_shows_and_changes_only_what_an_unproven_caller_can_reach() { + install_test_user_action_key(); + let (_d, root, app) = build_test_router_with_root(); + seed(&app, &root).await; + + // The user pins the private base as the machine-wide primary. + let (status, body) = call( + &app, + "POST", + "/active", + Some(serde_json::json!({ "primary_kb": PRIVATE_KB, "hidden_kbs": [] })), + true, + ) + .await; + assert_eq!(status, 200, "{body}"); + + // An unproven reader is told nothing about it. + let (status, body) = call(&app, "GET", "/active", None, false).await; + assert_eq!(status, 200, "{body}"); + let seen: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!( + !body.contains(PRIVATE_KB), + "an unproven caller was shown a private base: {body}" + ); + assert_eq!(seen["primary_kb"], serde_json::Value::Null); + + // It cannot clear what it cannot see… + let (status, body) = call( + &app, + "POST", + "/active", + Some(serde_json::json!({ "clear_primary": true })), + false, + ) + .await; + assert_eq!(status, 200, "{body}"); + // …cannot name it… + let (named, named_body) = call( + &app, + "POST", + "/active", + Some(serde_json::json!({ "primary_kb": PRIVATE_KB })), + false, + ) + .await; + let (absent, absent_body) = call( + &app, + "POST", + "/active", + Some(serde_json::json!({ "primary_kb": ABSENT_KB })), + false, + ) + .await; + assert_eq!(named, 403, "{named_body}"); + assert_eq!( + (named, named_body.as_str()), + (absent, absent_body.as_str()), + "naming a private base and naming no base answered differently" + ); + assert!( + !absent_body.contains(PRIVATE_KB), + "the refusal enumerated a private id: {absent_body}" + ); + + // …and cannot hide it: an id it cannot reach is not its to move. + let (status, body) = call( + &app, + "POST", + "/active", + Some(serde_json::json!({ "hidden_kbs": [PRIVATE_KB] })), + false, + ) + .await; + assert_eq!(status, 200, "{body}"); + + let (status, body) = call(&app, "GET", "/active", None, true).await; + assert_eq!(status, 200, "{body}"); + let truth: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(truth["primary_kb"], serde_json::json!(PRIVATE_KB), "{body}"); + assert!( + !truth["hidden_kbs"] + .as_array() + .unwrap() + .iter() + .any(|id| id == PRIVATE_KB), + "an unproven caller hid a private base: {body}" + ); + + // The user hides it; an unproven caller that rewrites the set cannot + // bring it back. + let (status, _) = call( + &app, + "POST", + "/active", + Some(serde_json::json!({ "hidden_kbs": [PRIVATE_KB], "clear_primary": true })), + true, + ) + .await; + assert_eq!(status, 200); + let (status, body) = call( + &app, + "POST", + "/active", + Some(serde_json::json!({ "hidden_kbs": [] })), + false, + ) + .await; + assert_eq!(status, 200, "{body}"); + assert!(!body.contains(PRIVATE_KB), "{body}"); + let (_, body) = call(&app, "GET", "/active", None, true).await; + let truth: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + truth["hidden_kbs"], + serde_json::json!([PRIVATE_KB]), + "an unproven caller un-hid a private base it could not see: {body}" + ); + } + + /// `POST /bases/{id}/ingest-conversation` names chats as well as a base, + /// and streams what the macro makes of them back to the caller. So the + /// caller must be able to reach every chat it names — the same gate, with + /// the same refusal, as `GET /sessions/{id}` — before a transcript is read. + #[tokio::test] + async fn conversation_ingest_refuses_a_private_chat_to_an_unproven_caller() { + use biorouter::session::session_manager::{SessionManager, SessionType}; + install_test_user_action_key(); + let (_d, root, app) = build_test_router_with_root(); + seed(&app, &root).await; + + let manager = SessionManager::instance(); + let chat = manager + .create_session( + std::path::PathBuf::from("/tmp/qa_h2_ingest"), + "QA H2 ingest (test fixture)".to_string(), + SessionType::User, + ) + .await + .unwrap(); + manager + .add_message( + &chat.id, + &biorouter::conversation::message::Message::user().with_text(SENTINEL), + ) + .await + .unwrap(); + manager + .update(&chat.id) + .provider_name("versa_azure") + .model_config(biorouter::model::ModelConfig::new("gpt-4o").unwrap()) + .raise_privacy( + biorouter::privacy::SessionClassification::Private, + "turn:versa_azure", + ) + .apply() + .await + .unwrap(); + + let ingest = |id: String| serde_json::json!({ "session_ids": [id], "model": model() }); + let uri = format!("/bases/{PUBLIC_KB}/ingest-conversation"); + let (private_status, private_body) = + call(&app, "POST", &uri, Some(ingest(chat.id.clone())), false).await; + let (absent_status, absent_body) = call( + &app, + "POST", + &uri, + Some(ingest("29990101_424242".into())), + false, + ) + .await; + assert_eq!(private_status, 403, "{private_body}"); + assert_eq!( + (private_status, private_body.as_str()), + (absent_status, absent_body.as_str()), + "a private chat and an absent one answered differently" + ); + assert!(!private_body.contains(SENTINEL)); + + // The person at the keyboard gets past the gate to the handler's own + // answer — the unknown provider's 400. + let (status, body) = call(&app, "POST", &uri, Some(ingest(chat.id.clone())), true).await; + assert_eq!(status, 400, "{body}"); + + manager.delete_session(&chat.id).await.unwrap(); + } +} diff --git a/crates/biorouter-server/tests/new_chat_no_user_key.rs b/crates/biorouter-server/tests/new_chat_no_user_key.rs new file mode 100644 index 000000000..da76d0344 --- /dev/null +++ b/crates/biorouter-server/tests/new_chat_no_user_key.rs @@ -0,0 +1,561 @@ +//! SD-12: on a daemon that holds no proof-of-user key — `biorouter serve` (SD-7) +//! or a hand-run `biorouterd` — a new chat starts on the operator's configured +//! provider, private or not, and nothing on the HTTP surface can move it onto a +//! private model the operator did not configure. +//! +//! The 2026-09-10 QA run (finding F1) measured the first half failing: with +//! `versa_azure` configured, every `POST /agent/start` on a `serve` daemon was +//! refused 409, because the new-chat gate asked for a proof this daemon can +//! never check. The browser showed nothing at all. +//! +//! ⚠ **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. + +// 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` +// or write the developer's real `config.yaml`. +#[path = "../src/test_sandbox.rs"] +mod test_sandbox; + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{HeaderMap, Request, StatusCode}; +use axum::Router; +use biorouter::config::{with_config_overrides, Config}; +use biorouter::conversation::message::Message; +use biorouter::privacy::refusal::USER_ACTION_REFUSAL_MARKER; +use biorouter::privacy::SessionClassification; +// Renamed on `main` (a MODEL, not a deployment — `VERSA_AZURE_DEPLOYMENTS` is +// the model -> deployment map that replaced it). This binary was left RED by +// the merge at f276111f, so nothing here ran until the name was repaired. +use biorouter::providers::versa_azure::VERSA_AZURE_DEFAULT_MODEL; +use biorouter_server::auth::{user_action_proof, UserActionProof}; +use biorouter_server::state::AppState; +use serde_json::{json, Value}; +use serial_test::serial; +use tower::ServiceExt; +use wiremock::matchers::{body_string_contains, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// The posture the QA run used: `biorouter configure` chose Versa. The key is a +/// placeholder — these tests construct the provider and never send it a request. +fn versa_is_the_configured_default() -> HashMap { + HashMap::from([ + ("BIOROUTER_PROVIDER".to_string(), "versa_azure".to_string()), + ( + "BIOROUTER_MODEL".to_string(), + VERSA_AZURE_DEFAULT_MODEL.to_string(), + ), + ( + "VERSA_AZURE_API_KEY".to_string(), + "placeholder-never-sent".to_string(), + ), + ]) +} + +/// A **public** configured default, for the tests that measure what happens when +/// the configuration moves after launch. `self_hosted_tier` reads `ollama` as +/// Public exactly while its host is not loopback, and constructing the provider +/// opens no connection, so `ollama.example` is never resolved. +fn a_public_ollama_is_the_configured_default() -> HashMap { + HashMap::from([ + ("BIOROUTER_PROVIDER".to_string(), "ollama".to_string()), + ("BIOROUTER_MODEL".to_string(), "stub-model".to_string()), + ( + "OLLAMA_HOST".to_string(), + "https://ollama.example".to_string(), + ), + ]) +} + +/// Stand up the launch posture SD-12's exemption is pinned to: a daemon started +/// with `configured` as its configuration, by a launcher that promised no +/// user-action key. +/// +/// Production samples this once in `commands::agent::run`. Here it has to happen +/// inside the override scope, because the overrides are a `tokio` task-local. +async fn launched_with(configured: HashMap) { + launched_by(configured, false).await; +} + +/// [`launched_with`], plus what the launcher claimed about the key it would send. +async fn launched_by(configured: HashMap, launcher_promised_a_key: bool) { + with_config_overrides(configured, async { + biorouter_server::launch::record_launch_state(launcher_promised_a_key); + }) + .await; +} + +/// 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 no gate below would fire either way" + ); +} + +async fn post_json(app: Router, uri: &str, body: Value) -> (StatusCode, String) { + let response = app + .oneshot( + Request::builder() + .uri(uri) + .method("POST") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = tokio::time::timeout( + Duration::from_secs(120), + axum::body::to_bytes(response.into_body(), usize::MAX), + ) + .await + .expect("the response body did not finish within two minutes") + .unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +fn start_request(working_dir: &std::path::Path) -> Value { + // No extensions: the chat under test needs a model and nothing else, and + // the machine default set is not this test's subject. + json!({ "working_dir": working_dir, "extension_overrides": [] }) +} + +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; +} + +/// F1, the half the QA run measured: the configured private provider is the +/// person's choice, made at the terminal, so binding it to a brand-new chat is +/// not a switch and needs no proof — on the one kind of daemon where no proof +/// can exist. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_starts_a_new_chat_on_its_configured_private_model() { + assert_the_daemon_is_keyless(); + launched_with(versa_is_the_configured_default()).await; + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + + let (status, body) = with_config_overrides( + versa_is_the_configured_default(), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "a keyless daemon refused to start a chat on its own configured model: {body}" + ); + let id = serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .expect("the started session carries an id") + .to_string(); + + let row = state + .session_manager() + .get_session(&id, false) + .await + .unwrap(); + assert_eq!(row.provider_name.as_deref(), Some("versa_azure")); + assert_eq!( + row.model_config.map(|config| config.model_name).as_deref(), + Some(VERSA_AZURE_DEFAULT_MODEL) + ); + // O5: the ratchet fires on the first turn, never on the bind. A chat that + // has touched nothing is not yet private — it is private-CAPABLE. + assert_eq!(row.privacy_tier, SessionClassification::Public); + + let agent = state.get_agent_for_route(id.clone()).await.unwrap(); + assert_eq!( + agent + .provider() + .await + .expect("the first turn must not fail with `Provider not set`") + .get_name(), + "versa_azure" + ); + + discard(&state, &id).await; +} + +/// The complement the exemption must not leak into: a request that asks a new +/// chat for a DIFFERENT private model than the one the operator configured is +/// still refused. `/agent/start` cannot name one, so the request that can is +/// `/agent/update_provider` on the chat it just made — `Private -> Private`, +/// which the raise predicate alone would call sideways and wave through. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_will_not_move_a_new_chat_to_a_private_model_nobody_configured() { + assert_the_daemon_is_keyless(); + launched_with(versa_is_the_configured_default()).await; + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + + let (status, body) = with_config_overrides( + versa_is_the_configured_default(), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let id = serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // A loopback Ollama is Private (`self_hosted_tier`), and constructing one + // opens no connection, so port 1 is never dialled. + let (status, body) = with_config_overrides( + HashMap::from([("OLLAMA_HOST".to_string(), "http://127.0.0.1:1".to_string())]), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/update_provider", + json!({ "session_id": id, "provider": "ollama", "model": "stub-model" }), + ), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "a keyless daemon moved a new chat onto a private model the operator never configured: \ + {body}" + ); + assert!( + body.contains(USER_ACTION_REFUSAL_MARKER), + "refused, but not by the tier gate: {body}" + ); + + let row = state + .session_manager() + .get_session(&id, false) + .await + .unwrap(); + assert_eq!( + row.provider_name.as_deref(), + Some("versa_azure"), + "the refused switch rewrote the row anyway" + ); + let agent = state.get_agent_for_route(id.clone()).await.unwrap(); + assert_eq!(agent.provider().await.unwrap().get_name(), "versa_azure"); + + discard(&state, &id).await; +} + +/// SD-1 is untouched by SD-12: the configured default itself still cannot be +/// changed from a keyless daemon, which is what makes it the operator's choice. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_still_refuses_to_change_the_configured_default() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let (status, body) = post_json( + biorouter_server::routes::config_management::routes(state), + "/config/set_provider", + json!({ "provider": "versa_azure", "model": VERSA_AZURE_DEFAULT_MODEL }), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); +} + +/// "privacy_tier ratchets on the first turn as usual": the exemption changes who +/// may bind the configured model, and nothing about what a turn on it does. +/// +/// The configured default here is an Ollama endpoint on loopback, because a +/// Versa module re-pointed at a stub server is no longer Private +/// (`ucsf_gateway_tier` reads the resolved host) and a test must not send +/// traffic to the real gateway. ⚠ No model runs: the endpoint is a `wiremock` +/// stub that returns one canned completion. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn the_first_turn_on_a_keyless_default_chat_ratchets_it_as_usual() { + assert_the_daemon_is_keyless(); + + let stub = MockServer::start().await; + let chunk = |delta: Value, finish: Value| { + json!({ + "id": "stub", "object": "chat.completion.chunk", "model": "stub-model", + "choices": [{ "index": 0, "delta": delta, "finish_reason": finish }] + }) + }; + let sse = format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + chunk( + json!({ "role": "assistant", "content": "ready" }), + Value::Null + ), + chunk(json!({ "content": "" }), json!("stop")), + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("\"stream\":true")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse), + ) + .with_priority(1) + .mount(&stub) + .await; + // Anything that asks without streaming — the chat's auto-title — gets a + // plain completion rather than an event stream it cannot parse. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "stub", "object": "chat.completion", "model": "stub-model", + "choices": [{ "index": 0, "finish_reason": "stop", + "message": { "role": "assistant", "content": "Stub title" } }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + }))) + .mount(&stub) + .await; + + // Written to this binary's sandboxed config.yaml rather than scoped to a + // task: the turn runs on a spawned task, which a task-local override would + // not reach. + let config = Config::global(); + config.set_param("BIOROUTER_PROVIDER", "ollama").unwrap(); + config.set_param("BIOROUTER_MODEL", "stub-model").unwrap(); + config.set_param("OLLAMA_HOST", stub.uri()).unwrap(); + // …and that IS this daemon's launch configuration, so SD-12's exemption + // applies. Recorded after the writes rather than before, for the reason + // production records it before `AppState::new()`: the snapshot has to be the + // configuration the requests will read. + biorouter_server::launch::record_launch_state(false); + + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + let (status, body) = post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let id = serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let before = state + .session_manager() + .get_session(&id, false) + .await + .unwrap(); + assert_eq!(before.provider_name.as_deref(), Some("ollama")); + assert_eq!(before.privacy_tier, SessionClassification::Public); + + let message = Message::user().with_text("Reply with the single word ready."); + let (status, stream) = post_json( + biorouter_server::routes::reply::routes(Arc::clone(&state)), + "/reply", + json!({ "user_message": message, "session_id": id }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{stream}"); + assert!( + stream.contains("\"Finish\""), + "the turn did not finish: {stream}" + ); + assert!( + stream.contains("ready"), + "the turn did not run on the stub: {stream}" + ); + + let after = state + .session_manager() + .get_session(&id, false) + .await + .unwrap(); + assert_eq!( + after.privacy_tier, + SessionClassification::Private, + "a turn on the configured private model did not ratchet the chat" + ); + assert_eq!(after.privacy_reason.as_deref(), Some("turn:ollama")); + + // The chat's NEXT request, now that it is private. A keyless daemon reaches a + // private chat only for a caller whose stated capability covers it, so a + // browser tab that states nothing loses the chat it just started; the host's + // provider, which is what a tab on this host states (SD-12), keeps it. + let reach = |caller: Option<&str>| { + let mut request = Request::builder().uri(format!("/sessions/{id}")); + if let Some(provider) = caller { + request = request.header("X-Caller-Provider", provider); + } + let app = biorouter_server::routes::session::routes(Arc::clone(&state)); + async move { + app.oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + .status() + } + }; + assert_eq!(reach(None).await, StatusCode::FORBIDDEN); + assert_eq!(reach(Some("ollama")).await, StatusCode::OK); + + discard(&state, &id).await; + for key in ["BIOROUTER_PROVIDER", "BIOROUTER_MODEL", "OLLAMA_HOST"] { + let _ = config.delete(key); + } +} + +/// **Finding 1 (HIGH), the review that refuted SD-12's first justification.** +/// +/// SD-12 rested on *"`/agent/start` binds `BIOROUTER_PROVIDER`, a key only a +/// proven person may write"*. The HTTP doors to that key are genuinely closed — +/// `a_keyless_daemon_still_refuses_to_change_the_configured_default` above is one +/// of them — but `config.yaml` is not an HTTP resource. DR-14's filesystem deny +/// is DEFERRED, the agent holds `developer__shell`, and `Config` re-`stat`s and +/// reloads the file, so a model could write the provider it wanted and then ask +/// for a new chat on it. +/// +/// The exemption is pinned to the launch configuration instead: the daemon here +/// started on a **public** Ollama, so the private provider that appeared in the +/// file afterwards is nobody's declaration and the bind is refused. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_private_provider_written_after_launch_does_not_start_a_new_chat() { + assert_the_daemon_is_keyless(); + launched_with(a_public_ollama_is_the_configured_default()).await; + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + + let (status, body) = with_config_overrides( + versa_is_the_configured_default(), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "a private provider written into config.yaml after this daemon launched started a chat \ + at Private capability: {body}" + ); + assert!( + body.contains("BIOROUTER_PROVIDER"), + "the refusal must name the key that moved, so the operator can act on it: {body}" + ); + assert!( + body.to_lowercase().contains("restart"), + "the refusal must say how to make the new configuration take effect: {body}" + ); +} + +/// The same escalation through a key that is **not** the provider's name, which +/// is why the pin is the whole capability list rather than `BIOROUTER_PROVIDER` +/// alone: `self_hosted_tier` reads `ollama` as Private exactly while its host is +/// loopback, so moving `OLLAMA_HOST` moves the tier with the provider name +/// untouched. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_private_endpoint_written_after_launch_does_not_start_a_new_chat() { + assert_the_daemon_is_keyless(); + launched_with(a_public_ollama_is_the_configured_default()).await; + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + + let mut flipped_to_loopback = a_public_ollama_is_the_configured_default(); + // Private, and never dialled: constructing an Ollama provider opens nothing. + flipped_to_loopback.insert("OLLAMA_HOST".to_string(), "http://127.0.0.1:1".to_string()); + + let (status, body) = with_config_overrides( + flipped_to_loopback, + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "moving the endpoint alone was enough to mint a Private-capability chat: {body}" + ); + assert!( + body.contains("OLLAMA_HOST"), + "the refusal named the wrong key: {body}" + ); +} + +/// **Finding 3 (LOW).** `NoKeyInstalled` is "this process read no valid digest", +/// which is two situations wearing one name. SD-12's exemption is for the one +/// where no proof can *ever* exist; a desktop daemon whose key never arrived — +/// `userActionKey` undefined, or the bounded 2s stdin read timing out — is a +/// fault to repair, and on `main` it degraded loudly by refusing every private +/// new chat. It must keep doing that rather than silently binding. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_daemon_whose_launcher_promised_a_key_and_got_none_refuses_the_exemption() { + assert_the_daemon_is_keyless(); + launched_by(versa_is_the_configured_default(), true).await; + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + + let (status, body) = with_config_overrides( + versa_is_the_configured_default(), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "a daemon that expected a user-action key and got none took SD-12's exemption anyway: \ + {body}" + ); + assert!( + body.contains("user-action key"), + "the refusal must say the key is missing, not that the user did not confirm: {body}" + ); + + // …and the same configuration, launched by something that promised nothing, + // still starts the chat. Without this the test above would pass on a build + // that had simply broken the exemption. + launched_with(versa_is_the_configured_default()).await; + let (status, body) = with_config_overrides( + versa_is_the_configured_default(), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let id = serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + discard(&state, &id).await; +} diff --git a/crates/biorouter-server/tests/privacy_ar15_is_retired.rs b/crates/biorouter-server/tests/privacy_ar15_is_retired.rs index 6862181b4..dcbe25522 100644 --- a/crates/biorouter-server/tests/privacy_ar15_is_retired.rs +++ b/crates/biorouter-server/tests/privacy_ar15_is_retired.rs @@ -345,9 +345,24 @@ fn the_documented_closure_is_the_one_the_code_performs() { // The docs now assert a specific gate. If the gate goes, the docs are // wrong in the *dangerous* direction — claiming a hole is closed when it is // open — and no other test in this tree ties the two together. - let refusal = AGENT_ROUTE - .find("PrivacyRefusal::TierRaiseNeedsUser") - .expect("routes/agent.rs no longer refuses an unproven tier raise at all"); + // + // ⚠ The scan starts at `update_agent_provider`, the handler AR-15 is about. + // It used to take the FIRST refusal in the file, which stopped being this + // handler's when `eb594ded` put the new-chat gate above it: from then on the + // scan read that gate, and deleting the proof from this one left it green. + // The new-chat gate is SD-12's, a different rule with its own tests in + // `routes/agent.rs`. + let handler = AGENT_ROUTE + .find("async fn update_agent_provider") + .expect("update_agent_provider moved; AR-15's gate is the one inside it"); + let body = cut_from(AGENT_ROUTE, handler); + // Bounded by the next route, so a refusal that left this handler cannot be + // found in the one after it. + let body = cut_to(body, body.find("\n#[utoipa::path(").unwrap_or(body.len())); + let refusal = handler + + body + .find("PrivacyRefusal::TierRaiseNeedsUser") + .expect("update_agent_provider no longer refuses an unproven tier raise at all"); let guard = cut_to(AGENT_ROUTE, refusal) .rfind(" if ") .expect("the tier-raise refusal is not inside an `if`"); @@ -370,6 +385,34 @@ fn the_documented_closure_is_the_one_the_code_performs() { ); } + // SD-12 (PR #229) put a second thing between the raise predicate and the + // truth, and it is this PR's own protection, so this PR pins it: on a daemon + // holding no user-action key, `raise_baseline` forces the capability the raise + // is measured FROM down to Public, which is what keeps a `Private -> Private` + // sideways move onto a private model nobody configured refused. Delete it and + // every token above is still present, the condition still reads as a gate, and + // the sideways move is waved through as "not a raise". `routes/agent.rs`'s unit + // tests cover the function; nothing covered its WIRING. + // + // ⚠ Two assertions, not one token in the loop above, and the split is + // load-bearing: the call is a `let` on the line *before* the `if`, so the + // condition slice — which starts at the last ` if ` — structurally cannot + // contain it. The handler carries the call; the condition carries the half + // that matters just as much, that the predicate measures from that value + // rather than from the chat's live `current` binding. + let handler_body = cut(AGENT_ROUTE, handler, refusal); + assert!( + handler_body.contains("raise_baseline("), + "update_agent_provider no longer computes a `raise_baseline`, so a keyless daemon measures \ + a private raise from the chat's live binding again and SD-12's exemption can be carried \ + sideways onto a private model the operator never configured" + ); + assert!( + squash(condition).contains("raise_needs_user_action(baseline,"), + "the tier-raise predicate is no longer measured from `raise_baseline`'s result, so \ + computing it changes nothing.\nGuard reads: {condition}" + ); + // A negative control, so the extractor is provably not matching anything it // is handed: the same file's `update_working_dir` is not a raise channel. let elsewhere = AGENT_ROUTE diff --git a/crates/biorouter-server/tests/serve_operator_reach.rs b/crates/biorouter-server/tests/serve_operator_reach.rs new file mode 100644 index 000000000..35c496b8c --- /dev/null +++ b/crates/biorouter-server/tests/serve_operator_reach.rs @@ -0,0 +1,193 @@ +//! Issue #56, QA 2026-09-10 (SD-10): a `biorouter serve` daemon's own web +//! interface keeps the reach its operator's provider implies — on the listing +//! and knowledge-base surfaces, which were open to it before they were gated — +//! and a caller holding only the daemon secret does not. +//! +//! ⚠ **Its own test binary on purpose.** The operator standing and the +//! user-action digest are both process-global `OnceLock`s. Nothing here +//! installs a digest, which is exactly how `biorouter serve` starts its daemon +//! (`Stdio::null()`, SD-7), and the operator standing installed below must not +//! leak into any other binary's view of the gates. + +// 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 axum::{body::Body, http::Request, Router}; +use biorouter::conversation::message::Message; +use biorouter::model::ModelConfig; +use biorouter::privacy::{ProviderTier, SessionClassification}; +use biorouter::session::SessionType; +use biorouter_mcp::knowledge::service::KnowledgeService; +use biorouter_server::routes::session_reach::{KNOWLEDGE_BASE_REACH_NO_KEY, SESSION_REACH_NO_KEY}; +use biorouter_server::state::AppState; +use std::sync::Arc; +use tower::ServiceExt; + +/// The browser token `biorouter serve` would have minted for this launch. +const BROWSER_TOKEN: &str = "9f1c2e7a5b3d4c6e8f0a1b2c3d4e5f60"; +const SENTINEL: &str = "sd9-served-operator-marker-not-real-data"; + +/// The operator configured a private provider — institution-hosted — so SD-1 +/// pins every session this daemon runs to a private model. +fn install_private_operator() { + biorouter_server::auth::install_served_operator( + BROWSER_TOKEN.to_string(), + ProviderTier::Private, + ); +} + +fn served_document_cookie() -> String { + format!("biorouter_session={BROWSER_TOKEN}") +} + +async fn send(app: &Router, uri: &str, cookie: Option<&str>) -> (u16, String) { + let mut builder = Request::builder().uri(uri); + if let Some(cookie) = cookie { + builder = builder.header("cookie", cookie); + } + let res = app + .clone() + .oneshot(builder.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = res.status().as_u16(); + let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +/// The Knowledge view in the operator's browser still reads a private base in +/// full; a caller holding the same secret without the served document's cookie +/// — or with a cookie that is not it — is refused, in the keyless daemon's own +/// words, and is listed only the public base. +#[tokio::test] +async fn the_served_interface_keeps_the_operators_reach_on_knowledge_bases() { + install_private_operator(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let svc = Arc::new(KnowledgeService::new(root.clone())); + svc.create_base("omop", "OMOP", None).unwrap(); + svc.create_base("notes", "Notes", None).unwrap(); + let page = root.join("omop").join("knowledge").join("x.md"); + std::fs::create_dir_all(page.parent().unwrap()).unwrap(); + std::fs::write(&page, format!("# x\n\n{SENTINEL}\n")).unwrap(); + biorouter_mcp::knowledge::tier::raise_unlocked(&root, "omop", true).unwrap(); + let app = biorouter_server::routes::knowledge::router(svc); + + let cookie = served_document_cookie(); + let (status, body) = send(&app, "/bases/omop/page?path=knowledge/x.md", Some(&cookie)).await; + assert_eq!( + status, 200, + "the operator's own browser lost its Knowledge view: {body}" + ); + assert!(body.contains(SENTINEL)); + let (status, body) = send(&app, "/bases", Some(&cookie)).await; + assert_eq!(status, 200); + assert!(body.contains("omop") && body.contains("notes"), "{body}"); + + for (label, cookie) in [ + ("no cookie", None), + ( + "a cookie that is not the served document's", + Some("biorouter_session=guessed"), + ), + ( + "a cookie under another name", + Some("other_session=9f1c2e7a5b3d4c6e8f0a1b2c3d4e5f60"), + ), + ] { + let (status, body) = send(&app, "/bases/omop/page?path=knowledge/x.md", cookie).await; + assert_eq!( + (status, body.as_str()), + (403, KNOWLEDGE_BASE_REACH_NO_KEY), + "{label}: a caller holding only the secret read a private base" + ); + let (status, body) = send(&app, "/bases", cookie).await; + assert_eq!(status, 200); + assert!( + body.contains("notes") && !body.contains("omop"), + "{label}: listed a private base: {body}" + ); + } +} + +/// On chats, the operator standing preserves the History list — and nothing +/// else. The transcript gate refused this browser every private chat before +/// this change and still does, and so does every route that names a chat: +/// deleting one is never cheaper than reading it. Widening the transcript gate +/// for a serve operator is recorded as an open decision (SD-10), not taken. +#[tokio::test(flavor = "multi_thread")] +async fn the_served_interface_keeps_its_history_list_and_gains_nothing_else() { + install_private_operator(); + let state = AppState::new().await.unwrap(); + let manager = state.session_manager(); + let chat = manager + .create_session( + std::path::PathBuf::from("/tmp/sd9_served_operator"), + "SD-10 private (test fixture)".to_string(), + SessionType::User, + ) + .await + .unwrap(); + manager + .add_message(&chat.id, &Message::user().with_text(SENTINEL)) + .await + .unwrap(); + manager + .update(&chat.id) + .provider_name("versa_azure") + .model_config(ModelConfig::new("gpt-4o").unwrap()) + .raise_privacy(SessionClassification::Private, "turn:versa_azure") + .apply() + .await + .unwrap(); + let app = biorouter_server::routes::configure(state.clone(), "sd9-secret".to_string()); + let cookie = served_document_cookie(); + + let (status, body) = send(&app, "/sessions", Some(&cookie)).await; + assert_eq!(status, 200); + assert!( + body.contains(&chat.id), + "the operator's history lost a private chat" + ); + let (status, body) = send(&app, "/sessions", None).await; + assert_eq!(status, 200); + assert!( + !body.contains(&chat.id), + "a secret-only caller was listed a private chat" + ); + + // The transcript: refused before this change, refused after — with the + // cookie or without it. + for cookie in [Some(cookie.as_str()), None] { + let (status, body) = send(&app, &format!("/sessions/{}", chat.id), cookie).await; + assert_eq!( + (status, body.as_str()), + (403, SESSION_REACH_NO_KEY), + "{cookie:?}: the served-operator standing reached a private transcript" + ); + } + let res = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/sessions/{}", chat.id)) + .header("cookie", served_document_cookie()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + 403, + "a route that names a chat admitted what the transcript read refuses" + ); + assert!(manager.get_session(&chat.id, false).await.is_ok()); + + manager.delete_session(&chat.id).await.unwrap(); +} diff --git a/crates/biorouter-server/tests/turn_control_no_user_key.rs b/crates/biorouter-server/tests/turn_control_no_user_key.rs index d9b026d07..faa2c2ac5 100644 --- a/crates/biorouter-server/tests/turn_control_no_user_key.rs +++ b/crates/biorouter-server/tests/turn_control_no_user_key.rs @@ -30,6 +30,15 @@ //! (`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. +//! +//! ⚠ **The terminal reads what these refusals look like.** `biorouter session` +//! cannot ask a daemon whether it holds a key, so `session cancel`, `attach` and +//! `send` go out without one and read the answer (`commands/session_watch.rs`, +//! `key_verdict`): an EMPTY 403 means "this daemon holds a key", and only that +//! makes the terminal ask the person for it. Every refusal here must therefore +//! carry the daemon's sentence, or a `serve` user is asked for a key that does +//! not exist — `the_terminals_empty_steer_is_answered_by_the_gate_and_touches_nothing` +//! pins it for the question the terminal actually sends. // 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`. @@ -566,6 +575,88 @@ async fn a_subagents_turn_is_still_refused_and_the_refusal_says_why() { discard(&state, &id).await; } +/// The empty steer exactly as `biorouter session` sends it to ask whether it may +/// steer (`steer_gate_question` in `commands/session_watch.rs`): no +/// `X-User-Action` header at all — the browser's shim sends an empty one — and +/// no turn id. +fn terminal_question(session_id: &str, caller_provider: Option<&str>) -> Request { + let mut request = Request::builder() + .uri("/interrupt") + .method("POST") + .header("content-type", "application/json"); + if let Some(provider) = caller_provider { + request = request.header("X-Caller-Provider", provider); + } + request + .body(Body::from( + json!({ "session_id": session_id, "text": "" }).to_string(), + )) + .unwrap() +} + +/// The question `biorouter session attach` asks as it joins, and `session send` +/// after a refusal: would this daemon take a steer from this terminal, and does +/// it want the user-action key for one? +/// +/// The answer here is always **no, and here is why** — `STEER_NO_KEY`, a 403 +/// carrying the daemon's own sentence. ⚠ This is the row where SD-11 settled +/// narrower than the branch that wrote this test assumed. `POST /interrupt` is +/// NOT admitted by the reach gate on a keyless daemon: it asks for the proof on +/// **both** kinds, so `reply::steer_refusal` answers from the HEADERS, before +/// the body is parsed and before any chat is resolved. So the chat and the +/// stated caller change nothing, where an earlier draft expected a 400 for a +/// chat the gate would have admitted. +/// +/// What the terminal actually needs is unchanged, and is what this asserts: +/// never the EMPTY 403 that means "this daemon holds a key", which would send +/// the terminal to ask the person for one that does not exist; always a sentence +/// it can print instead; and the question touches neither the turn nor the +/// agent's queue — now trivially, since nothing is reached. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn the_terminals_empty_steer_is_answered_by_the_gate_and_touches_nothing() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + // Every chat, asked with and without a stated capability: the answer is the + // same 403 and the same sentence, because the refusal is decided from the + // headers alone. The rows are kept rather than collapsed so that a change + // admitting the steer for SOME chat fails here instead of passing quietly. + for (chat, caller, expected) in [ + (Chat::Public, None, StatusCode::FORBIDDEN), + (Chat::Private, None, StatusCode::FORBIDDEN), + (Chat::Private, Some("versa_azure"), StatusCode::FORBIDDEN), + (Chat::Subagent, None, StatusCode::FORBIDDEN), + (Chat::Subagent, Some("versa_azure"), StatusCode::FORBIDDEN), + ] { + let id = seed(&state, chat).await; + let (guard, token) = begin_turn(&state, &id); + let agent = state.get_agent(id.clone()).await.unwrap(); + agent.open_for_turn(TurnId::new("questioned-agent-turn")); + + let (status, body) = send(reply_routes(&state), terminal_question(&id, caller)).await; + + assert_eq!(status, expected, "{chat:?} asked by {caller:?}: {body}"); + if status == StatusCode::FORBIDDEN { + assert!( + body.contains("without a user-action key"), + "a keyless refusal without the daemon's sentence reads to the terminal as \ + 'this daemon wants a key': {body:?}" + ); + } + assert!( + !agent.has_soft_interrupts(), + "the terminal's question reached the agent's queue" + ); + assert!( + !token.is_cancelled(), + "the terminal's question reached the turn" + ); + + 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 diff --git a/crates/biorouter/src/agents/agent.rs b/crates/biorouter/src/agents/agent.rs index 829afb7e2..b5ee1731f 100644 --- a/crates/biorouter/src/agents/agent.rs +++ b/crates/biorouter/src/agents/agent.rs @@ -696,13 +696,27 @@ fn skill_already_loaded_pointer() -> &'static str { // `user` message every turn. That over-reached: when the agent was genuinely stuck // (e.g. an unrecoverable provider error), it re-injected the same message forever // and never resolved the root cause — and it polluted the conversation with fake -// user input. "Don't stop while work is unfinished" is now left to the proper, -// bounded, user-configurable mechanisms: the Stop-hook system (`StopHookVerdict`, -// capped by `STOP_HOOK_BLOCK_CAP`, delivered as hidden-visibility feedback + a -// user-facing system notification) and the `/goal` loop (whose stall budget does -// NOT reset when tools run, so it gives up when progress stalls). A user who wants -// "keep going until the todos are done" sets a `/goal` or a Stop hook — both go -// through that bounded, stall-aware path instead of an unbounded loop injection. +// user input. "Don't stop while work is unfinished" now goes through bounded +// paths only: the Stop-hook system (`StopHookVerdict`, capped by +// `STOP_HOOK_BLOCK_CAP`, delivered as hidden-visibility feedback + a user-facing +// system notification), the `/goal` loop (whose stall budget does NOT reset when +// tools run, so it gives up when progress stalls), and — for the checklist +// itself — the planning gate's stop check (`agents::planning_gate`). That check +// is the old gate's intent without its defects: it runs only on a turn that +// worked the list, is satisfied by a final message that NAMES the open items, +// speaks through the same hidden feedback + notice as a Stop hook, and blocks at +// most `STOP_HOOK_BLOCK_CAP` times per turn on a count that does not reset when +// tools run. + +/// How a turn's attempt to finish resolves — decided by +/// [`Agent::decide_turn_stop`], acted on by the reply loop. +enum TurnStop { + /// Let the turn end, after showing the user `notices`. + Finish { notices: Vec }, + /// Keep working: `feedback` goes to the model as a hidden steer, `notice` + /// to the user. + KeepWorking { feedback: String, notice: String }, +} /// Context needed for the reply function pub struct ReplyContext { @@ -3474,6 +3488,9 @@ pub struct Agent { pub(super) hooks_manager: Arc, /// Active `/goal` conditions per session (see [`crate::agents::goal`]). pub(super) goals: crate::agents::goal::GoalRegistry, + /// The planning gate's per-turn state, per session (see + /// [`crate::agents::planning_gate`]). + pub(super) planning: crate::agents::planning_gate::PlanningRegistry, /// Lazily-created scheduler for `/loop`/`/schedule` when no /// `scheduler_service` was injected (plain CLI/TUI sessions). pub(super) fallback_scheduler: tokio::sync::OnceCell>, @@ -4309,6 +4326,7 @@ impl Agent { )), hooks_manager, goals: Default::default(), + planning: Default::default(), fallback_scheduler: tokio::sync::OnceCell::new(), vault: Mutex::new(None), soft_interrupts: Arc::new(std::sync::Mutex::new(SoftInterrupts::new())), @@ -5531,6 +5549,9 @@ impl Agent { let _phase = super::phase_timing::Phase::start("agent.assemble_turn_context"); let moim_phase = super::phase_timing::Phase::start("agent.inject_moim"); + // The planning gate's "write the checklist first", for as long as a + // multi-step turn's list is empty. See `planning_gate`. + let reminder = self.planning_reminder(session_id).await; let (conversation, moim_injected) = super::moim::inject_moim( session_id, conversation.clone(), @@ -5538,6 +5559,7 @@ impl Agent { working_dir, &self.normalizer, cancel, + reminder.as_deref(), ) .await; drop(moim_phase); @@ -5611,6 +5633,18 @@ impl Agent { inspection_results.append(&mut revalidated); } + // The planning gate's once-per-turn redirect, added as inspection + // results so the permission merge, the denial path and the transcript + // treat it like any other refusal. Deliberately NOT a registered + // inspector: the coding-agent bridge and the approval relay run the + // registered set too, and the bridge flattens every denial to a + // generic "denied by policy" — a pointer to `todo_write` that never + // reaches the model is a refusal with no way forward. + inspection_results.extend( + self.planning_gate_denials(&session.id, remaining_requests) + .await, + ); + let permission_check_result = self .tool_inspection_manager .process_inspection_results_with_permission_inspector( @@ -8637,6 +8671,14 @@ impl Agent { self.restore_goal(&session_config.id).await; let message_text = user_message.as_concat_text(); + // The planning gate's reading of this prompt. Classified here, where + // the prompt text is known, and armed in `reply_internal`, where the + // turn's tool roster is. A slash command is not a request to plan. + let planning_signal = if message_text.trim().starts_with('/') { + None + } else { + crate::agents::planning_gate::classify_request(&message_text) + }; // User-configured hooks: SessionStart fires once per session, then // UserPromptSubmit may block the prompt or inject context. Slash @@ -9038,7 +9080,7 @@ impl Agent { } }; - let mut reply_stream = self.reply_internal(final_conversation, rewrite_basis, session_config, session, cancel_token).await?; + let mut reply_stream = self.reply_internal(final_conversation, rewrite_basis, session_config, session, cancel_token, planning_signal).await?; while let Some(event) = reply_stream.next().await { yield event?; } @@ -9200,6 +9242,79 @@ impl Agent { } } + /// Everything that decides whether a turn may end, in order: the planning + /// gate's checklist check, then the Stop hooks (a `/goal` judge is one). + /// + /// The checklist goes first because it is deterministic and cheap — a + /// command hook may run a test suite and a prompt hook costs a model call, + /// neither worth paying on a stop the checklist is about to refuse. It is + /// skipped while a `/goal` is active: that session already has a judge + /// re-reading the work on every stop, and a checklist block would be + /// counted against the goal's own budget by `stop_hook_block_feedback`. + /// + /// Split out of the `reply_internal` generator to keep its `poll` frame + /// small — see [`ToolBatchMaps`]. + async fn decide_turn_stop( + &self, + session_id: &str, + working_dir: &std::path::Path, + conversation: &Conversation, + active_goal: Option, + ) -> TurnStop { + let mut notices = Vec::new(); + if active_goal.is_none() { + match self.checklist_stop(session_id, conversation).await { + crate::agents::planning_gate::ChecklistStop::Block { feedback, notice } => { + return TurnStop::KeepWorking { feedback, notice }; + } + crate::agents::planning_gate::ChecklistStop::GiveUp { notice } => { + notices.push(notice); + } + crate::agents::planning_gate::ChecklistStop::Clear => {} + } + } + + let transcript_tail = crate::agents::goal::transcript_tail(conversation); + match self + .hooks_manager + .stop(session_id, working_dir, transcript_tail) + .await + { + crate::hooks::StopHookVerdict::Proceed => { + // An active goal whose evaluator let the stop proceed is met: + // clear it and tell the user. + if let Some(goal) = active_goal { + self.clear_goal(session_id).await; + notices.push(format!( + "🎯 Goal met and cleared: {}", + crate::agents::goal::ellipsize(&goal.condition, 200) + )); + } + TurnStop::Finish { notices } + } + crate::hooks::StopHookVerdict::CapReached => { + let goal_hint = if active_goal.is_some() { + " The /goal stays active and will be re-evaluated next turn; run /goal clear to stop it." + } else { + "" + }; + notices.push(format!( + "Stop hook block limit ({}) reached; finishing anyway.{}", + crate::hooks::STOP_HOOK_BLOCK_CAP, + goal_hint + )); + TurnStop::Finish { notices } + } + crate::hooks::StopHookVerdict::Blocked { reason } => { + // The goal-budget accounting lives on `stop_hook_block_feedback`. + let (feedback, notice) = self + .stop_hook_block_feedback(session_id, &reason, active_goal.is_some()) + .await; + TurnStop::KeepWorking { feedback, notice } + } + } + } + /// Bill one overflow-recovery compaction's provider round-trips to both the /// reply budget and the session gauge. /// @@ -9241,6 +9356,7 @@ impl Agent { session_config: SessionConfig, session: Session, cancel_token: Option, + planning_signal: Option, ) -> Result>> { let session_manager = self.config.session_manager.clone(); let provider_conversation = crate::conversation::without_bedrock_reasoning(&conversation); @@ -9269,6 +9385,15 @@ impl Agent { } = context; let reply_span = tracing::Span::current(); self.reset_retry_attempts().await; + // Opened here, outside the generator: the turn's roster is final and + // `session` still holds the checklist as the turn found it. + self.begin_planning_turn( + &session, + planning_signal, + &tools, + &toolshim_tools, + reply_provider.uses_tool_bridge_for_tool_surface(), + ); // Freshness basis for this turn's overflow-recovery compactions. // @@ -11290,69 +11415,40 @@ impl Agent { } } - let transcript_tail = crate::agents::goal::transcript_tail(&conversation); - match self.hooks_manager.stop(&session_config.id, &session.working_dir, transcript_tail).await { - crate::hooks::StopHookVerdict::Proceed => { - // An active goal whose evaluator let the stop - // proceed is met: clear it and tell the user. - if let Some(goal) = active_goal { - self.clear_goal(&session_config.id).await; - yield AgentEvent::Message( - inline_notice_user_only( - format!( - "🎯 Goal met and cleared: {}", - crate::agents::goal::ellipsize(&goal.condition, 200) - ), - ), - ); + // The planning gate's checklist check, then the Stop hooks + // (a /goal judge is one). The deciding lives in + // `decide_turn_stop`, out of this generator's `poll` frame; + // only the yields are left here. + match self.decide_turn_stop( + &session_config.id, + &session.working_dir, + &conversation, + active_goal, + ).await { + TurnStop::Finish { notices } => { + for notice in notices { + yield AgentEvent::Message(inline_notice_user_only(notice)); } break; } - crate::hooks::StopHookVerdict::CapReached => { - let goal_hint = if active_goal.is_some() { - " The /goal stays active and will be re-evaluated next turn; run /goal clear to stop it." - } else { - "" - }; - yield AgentEvent::Message( - inline_notice_user_only( - format!( - "Stop hook block limit ({}) reached; finishing anyway.{}", - crate::hooks::STOP_HOOK_BLOCK_CAP, - goal_hint - ), - ), - ); - break; - } - crate::hooks::StopHookVerdict::Blocked { reason } => { - // The goal-budget accounting lives on - // `stop_hook_block_feedback`. - let (feedback_text, notice) = self.stop_hook_block_feedback( - &session_config.id, - &reason, - active_goal.is_some(), - ).await; - + TurnStop::KeepWorking { feedback, notice } => { // #59 / #66 SHAPE 2: hidden from the user, named for // the client. let (feedback, published) = persist_steering_message( &session_manager, &session_config.id, - feedback_text, + feedback, ).await?; if let Some(published) = published { yield published; } conversation.push(feedback); - yield AgentEvent::Message( - inline_notice_user_only(notice,), - ); + yield AgentEvent::Message(inline_notice_user_only(notice)); // Keep looping: the model sees the feedback next turn. - // After a give-up the goal is cleared, so the next stop + // After a goal gives up it is cleared, so the next stop // proceeds once the agent delivers its wrap-up. // - // #69: a blocked Stop reverses the exit the queue was + // #69: a blocked stop reverses the exit the queue was // closed for, so re-open it for the extra work. self.reopen_for_more_work(); } @@ -16783,6 +16879,16 @@ mod tests { script, so Code Execution mode must leave it directly callable: {names:?}" ); } + // The checklist is the second exemption, and the reason is the model + // rather than the plumbing: behind a script wrapper it went unused. + // `agent_for_tests` loads Todo, so all five are on this roster. + for expected in crate::agents::todo_extension::TODO_TOOL_NAMES { + assert!( + names.contains(&expected), + "{expected} must stay a direct call in Code Execution mode, or the planning \ + gate points the model at a tool it can only reach from a script: {names:?}" + ); + } } /// The exemption is written against the ONE predicate that also decides the diff --git a/crates/biorouter/src/agents/code_execution_extension.rs b/crates/biorouter/src/agents/code_execution_extension.rs index 36c446e5d..ef4143349 100644 --- a/crates/biorouter/src/agents/code_execution_extension.rs +++ b/crates/biorouter/src/agents/code_execution_extension.rs @@ -1834,6 +1834,42 @@ impl CodeExecutionClient { .unwrap_or_default() } + /// The `_meta` a finished script's collected artifacts and tool calls carry, + /// or `None` when it collected neither. + /// + /// Split out of [`Self::handle_execute_code`] so that function stays under + /// `clippy::too_many_lines`. It is assembly only — every key here is read by + /// the desktop artifact panel and by `ToolCallWithResponse`, so adding one + /// means adding a reader, not only a writer. + fn collected_meta(collected: &CollectedArtifacts) -> Option { + let mut meta = JsonObject::new(); + if !collected.app_paths.is_empty() { + if let Some(path) = &collected.last_app_path { + meta.insert( + "biorouter/app-path".to_string(), + Value::String(path.clone()), + ); + } + meta.insert( + "biorouter/app-paths".to_string(), + serde_json::to_value(&collected.app_paths).unwrap_or_default(), + ); + } + if !collected.tool_calls.is_empty() { + meta.insert( + TOOL_CALLS_META_KEY.to_string(), + serde_json::to_value(&collected.tool_calls).unwrap_or_default(), + ); + if collected.dropped_tool_calls > 0 { + meta.insert( + TOOL_CALLS_DROPPED_META_KEY.to_string(), + Value::from(collected.dropped_tool_calls), + ); + } + } + (!meta.is_empty()).then_some(rmcp::model::Meta(meta)) + } + async fn handle_execute_code( &self, session_id: &str, @@ -1925,32 +1961,7 @@ impl CodeExecutionClient { tool_handler.abort(); let mut collected = collected_artifacts.lock().await; - let mut meta = JsonObject::new(); - if !collected.app_paths.is_empty() { - if let Some(path) = &collected.last_app_path { - meta.insert( - "biorouter/app-path".to_string(), - Value::String(path.clone()), - ); - } - meta.insert( - "biorouter/app-paths".to_string(), - serde_json::to_value(&collected.app_paths).unwrap_or_default(), - ); - } - if !collected.tool_calls.is_empty() { - meta.insert( - TOOL_CALLS_META_KEY.to_string(), - serde_json::to_value(&collected.tool_calls).unwrap_or_default(), - ); - if collected.dropped_tool_calls > 0 { - meta.insert( - TOOL_CALLS_DROPPED_META_KEY.to_string(), - Value::from(collected.dropped_tool_calls), - ); - } - } - let meta = (!meta.is_empty()).then_some(rmcp::model::Meta(meta)); + let meta = Self::collected_meta(&collected); match js_result { Ok(r) => { diff --git a/crates/biorouter/src/agents/knowledge_source_tool.rs b/crates/biorouter/src/agents/knowledge_source_tool.rs index 07d2bef97..c4ac18a09 100644 --- a/crates/biorouter/src/agents/knowledge_source_tool.rs +++ b/crates/biorouter/src/agents/knowledge_source_tool.rs @@ -452,6 +452,162 @@ mod tests { } } + // ----------------------------------------------------------------------- + // Gate H: the alternate provider a MODEL names + // ----------------------------------------------------------------------- + + fn public_session(id: &str) -> Session { + use crate::session::session_manager::SessionType; + Session { + id: id.to_string(), + working_dir: PathBuf::from("."), + name: "Gate H".to_string(), + user_set_name: false, + session_type: SessionType::User, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + extension_data: Default::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + workflow: None, + user_workflow_values: None, + conversation: None, + message_count: 0, + provider_name: None, + model_config: None, + diverged_from: None, + branch_point_msg_uid: None, + parent_session_id: None, + privacy_tier: crate::privacy::SessionClassification::Public, + privacy_reason: None, + } + } + + /// `ollama` resolved against a host, so `providers::create` builds a REAL + /// provider whose `tier()` is the production implementation reading the + /// resolved base URL. 127.0.0.1 is loopback, so it reads PRIVATE; port 1 + /// refuses instantly, so nothing here waits on a model. + fn ollama_at(host: &str) -> std::collections::HashMap { + std::collections::HashMap::from([("OLLAMA_HOST".to_string(), host.to_string())]) + } + const A_PRIVATE_HOST: &str = "http://127.0.0.1:1"; + const A_PUBLIC_HOST: &str = "https://api.example-saas.invalid"; + + async fn ingest_on(kb: &str, session: &Session, host: &str) -> ToolResult> { + crate::config::with_config_overrides( + ollama_at(host), + handle_ingest_source_with_provider( + serde_json::json!({ + "kb_id": kb, + "text": "Ordinary public notes, ingested from a public chat.", + "title": "note", + "model": {"provider": "ollama", "model": "qwen3"}, + }), + session, + None, + crate::privacy::CallCapability::for_test(ProviderTier::Public, true), + None, + ), + ) + .await + } + + /// **The finding, driven through the real handler.** A PUBLIC chat names a + /// PRIVATE model in `platform__ingest_source`'s `model` argument. The chosen + /// provider's tier — not the session's classification — is what + /// `SourceIngestArgs::caller_capability` carries into the knowledge base's + /// permanent ratchet, so before the fix this call privatised the base and + /// locked the chat that owns it out of its own notes. + /// + /// Measured on this branch before the gate changed, with exactly this test: + /// + /// ```text + /// --- handler outcome: Ok([… "Curated 0 of 1 source(s) into knowledge base + /// 'gate-h-exploit' on ollama/qwen3. …"]) + /// --- tier::is_private after the call: true + /// --- a public caller can still reach it: false + /// ``` + /// + /// i.e. the tool answered with an ordinary per-source report — not an error — + /// and the base was gone. Note the ratchet fires in `prepare_ingest_base`, + /// **before** the sub-agent runs, so a curation that failed outright still + /// cost the user the base. + #[tokio::test] + async fn a_public_chat_may_not_privatise_a_base_by_naming_a_private_model() { + use biorouter_mcp::knowledge::caller::KbCaller; + use biorouter_mcp::knowledge::tier; + + let svc = KnowledgeService::new_default().expect("the lib binary's sandboxed root"); + let kb = "gate-h-private-model-from-public-chat"; + svc.create_base(kb, "Gate H", None).expect("create"); + assert!( + !tier::is_private(svc.root(), kb) && KbCaller::restricted().can_reach(svc.root(), kb), + "precondition: the base starts public and its public owner can reach it" + ); + + let session = public_session("gate-h-public"); + let err = ingest_on(kb, &session, A_PRIVATE_HOST) + .await + .expect_err("a public chat may not run an ingest on a private model") + .message + .to_string(); + assert!(err.contains("private model"), "{err}"); + assert!( + err.contains("`model` argument"), + "the refusal must name the knob that fixes it: {err}" + ); + assert!( + err.contains("ollama"), + "a refusal names what it refused: {err}" + ); + + // The ratchet is permanent, so a refusal that had already written would be + // unrecoverable. It did not write, and the owner still has its base. + assert!( + !tier::is_private(svc.root(), kb), + "a refused ingest ratcheted the base to PRIVATE anyway" + ); + assert!( + KbCaller::restricted().can_reach(svc.root(), kb), + "the public chat that owns this base can no longer read it" + ); + } + + /// The other direction, or the gate is not a barrier but an outage: the same + /// public chat naming a PUBLIC alternate model still runs, and the base stays + /// public. + /// + /// The ingest itself fails — the endpoint is unroutable — and that is the + /// point: it fails *past* the gate, with the per-source report the tool + /// answers with, having reached `prepare_ingest_base` and left the base + /// public. + #[tokio::test] + async fn the_same_chat_may_still_name_a_public_alternate_model() { + use biorouter_mcp::knowledge::caller::KbCaller; + use biorouter_mcp::knowledge::tier; + + let svc = KnowledgeService::new_default().expect("the lib binary's sandboxed root"); + let kb = "gate-h-public-model-from-public-chat"; + svc.create_base(kb, "Gate H", None).expect("create"); + + let session = public_session("gate-h-public-sideways"); + let report = ingest_on(kb, &session, A_PUBLIC_HOST) + .await + .expect("a public chat on a public alternate model is a sideways choice"); + let text = format!("{report:?}"); + assert!( + text.contains("ollama/qwen3"), + "the report must name the model that ran it: {text}" + ); + assert!(!tier::is_private(svc.root(), kb)); + assert!(KbCaller::restricted().can_reach(svc.root(), kb)); + } + /// Half a model reference is not a choice, so the chat's own model runs the /// ingest — never a partially-resolved provider. #[test] diff --git a/crates/biorouter/src/agents/knowledge_tool.rs b/crates/biorouter/src/agents/knowledge_tool.rs index d55d1cd81..e1a342141 100644 --- a/crates/biorouter/src/agents/knowledge_tool.rs +++ b/crates/biorouter/src/agents/knowledge_tool.rs @@ -414,7 +414,7 @@ async fn build_model_ref_completer( Ok((Box::new(completer), tier, affiliation)) } -/// The provider behind a [`ModelRef`], past **Gate H**. +/// The provider behind a [`ModelRef`], past **Gate H's ratcheting half**. /// /// Split out of [`build_model_ref_completer`] so a caller that needs the /// provider itself — a batch, which mints one completer per source from one @@ -427,6 +427,27 @@ async fn build_model_ref_completer( /// to consult. `what` and `env_key_to_name` are Gate H's own two strings — the /// feature named in the refusal and the knob that fixes it — and they differ per /// caller, which is why they are arguments rather than constants here. +/// +/// ⚠ **It asks [`crate::privacy::assert_alt_provider_matches_session`], not its +/// laxer sibling, and that is the whole of the fix for the Gate H finding.** The +/// tier of the provider built here does not stay in this process: it becomes +/// `SourceIngestArgs::caller_capability` / `ConversationIngestArgs::caller_capability`, +/// which crosses to `caller_is_private` and lands in +/// `knowledge::tier::raise_unlocked` — a permanent, monotone ratchet on a +/// knowledge base. So the upward choice `bind_allowed` waves through (a PRIVATE +/// provider named by a PUBLIC chat) is not harmless here: it privatises that +/// chat's own base for good, after which every KB read choke point refuses the +/// chat with `tier::KB_PRIVATE_REFUSAL`. Measured before the fix: the tool +/// returned an ordinary report (*"Curated 0 of 1 source(s) … on ollama/qwen3"*) +/// while `tier::is_private` flipped to `true` and the public caller's +/// `can_reach` to `false` — a base lost to a failed ingest. +/// +/// This is the choke point rather than the tool's own handler because both +/// knowledge paths reach an alternate provider through here: the `model` +/// argument of `platform__ingest_source` (a name the MODEL wrote) and a base's +/// stored `default_model` on a scheduled digest. Both end in the same ratchet, +/// so both take the same rule, and a third knowledge path cannot be added +/// without passing it. pub(crate) async fn build_model_ref_provider( model: &ModelRef, session: crate::privacy::SessionClassification, @@ -437,7 +458,12 @@ pub(crate) async fn build_model_ref_provider( let provider = crate::providers::create(&model.provider, model_config).await?; // AFTER `create`: the tier belongs to the instance that was resolved, not to // the name the manifest asked for. Constructing it discloses nothing. - crate::privacy::assert_alt_provider_allowed(what, provider.as_ref(), session, env_key_to_name)?; + crate::privacy::assert_alt_provider_matches_session( + what, + provider.as_ref(), + session, + env_key_to_name, + )?; Ok(provider) } @@ -887,6 +913,26 @@ mod tests { ) .await .is_ok()); + + // The fourth cell, and the one this path needs the STRICTER half of Gate + // H for: a PUBLIC session on a PRIVATE default model. `bind_allowed` + // permits it — nothing leaks upward — but the tier that comes back is + // what ratchets the target base, so permitting it hands a public + // scheduled digest the power to privatise the base it writes into. It is + // refused here and the refusal names the base's own knob. + let raise = crate::config::with_config_overrides( + ollama_at("http://localhost:11434"), + build_model_ref_completer(&model, SessionClassification::Public, None), + ) + .await + .err() + .expect("a public chat may not digest itself on the base's private default model") + .to_string(); + assert!(raise.contains("private model"), "{raise}"); + assert!( + raise.contains("knowledge base's default model"), + "the refusal must name the knob that fixes it: {raise}" + ); } /// Issue #56 Gate H, the *wiring*. The test above proves diff --git a/crates/biorouter/src/agents/mistakes.rs b/crates/biorouter/src/agents/mistakes.rs index 2f43c8d75..0990d563f 100644 --- a/crates/biorouter/src/agents/mistakes.rs +++ b/crates/biorouter/src/agents/mistakes.rs @@ -421,18 +421,43 @@ fn recovery_notice(error: &ProviderError, attempt: u32, limit: u32) -> String { ) } -/// The user-facing message when the turn ends on a provider error. The first -/// sentence is unchanged from before BR-66 — only the retry count is new, so the -/// user is not told to "retry" a call Biorouter already silently retried. +/// The user-facing message when the turn ends on a provider error. +/// +/// ⚠ **The retry invitation is only offered for an error a retry could survive.** +/// This notice is reached by three different routes — the error is not +/// recoverable, the budget is spent, or retries are switched off — and it used to +/// end with "Please retry if you think this is a transient or recoverable error" +/// on all three. On the first route that sentence contradicts the one above it: +/// an authentication failure, an unsupported operation or a rejected model name +/// will fail identically forever, and [`is_recoverable`]'s own doc says so. The +/// user is then told, by the same paragraph, that the thing that cannot work +/// might. Measured on a vendor model rejection, where the text above read "no +/// retry will fix it" and the frame below invited one anyway. +/// +/// So the advice follows the same predicate the retry decision does, and cannot +/// drift from it. +/// +/// The retried count stays on the retryable branch only. Biorouter never retries +/// a fatal error, so on the other branch "already retried **it**" would name a +/// call that never happened — the retries it counts were of earlier, different +/// errors in the same turn, and that is exactly the kind of near-true sentence +/// this function is being cleaned of. fn stop_notice(error: &ProviderError, retried: u32) -> String { - let retried_clause = match retried { - 0 => String::new(), - 1 => " Biorouter already retried it once.".to_string(), - n => format!(" Biorouter already retried it {n} times."), + let advice = if is_recoverable(error) { + let retried_clause = match retried { + 0 => String::new(), + 1 => " Biorouter already retried it once.".to_string(), + n => format!(" Biorouter already retried it {n} times."), + }; + format!( + "Please retry if you think this is a transient or recoverable \ + error.{retried_clause}" + ) + } else { + "Retrying will not help: this one returns the same way until its cause changes.".to_string() }; format!( - "Ran into this error: {}\n\nPlease retry if you think this is a transient or \ - recoverable error.{retried_clause}", + "Ran into this error: {}\n\n{advice}", end_sentence(&error.to_string()) ) } @@ -447,7 +472,12 @@ fn stop_notice(error: &ProviderError, retried: u32) -> String { /// /// Deliberately conservative: only `.`, `!`, `?` and a closing quote or bracket /// after one of them count as an ending. Anything else gets the period it needs. -fn end_sentence(text: &str) -> String { +/// +/// `pub` because the CLI needs the same rule: `session --provider` printed +/// `Error .` with an unconditional stop of its own, and produced the +/// identical `…in Settings..` on an unconfigured provider. One rule, not two +/// spellings of it. +pub fn end_sentence(text: &str) -> String { let trimmed = text.trim_end(); let ends = trimmed .chars() @@ -789,6 +819,71 @@ mod tests { assert!(!notice.contains(".."), "{notice}"); } + /// The same measured failure as the test above, read for the other defect it + /// carried. `does not support this model` is a 400, so + /// `classify_provider_details` reads `InvalidRequest` and [`is_recoverable`] + /// says false: Biorouter will not retry it, and neither should the user. The + /// frame invited one anyway — in the paragraph directly beneath text that had + /// just named the two things to run instead. + #[test] + fn a_rejection_no_retry_can_fix_does_not_invite_one() { + let config = MistakeConfig::default(); + let mut tracker = MistakeTracker::default(); + let error = ProviderError::RequestFailed( + "API Error: 400 Claude Code 2.1.235 does not support this model; version 2.1.251 or \ + newer is required." + .to_string(), + ); + assert!(!is_recoverable(&error), "the fixture must be fatal"); + + let ProviderErrorAction::Stop { notice } = tracker.observe_provider_error(&config, &error) + else { + panic!("a fatal error ends the turn"); + }; + assert!( + !notice.contains("Please retry"), + "a turn that cannot be retried must not invite one: {notice}" + ); + assert!( + notice.contains("Retrying will not help"), + "it should say so instead: {notice}" + ); + // The vendor's own text, and its instructions, are still there in full. + assert!( + notice.contains("version 2.1.251 or newer is required."), + "{notice}" + ); + } + + /// The other branch, unchanged: a blip still invites the retry, and still + /// reports the ones Biorouter already spent so the user is not told to retry + /// a call it silently retried three times. + #[test] + fn a_transient_error_still_invites_a_retry_and_names_the_ones_already_spent() { + let config = MistakeConfig::default(); + let mut tracker = MistakeTracker::default(); + let error = ProviderError::ServerError("502".to_string()); + + let mut notice = None; + // Burn the budget, then read the notice the exhausted retry produces. + for _ in 0..=config.provider_error_retries { + if let ProviderErrorAction::Stop { notice: text } = + tracker.observe_provider_error(&config, &error) + { + notice = Some(text); + } + } + let notice = notice.expect("the budget runs out and the turn stops"); + assert!( + notice.contains("Please retry if you think this is a transient"), + "{notice}" + ); + assert!( + notice.contains("Biorouter already retried it"), + "the count survives on this branch: {notice}" + ); + } + #[test] fn end_sentence_only_supplies_a_stop_that_is_missing() { assert_eq!(end_sentence("Server error: 502"), "Server error: 502."); diff --git a/crates/biorouter/src/agents/mod.rs b/crates/biorouter/src/agents/mod.rs index b35cbc7cc..92f2e811d 100644 --- a/crates/biorouter/src/agents/mod.rs +++ b/crates/biorouter/src/agents/mod.rs @@ -42,6 +42,9 @@ pub mod post_edit_diagnostics; // Stage 0 of the tool-call latency work: opt-in per-phase timing behind // `BIOROUTER_PHASE_TIMING=1`, free when off. pub mod phase_timing; +// Native checklist control for a multi-step turn: the reminder, the +// once-per-turn redirect to `todo_write`, and the bounded stop check. +pub(crate) mod planning_gate; pub mod prompt_manager; mod recurring; // BR-12: `pub(crate)` so `context_mgmt::run_eager_compaction` can reuse @@ -50,6 +53,10 @@ pub(crate) mod reply_parts; pub mod resource_refs; pub mod retry; pub(crate) mod schedule_tool; +/// A cron expression in words. Re-exported because the `biorouter schedule` +/// confirmation says when a job will run, and saying it differently from the +/// `manage_schedule` approval card would be two descriptions of one thing. +pub use schedule_tool::describe_cron; // QA finding F7: every tool call a Code Execution script makes faces the same // permission decision it would face as a direct call. pub(crate) mod script_call_gate; diff --git a/crates/biorouter/src/agents/moim.rs b/crates/biorouter/src/agents/moim.rs index c2d60f62d..c396e7c5a 100644 --- a/crates/biorouter/src/agents/moim.rs +++ b/crates/biorouter/src/agents/moim.rs @@ -59,9 +59,26 @@ fn strip_existing_moim(messages: &mut Vec) { }); } +/// Put `reminder` at the head of the block, directly after the opening tag. +/// +/// The head, not the tail, because [`cap_moim_block`] keeps the head: a +/// reminder appended after a large workspace map would be the first thing the +/// cap cut. +fn with_reminder(moim: String, reminder: Option<&str>) -> String { + match reminder.map(str::trim).filter(|text| !text.is_empty()) { + Some(reminder) => moim.replacen(MOIM_OPEN_TAG, &format!("{MOIM_OPEN_TAG}\n{reminder}"), 1), + None => moim, + } +} + /// Inject the MOIM `` block into the conversation handed to the model, /// returning the (re-normalized) conversation and whether a block was injected. /// +/// `reminder` is a first-party line the agent loop wants in front of the model +/// for this call only — the planning gate's "write the checklist first". It +/// rides inside the block, so it is never persisted and disappears the moment +/// the loop stops passing it. +/// /// BR-56: normalization goes through the agent's [`SharedNormalizer`], which /// re-fixes only the messages appended since the last call instead of the whole /// history — this runs on every provider call, so in a long multi-tool turn it was @@ -73,6 +90,7 @@ pub async fn inject_moim( working_dir: &Path, normalizer: &SharedNormalizer, cancel: Option<&CancellationToken>, + reminder: Option<&str>, ) -> (Conversation, bool) { if SKIP.with(|f| f.get()) { return (conversation, false); @@ -82,7 +100,7 @@ pub async fn inject_moim( .collect_moim(session_id, working_dir, cancel) .await { - let moim = cap_moim_block(moim, max_moim_tokens()); + let moim = cap_moim_block(with_reminder(moim, reminder), max_moim_tokens()); let mut messages = conversation.messages().clone(); // Drop any stale MOIM from a prior loop iteration first, so a long // multi-tool turn never accumulates several near-identical (and @@ -145,6 +163,7 @@ mod tests { &working_dir, &SharedNormalizer::new(), None, + None, ) .await; let msgs = result.messages(); @@ -184,6 +203,7 @@ mod tests { &working_dir, &SharedNormalizer::new(), None, + None, ) .await; @@ -257,6 +277,7 @@ mod tests { &working_dir, &SharedNormalizer::new(), None, + None, ) .await; let msgs = result.messages(); @@ -358,6 +379,52 @@ mod tests { assert_eq!(cap_moim_block(moim.clone(), 8_000), moim); } + /// The planning gate's reminder rides inside the one block, at its head, so + /// the size cap — which keeps the head — cannot be what removes it. + #[tokio::test] + async fn a_reminder_rides_at_the_head_of_the_block_and_survives_the_cap() { + let temp_dir = tempfile::tempdir().unwrap(); + let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf()); + let conv = Conversation::new_unvalidated(vec![Message::user().with_text("do it")]); + let (result, injected) = inject_moim( + "test-session-id", + conv, + &em, + &PathBuf::from("/test/dir"), + &SharedNormalizer::new(), + None, + Some("Planning required: write the checklist first."), + ) + .await; + assert!(injected); + assert_eq!(count_info_msgs(&result), 1, "one block, not two"); + let block = result.messages()[0] + .content + .iter() + .filter_map(|c| c.as_text()) + .find(|t| t.contains(MOIM_OPEN_TAG)) + .expect("the block") + .to_string(); + assert!( + block.starts_with(&format!( + "{MOIM_OPEN_TAG}\nPlanning required: write the checklist first." + )), + "{block}" + ); + + let huge = format!( + "{MOIM_OPEN_TAG}\nIt is currently now\n{}\n{MOIM_CLOSE_TAG}", + "x".repeat(40_000) + ); + let capped = cap_moim_block(with_reminder(huge, Some("KEEP ME")), 100); + assert!(capped.contains("KEEP ME"), "{capped}"); + assert!(is_moim_block(&capped)); + + // No reminder, or a blank one, leaves the block exactly as it was. + assert_eq!(with_reminder(sample_moim(), None), sample_moim()); + assert_eq!(with_reminder(sample_moim(), Some(" ")), sample_moim()); + } + /// BR-2: a cap of 0 disables MOIM truncation. #[test] fn test_cap_moim_block_disabled_with_zero() { @@ -385,6 +452,7 @@ mod tests { &working_dir, &SharedNormalizer::new(), None, + None, ) .await; diff --git a/crates/biorouter/src/agents/planning_gate.rs b/crates/biorouter/src/agents/planning_gate.rs new file mode 100644 index 000000000..d3817bdfb --- /dev/null +++ b/crates/biorouter/src/agents/planning_gate.rs @@ -0,0 +1,1750 @@ +//! The planning gate: native control of a multi-step turn's checklist. +//! +//! The Todo capability could always keep a checklist; nothing made the model +//! keep one. The only trigger was an advisory paragraph in `system.md`, +//! `TodoClient::get_moim` renders nothing while the list is empty, and the +//! hard-coded "don't stop with unchecked todos" gate was removed for +//! re-injecting a fake user message forever (the NOTE near the top of +//! `agent.rs`). Measured on 2026-09-10: three multi-step QA sessions, zero +//! checklists. This module is the harness half of the fix — three behaviours, +//! each bounded, each of which a model that disagrees can get past: +//! +//! 1. **Reminder.** A turn whose prompt [`classify_request`] reads as several +//! steps, in a session whose checklist is empty, carries a "write the +//! checklist first" line in its MOIM block until the list exists. +//! 2. **Redirect.** The first batch of non-Todo tool calls in such a turn is +//! refused with a pointer back to `todo__todo_write` — ONCE per turn. A model +//! that states a reason and repeats the call gets it run. +//! 3. **Stop check.** A turn that created or changed the checklist cannot end +//! while items are unfinished, unless its final message names each one. It +//! blocks at most [`STOP_HOOK_BLOCK_CAP`] times per turn and, unlike the +//! Stop-hook counter, the count does NOT reset when tools run — so a model +//! that keeps stopping cannot cycle until `max_turns`. +//! +//! **Scope** is one predicate, [`enforcement_applies`], read by both the turn +//! and the system prompt so the prompt can never promise what the turn does not +//! do: tool-running modes only (not Chat), not a subagent, not a coding-agent +//! provider (its tool calls run through the bridge, which never passes the +//! reply loop's gate), and only while `todo__todo_write` is on the model's +//! roster. Disable the Todo capability and none of it fires. +//! +//! Everything that runs inside the reply loop lives here, as `Agent` methods, +//! so the `reply_internal` generator only calls them — its `poll` frame sits a +//! few percent under the thread stack in debug builds, and every line kept out +//! of it is paid for two or three times over during delegation. + +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::sync::PoisonError; + +use once_cell::sync::Lazy; +use regex::Regex; +use rmcp::model::{Role, Tool}; + +use crate::agents::final_output_tool::FINAL_OUTPUT_TOOL_NAME; +use crate::agents::todo_extension::{is_todo_tool_name, TODO_WRITE_TOOL_NAME}; +use crate::config::BioRouterMode; +use crate::conversation::message::ToolRequest; +use crate::conversation::Conversation; +use crate::hooks::STOP_HOOK_BLOCK_CAP; +use crate::session::extension_data::{TodoItem, TodoState, TodoStatus}; +use crate::session::session_manager::SessionType; +use crate::session::Session; +use crate::tool_inspection::{InspectionAction, InspectionResult}; + +use super::Agent; + +/// `InspectionResult::inspector_name` on the gate's refusals, so the denial +/// path hands the model the real reason instead of claiming the user declined. +pub(crate) const PLANNING_GATE_NAME: &str = "planning_gate"; + +/// The two Todo calls that put items on an empty checklist. A batch carrying +/// one is writing the plan in the same step as the work, so the gate lets the +/// whole batch through. +const CHECKLIST_SEEDING_TOOLS: [&str; 2] = ["todo__todo_write", "todo__todo_add"]; + +/// Sessions whose turn state one [`Agent`] keeps at once. An entry outlives its +/// turn only until the session's next turn replaces it; the bound is for a +/// daemon hosting many chats on one agent (`biorouter web`). +const MAX_TRACKED_SESSIONS: usize = 256; + +// --------------------------------------------------------------------------- +// The classifier +// --------------------------------------------------------------------------- + +/// Why a request reads as several steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MultiStep { + /// A numbered (`1.`, `2)`, `(3)`, `Step 4:`) or bulleted list of actions. + List { items: usize }, + /// Three or more instructions in a row, each opening with an action verb. + Instructions { count: usize }, + /// Two or more instructions joined by an explicit sequencing word — "then", + /// "after that", "finally", "steps". + Sequenced { count: usize }, +} + +impl MultiStep { + fn describe(self) -> String { + match self { + Self::List { items } => format!("a list of {items} steps"), + Self::Instructions { count } => format!("{count} separate instructions"), + Self::Sequenced { count } => format!("{count} instructions in sequence"), + } + } +} + +/// Code is pasted data, never the request's own steps: a stack trace with +/// numbered frames is not a plan. +static FENCED_CODE: Lazy = + Lazy::new(|| Regex::new(r"(?s)```.*?(?:```|\z)").expect("valid regex")); + +/// Inline code, replaced by a neutral word so `write `x.txt`` keeps its verb. +static INLINE_CODE: Lazy = Lazy::new(|| Regex::new(r"`[^`\n]+`").expect("valid regex")); + +/// A numbered-list marker: `1.` `2)` `(3)` or `Step 4:`, after the start of the +/// text, whitespace or a bracket, and before whitespace. The whitespace on both +/// sides is what keeps `3.12` and `v1.2` out. +static NUMBERED_MARKER: Lazy = Lazy::new(|| { + Regex::new(r"(?i)(?:^|[\s(\[])(?:step\s+)?\(?(\d{1,2})[.):]\s+").expect("valid regex") +}); + +/// A bulleted line. +static BULLET: Lazy = + Lazy::new(|| Regex::new(r"(?m)^[ \t]*[-*+•][ \t]+(\S[^\n]*)$").expect("valid regex")); + +/// Where one instruction ends and the next may begin. `.` counts only before +/// whitespace, so `hello.txt` and `3.12` stay whole. +static CLAUSE_BREAK: Lazy = Lazy::new(|| { + Regex::new( + r"(?i)[.!?;:](?:\s+|$)|[,\n]|\b(?:and|then|after\s+that|afterwards?|finally|lastly|also|plus)\b", + ) + .expect("valid regex") +}); + +/// An explicit statement that the work comes in order. +static SEQUENCE_CUE: Lazy = Lazy::new(|| { + Regex::new( + r"(?i)\b(?:then|after\s+that|afterwards?|finally|lastly|followed\s+by|once\s+(?:that|this|it)(?:'s|\s+is)?\s+(?:done|finished|complete)|steps?)\b", + ) + .expect("valid regex") +}); + +/// A request for an explanation, not for work. "How do I create a venv and +/// then install the deps?" names two actions in sequence and wants neither +/// performed; the system prompt already says to answer those first. +static INFORMATIONAL: Lazy = Lazy::new(|| { + Regex::new( + r"(?i)\b(?:how\s+(?:to|do|does|did|can|could|would|should|might|is|are)|what(?:'s|\s+is)\s+the\s+(?:best\s+)?way\s+to|explain\s+how|walk\s+me\s+through)\b", + ) + .expect("valid regex") +}); + +/// Verbs that open an instruction to DO something — to files, data, code, a +/// service. Base forms only: an imperative uses them, a description ("it +/// validates each row") does not. Answer-shaped verbs (explain, describe, +/// summarize, tell, compare, show, translate) are left out on purpose: three +/// questions in a row are not three steps of work. +const ACTION_VERB_LIST: &str = "\ + add adjust aggregate align analyze analyse annotate append apply archive assemble \ + attach audit automate backup benchmark bisect build bump calculate cancel capture cd \ + change check checkout chmod clean clear clone close cluster collect combine commit \ + compile compress compute concatenate configure connect convert copy correct count \ + crawl create crop curl debug decompress decrypt deduplicate delete deploy detect \ + diff disable download draw drop dump duplicate edit email embed enable encode \ + encrypt erase estimate evaluate execute expand export extend extract fetch filter \ + find fit fix flatten fork format gather generate get grep group gunzip gzip hash \ + identify implement import increase index ingest initialize initialise insert inspect \ + install integrate join kill label launch lint link list load locate lookup make map \ + mark measure merge migrate mkdir modify monitor mount move mv normalize normalise \ + notify open optimize optimise organize organise package parse paste patch ping plot \ + populate post prepare preprocess print process profile prune publish pull push put \ + query rank read rebase rebuild record recompute redact redo reduce refactor refresh \ + regenerate register reindex reinstall release reload remove rename render reorder \ + reorganize repair replace replicate reproduce request rerun resample reset reshape \ + resize resolve restart restore restructure retrain retrieve retry revert review \ + rewrite rm rotate run sample save scaffold scale scan schedule scrape search seed \ + select send separate serialize set setup share shuffle sign simulate slice smooth \ + sort split stage standardize start stash stop store strip submit subset subtract sum \ + swap switch symlink sync tabulate tag tar test tidy tokenize touch trace track train \ + transfer transform transpose trigger trim truncate tune uncompress undo uninstall \ + unpack unzip update upgrade upload validate verify visualize visualise watch wget \ + wire wrap write zip"; + +static ACTION_VERBS: Lazy> = + Lazy::new(|| ACTION_VERB_LIST.split_whitespace().collect()); + +/// Words that can precede an instruction's verb without changing it. Longest +/// first, so "i need you to" is stripped before "i need to" is tried. +const FILLERS: &[&[&str]] = &[ + &["i", "would", "like", "you", "to"], + &["i'd", "like", "you", "to"], + &["i", "want", "you", "to"], + &["i", "need", "you", "to"], + &["go", "ahead", "and"], + &["make", "sure", "to"], + &["make", "sure", "you"], + &["i", "need", "to"], + &["i", "want", "to"], + &["we", "need", "to"], + &["you", "need", "to"], + &["after", "that"], + &["can", "you"], + &["could", "you"], + &["would", "you"], + &["will", "you"], + &["you", "should"], + &["let", "us"], + &["try", "to"], + &["please"], + &["kindly"], + &["then"], + &["and"], + &["also"], + &["now"], + &["just"], + &["next"], + &["finally"], + &["lastly"], + &["first"], + &["firstly"], + &["second"], + &["secondly"], + &["third"], + &["thirdly"], + &["afterwards"], + &["afterward"], + &["let's"], + &["lets"], + &["step"], + &["so"], +]; + +/// Read the user's prompt and say whether it asks for several steps of work. +/// +/// Deliberately a small, conservative heuristic — English cues only, and a +/// false negative is cheap (the system prompt still asks the model to plan) +/// where a false positive costs one redirected tool call. It fires on: +/// +/// * a numbered or bulleted list whose items open with action verbs — or of +/// three or more items introduced by an instruction ("Build a page with: +/// 1. a header 2. a footer 3. a nav bar"); +/// * three or more instructions, each opening with an action verb; +/// * two or more instructions joined by a sequencing word ("then", "after +/// that", "finally", "steps"). +/// +/// A request for an explanation ("how do I …") never fires, and fenced or +/// inline code is ignored. +pub fn classify_request(text: &str) -> Option { + let prose = prose_only(text); + if INFORMATIONAL.is_match(&prose) { + return None; + } + if let Some(items) = listed_steps(&prose) { + return Some(MultiStep::List { items }); + } + let count = action_clause_count(&prose); + if count >= 3 { + Some(MultiStep::Instructions { count }) + } else if count >= 2 && SEQUENCE_CUE.is_match(&prose) { + Some(MultiStep::Sequenced { count }) + } else { + None + } +} + +fn prose_only(text: &str) -> String { + let without_fences = FENCED_CODE.replace_all(text, "\n"); + INLINE_CODE.replace_all(&without_fences, "x").into_owned() +} + +/// The longest list in `prose` that reads as steps, as its item count. +fn listed_steps(prose: &str) -> Option { + [numbered_list(prose), bulleted_list(prose)] + .into_iter() + .flatten() + .filter(|(intro, items)| list_is_steps(intro, items)) + .map(|(_, items)| items.len()) + .max() +} + +fn list_is_steps(intro: &str, items: &[&str]) -> bool { + if items.len() < 2 { + return false; + } + let actions = items.iter().filter(|item| starts_with_action(item)).count(); + actions >= 2 || (items.len() >= 3 && intro_is_instruction(intro)) +} + +/// Does the line that introduces a list tell the model to do something? +fn intro_is_instruction(intro: &str) -> bool { + let line = intro.trim_end().rsplit('\n').next().unwrap_or_default(); + action_clause_count(line) >= 1 +} + +/// The longest `1, 2, 3, …` run of numbered markers, as the text before it and +/// the items it numbers. +fn numbered_list(prose: &str) -> Option<(&str, Vec<&str>)> { + // (marker start, item start) for each marker in the run being built. + let mut best: Vec<(usize, usize)> = Vec::new(); + let mut run: Vec<(usize, usize)> = Vec::new(); + for captures in NUMBERED_MARKER.captures_iter(prose) { + let (Some(whole), Some(number)) = (captures.get(0), captures.get(1)) else { + continue; + }; + let Ok(number) = number.as_str().parse::() else { + continue; + }; + if number == run.len() + 1 { + run.push((whole.start(), whole.end())); + } else if number == 1 { + if run.len() > best.len() { + best = std::mem::take(&mut run); + } else { + run.clear(); + } + run.push((whole.start(), whole.end())); + } + } + if run.len() > best.len() { + best = run; + } + let &(list_start, _) = best.first().filter(|_| best.len() >= 2)?; + // Every offset here is a regex match boundary or a `find` result, so a + // char boundary; `get` only states that without an index that could panic. + let items = best + .iter() + .enumerate() + .map(|(index, &(_, item_start))| { + let end = match best.get(index + 1) { + Some(&(next_marker, _)) => next_marker, + // The last item ends with its line: an inline list has one line, + // and prose after a line list is not part of its last step. + None => prose + .get(item_start..) + .and_then(|rest| rest.find('\n')) + .map_or(prose.len(), |offset| item_start + offset), + }; + prose.get(item_start..end).unwrap_or_default().trim() + }) + .collect(); + Some((prose.get(..list_start).unwrap_or_default(), items)) +} + +fn bulleted_list(prose: &str) -> Option<(&str, Vec<&str>)> { + let mut first_start = None; + let mut items = Vec::new(); + for captures in BULLET.captures_iter(prose) { + let (Some(whole), Some(item)) = (captures.get(0), captures.get(1)) else { + continue; + }; + first_start.get_or_insert(whole.start()); + items.push(item.as_str().trim()); + } + let start = first_start?; + (items.len() >= 2).then(|| (prose.get(..start).unwrap_or_default(), items)) +} + +fn action_clause_count(prose: &str) -> usize { + CLAUSE_BREAK + .split(prose) + .filter(|clause| starts_with_action(clause)) + .count() +} + +fn starts_with_action(clause: &str) -> bool { + let words = words(clause); + strip_fillers(&words) + .first() + .is_some_and(|word| ACTION_VERBS.contains(word.as_str())) +} + +/// Lower-cased words, keeping an inner apostrophe ("let's", "i'd"). +fn words(text: &str) -> Vec { + text.split(|c: char| !(c.is_alphanumeric() || c == '\'' || c == '’')) + .map(|word| { + word.trim_matches(|c| c == '\'' || c == '’') + .replace('’', "'") + .to_lowercase() + }) + .filter(|word| !word.is_empty()) + .collect() +} + +fn strip_fillers(mut words: &[String]) -> &[String] { + loop { + // A list number or a stray count ("2 files") is not the verb. + if words + .first() + .is_some_and(|word| word.chars().all(|c| c.is_ascii_digit())) + { + words = &words[1..]; + continue; + } + let Some(filler) = FILLERS.iter().find(|filler| { + words.len() >= filler.len() && filler.iter().zip(words).all(|(a, b)| *a == b) + }) else { + return words; + }; + words = &words[filler.len()..]; + } +} + +// --------------------------------------------------------------------------- +// Scope +// --------------------------------------------------------------------------- + +/// Does the gate run for this conversation? The ONE answer, read by the turn +/// (`Agent::begin_planning_turn`) and by the system prompt +/// (`prepare_tools_and_prompt_for_provider`), so the prompt describes exactly +/// what the turn enforces. +/// +/// * Chat mode runs no tools, so there is no tool call to redirect. +/// * A subagent's turn ends with an observe-only `SubagentStop`, never a +/// blockable Stop, so the stop check could not run there anyway; its task +/// was written by a parent model that keeps its own checklist. +/// * A coding-agent provider's tool calls run through the tool bridge, which +/// inspects them on its own path and never reaches the reply loop's gate. +/// * No `todo__todo_write` on the roster means nothing the gate could point at. +pub(crate) fn enforcement_applies<'a>( + mode: BioRouterMode, + is_subagent: bool, + bridge_surface: bool, + tool_names: impl IntoIterator, +) -> bool { + mode != BioRouterMode::Chat + && !is_subagent + && !bridge_surface + && tool_names + .into_iter() + .any(|name| name == TODO_WRITE_TOOL_NAME) +} + +// --------------------------------------------------------------------------- +// Turn state +// --------------------------------------------------------------------------- + +/// One turn's gate state. +#[derive(Debug, Clone)] +struct TurnPlan { + /// Why this turn's prompt reads as several steps, when it does. + signal: Option, + /// The checklist had no items when the turn opened. + list_was_empty: bool, + /// [`fingerprint`] of the checklist when the turn opened. + start_fingerprint: u64, + /// The once-per-turn redirect has fired. + redirect_spent: bool, + /// The checklist has been seen with items since the turn opened. + list_seen: bool, + /// Checklist stop blocks this turn. Never reset by a tool call. + stop_blocks: u32, + /// Insertion order, for the bound. + serial: u64, +} + +impl TurnPlan { + fn armed(&self) -> Option { + (self.list_was_empty && !self.list_seen && !self.redirect_spent) + .then_some(self.signal) + .flatten() + } +} + +#[derive(Debug, Default)] +struct Turns { + plans: HashMap, + serial: u64, +} + +/// Per-session turn state for one [`Agent`]. +/// +/// ⚠ Owned by the agent, never process-global. Session ids are `YYYYMMDD_N` +/// per DATABASE, so two tests with their own stores routinely share one, and a +/// global keyed by it would let one test's turn arm another test's gate. +#[derive(Debug, Default)] +pub(crate) struct PlanningRegistry { + turns: std::sync::Mutex, +} + +impl PlanningRegistry { + fn lock(&self) -> std::sync::MutexGuard<'_, Turns> { + self.turns.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Replace the session's state with `plan`, or clear it when the gate does + /// not run this turn. + fn begin(&self, session_id: &str, plan: Option) { + let mut turns = self.lock(); + let Some(mut plan) = plan else { + turns.plans.remove(session_id); + return; + }; + turns.serial += 1; + plan.serial = turns.serial; + turns.plans.insert(session_id.to_string(), plan); + while turns.plans.len() > MAX_TRACKED_SESSIONS { + let Some(oldest) = turns + .plans + .iter() + .min_by_key(|(_, plan)| plan.serial) + .map(|(id, _)| id.clone()) + else { + break; + }; + turns.plans.remove(&oldest); + } + } + + /// The turn's signal while the reminder and the redirect are live: a + /// multi-step prompt, a list that was empty and has not been seen since, + /// and the redirect not yet spent. + fn armed(&self, session_id: &str) -> Option { + self.lock().plans.get(session_id).and_then(TurnPlan::armed) + } + + fn note_list_exists(&self, session_id: &str) { + if let Some(plan) = self.lock().plans.get_mut(session_id) { + plan.list_seen = true; + } + } + + /// Spend the turn's one redirect. `None` when it is not armed — including + /// when it already fired, which is the once-per-turn bound. + fn spend_redirect(&self, session_id: &str) -> Option { + let mut turns = self.lock(); + let plan = turns.plans.get_mut(session_id)?; + let signal = plan.armed()?; + plan.redirect_spent = true; + Some(signal) + } + + /// `(start fingerprint, stop blocks so far)`, when the gate runs. + fn stop_state(&self, session_id: &str) -> Option<(u64, u32)> { + self.lock() + .plans + .get(session_id) + .map(|plan| (plan.start_fingerprint, plan.stop_blocks)) + } + + fn record_stop_block(&self, session_id: &str) { + if let Some(plan) = self.lock().plans.get_mut(session_id) { + plan.stop_blocks += 1; + } + } +} + +/// A stable fingerprint of a checklist — plan, ids, statuses and texts — so the +/// stop check can tell "this turn worked the list" from "the list is left over +/// from an earlier turn". An absent list and an empty one read the same. +fn fingerprint(state: Option<&TodoState>) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + state + .map(TodoState::render) + .unwrap_or_default() + .hash(&mut hasher); + hasher.finish() +} + +// --------------------------------------------------------------------------- +// Texts +// --------------------------------------------------------------------------- + +/// The MOIM line a multi-step turn carries while its checklist is empty. +fn reminder_text(signal: MultiStep) -> String { + format!( + "Planning required: this request has {}, and your checklist is empty. Before you do \ + anything else, call `{TODO_WRITE_TOOL_NAME}` with one `- [ ]` item per step. Until \ + the checklist exists, the first other tool call you make this turn will be refused.", + signal.describe() + ) +} + +/// What a redirected call returns to the model. +fn redirect_reason(signal: MultiStep) -> String { + format!( + "Not run: this request has {}, and the checklist is still empty, so Biorouter needs \ + the plan first. Call `{TODO_WRITE_TOOL_NAME}` with one `- [ ]` item per step, then \ + make this call again. This refusal happens once per turn: if you have a reason not \ + to keep a checklist for this request, say so in your reply and repeat the call — it \ + will run.", + signal.describe() + ) +} + +fn status_label(status: TodoStatus) -> &'static str { + match status { + TodoStatus::Pending => "not started", + TodoStatus::InProgress => "in progress", + TodoStatus::Blocked => "blocked", + TodoStatus::Completed => "completed", + } +} + +fn id_list<'a>(items: impl IntoIterator) -> String { + const SHOWN: usize = 6; + let ids: Vec = items + .into_iter() + .map(|item| format!("#{}", item.id)) + .collect(); + if ids.len() > SHOWN { + format!("{}, …", ids[..SHOWN].join(", ")) + } else { + ids.join(", ") + } +} + +/// The notice for a turn that ends with the checklist open because the stop +/// check has spent its budget. +fn give_up_notice(open: usize) -> String { + format!( + "📋 The checklist still has {open} unfinished item(s) after {STOP_HOOK_BLOCK_CAP} \ + reminders; finishing anyway." + ) +} + +// --------------------------------------------------------------------------- +// The redirect +// --------------------------------------------------------------------------- + +/// Which calls in one batch the redirect refuses: every call except the Todo +/// tools and the workflow's structured-output tool — and none at all when the +/// batch itself seeds the checklist, because the plan then lands in the same +/// step as the work. A malformed call is left alone; it fails on its own. +fn redirect_targets(requests: &[ToolRequest]) -> Vec { + let calls: Vec<(&str, &str)> = requests + .iter() + .filter_map(|request| { + let call = request.tool_call.as_ref().ok()?; + Some((request.id.as_str(), call.name.as_ref())) + }) + .collect(); + if calls + .iter() + .any(|(_, name)| CHECKLIST_SEEDING_TOOLS.contains(name)) + { + return Vec::new(); + } + calls + .into_iter() + .filter(|(_, name)| !is_todo_tool_name(name) && *name != FINAL_OUTPUT_TOOL_NAME) + .map(|(id, _)| id.to_string()) + .collect() +} + +// --------------------------------------------------------------------------- +// The stop check +// --------------------------------------------------------------------------- + +/// What the stop check found wrong with ending the turn now. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ChecklistObjection { + /// For the model: every unfinished item, and the two ways out. + feedback: String, + /// For the user. + notice: String, + /// How many items are unfinished. + open: usize, +} + +/// The turn may not end while `state` has unfinished items, unless +/// `final_text` names every one of them — by `#N` id or by its text. +/// +/// The feedback asks for a reason too, but only the naming is checked: a +/// deterministic check cannot tell a reason from a status recap, and a named +/// item is one the user can see was left open, which is the point. +fn checklist_objection(state: &TodoState, final_text: &str) -> Option { + let open: Vec<&TodoItem> = state + .items + .iter() + .filter(|item| item.status != TodoStatus::Completed) + .collect(); + if open.is_empty() { + return None; + } + let final_words = words(final_text); + if open + .iter() + .all(|item| names_item(final_text, &final_words, item)) + { + return None; + } + let listed = open + .iter() + .map(|item| { + format!( + "- #{} ({}) {}", + item.id, + status_label(item.status), + item.text + ) + }) + .collect::>() + .join("\n"); + Some(ChecklistObjection { + feedback: format!( + "Before you finish: your checklist still has {} unfinished item(s):\n{listed}\n\ + Finish them, marking each one completed with `todo__todo_update` as you go. If an \ + item cannot or should not be done now, end your turn with a message that names \ + each unfinished item by its #N id and says why it is not done.", + open.len() + ), + notice: format!( + "📋 The checklist still has {} unfinished item(s) ({}); asking the agent to finish \ + them or say why not.", + open.len(), + id_list(open.iter().copied()) + ), + open: open.len(), + }) +} + +fn names_item(final_text: &str, final_words: &[String], item: &TodoItem) -> bool { + mentions_id(final_text, &item.id) || contains_words(final_words, &words(&item.text)) +} + +/// `#3` names item 3; `#30` does not. +fn mentions_id(text: &str, id: &str) -> bool { + let needle = format!("#{id}"); + text.match_indices(&needle).any(|(at, _)| { + !text + .get(at + needle.len()..) + .and_then(|rest| rest.chars().next()) + .is_some_and(|c| c.is_ascii_digit()) + }) +} + +fn contains_words(haystack: &[String], needle: &[String]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +/// The text of the turn's final answer: every assistant message after the +/// last user-role message. A tool result and a steer are user-role here, so +/// this is exactly what the model said since it last heard anything. +fn final_reply_text(conversation: &Conversation) -> String { + let messages = conversation.messages(); + let start = messages + .iter() + .rposition(|message| message.role == Role::User) + .map_or(0, |index| index + 1); + messages[start..] + .iter() + .filter(|message| message.role == Role::Assistant) + .map(|message| message.as_concat_text()) + .collect::>() + .join("\n") +} + +/// The checklist half of a turn's stop decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ChecklistStop { + /// Nothing to say: the gate does not run, the turn did not work the list, + /// or every item is completed or named. + Clear, + /// Keep working: `feedback` to the model, `notice` to the user. + Block { feedback: String, notice: String }, + /// Still open, but the per-turn budget is spent: finish, and say so. + GiveUp { notice: String }, +} + +// --------------------------------------------------------------------------- +// The agent's side +// --------------------------------------------------------------------------- + +impl Agent { + /// Open this turn's gate state, once per reply, before the loop starts. + /// + /// `session` is the row `reply` read at the top of the turn, so its + /// `extension_data` is the checklist as the turn found it. + pub(super) fn begin_planning_turn( + &self, + session: &Session, + signal: Option, + tools: &[Tool], + toolshim_tools: &[Tool], + bridge_surface: bool, + ) { + let enforced = enforcement_applies( + self.config.biorouter_mode, + session.session_type == SessionType::SubAgent, + bridge_surface, + // A toolshim turn hands the provider no tools and keeps them here. + tools + .iter() + .chain(toolshim_tools) + .map(|tool| tool.name.as_ref()), + ); + if !enforced { + tracing::debug!(session_id = %session.id, "planning gate: not in scope for this turn"); + self.planning.begin(&session.id, None); + return; + } + // An unreadable blob (a newer build wrote it) counts as a list that + // exists: the gate never nags about a checklist it cannot see. + let (list_was_empty, start_fingerprint) = match TodoState::try_load(&session.extension_data) + { + Ok(state) => ( + state.as_ref().is_none_or(|state| state.items.is_empty()), + fingerprint(state.as_ref()), + ), + Err(_) => (false, fingerprint(None)), + }; + // The one line that says, from outside, whether this turn is gated: + // a private provider's request log is metadata-only, so neither the + // reminder nor the prompt clause can be read back from it. Carries the + // signal's kind and counts, never the prompt. + match signal.filter(|_| list_was_empty) { + Some(signal) => tracing::info!( + session_id = %session.id, + ?signal, + "planning gate: multi-step turn with an empty checklist; reminder and redirect armed" + ), + None => tracing::debug!( + session_id = %session.id, + ?signal, + list_was_empty, + "planning gate: in scope, not armed (stop check only)" + ), + } + self.planning.begin( + &session.id, + Some(TurnPlan { + signal, + list_was_empty, + start_fingerprint, + redirect_spent: false, + list_seen: false, + stop_blocks: 0, + serial: 0, + }), + ); + } + + /// The reminder for this provider call, while the turn is armed and the + /// checklist is still empty. + pub(super) async fn planning_reminder(&self, session_id: &str) -> Option { + let signal = self.planning.armed(session_id)?; + if self.checklist_exists(session_id).await { + self.planning.note_list_exists(session_id); + tracing::debug!( + session_id, + "planning gate: checklist exists; reminder and redirect disarmed" + ); + return None; + } + tracing::debug!( + session_id, + "planning gate: reminder added to this call's context" + ); + Some(reminder_text(signal)) + } + + /// The once-per-turn redirect, as inspection results the permission merge + /// turns into ordinary refusals. Empty unless the turn is armed, the batch + /// has a call to refuse, and the checklist is still empty. + pub(super) async fn planning_gate_denials( + &self, + session_id: &str, + requests: &[ToolRequest], + ) -> Vec { + if self.planning.armed(session_id).is_none() { + return Vec::new(); + } + let targets = redirect_targets(requests); + if targets.is_empty() { + return Vec::new(); + } + if self.checklist_exists(session_id).await { + self.planning.note_list_exists(session_id); + return Vec::new(); + } + let Some(signal) = self.planning.spend_redirect(session_id) else { + return Vec::new(); + }; + tracing::info!( + session_id, + refused = targets.len(), + "planning gate: redirected the turn's first tool batch to todo_write" + ); + let reason = redirect_reason(signal); + targets + .into_iter() + .map(|tool_request_id| InspectionResult { + tool_request_id, + action: InspectionAction::Deny, + reason: reason.clone(), + confidence: 1.0, + inspector_name: PLANNING_GATE_NAME.to_string(), + finding_id: None, + }) + .collect() + } + + /// Whether the turn may end with the checklist as it stands. + pub(super) async fn checklist_stop( + &self, + session_id: &str, + conversation: &Conversation, + ) -> ChecklistStop { + let Some((start_fingerprint, blocks)) = self.planning.stop_state(session_id) else { + return ChecklistStop::Clear; + }; + // Disabled mid-turn: no tool left to finish the list with. + if !self + .extension_manager + .is_extension_enabled(crate::agents::todo_extension::EXTENSION_NAME) + .await + { + return ChecklistStop::Clear; + } + // Fail open on a read error or a blob this build cannot parse: a check + // that cannot see the list does not keep a turn alive on its behalf. + let Ok(session) = self + .config + .session_manager + .get_session(session_id, false) + .await + else { + return ChecklistStop::Clear; + }; + let Ok(Some(state)) = TodoState::try_load(&session.extension_data) else { + return ChecklistStop::Clear; + }; + if fingerprint(Some(&state)) == start_fingerprint { + // Not this turn's list: left over from an earlier turn and untouched. + return ChecklistStop::Clear; + } + let Some(objection) = checklist_objection(&state, &final_reply_text(conversation)) else { + return ChecklistStop::Clear; + }; + if blocks >= STOP_HOOK_BLOCK_CAP { + tracing::info!( + session_id, + open = objection.open, + "planning gate: checklist still open at the stop-check cap; letting the turn end" + ); + return ChecklistStop::GiveUp { + notice: give_up_notice(objection.open), + }; + } + self.planning.record_stop_block(session_id); + tracing::info!( + session_id, + open = objection.open, + block = blocks + 1, + "planning gate: sent the turn back to finish or name its open checklist items" + ); + ChecklistStop::Block { + feedback: objection.feedback, + notice: objection.notice, + } + } + + /// Does the session's checklist have items right now? `true` on any error + /// or an unreadable blob, so neither the reminder nor the redirect ever + /// fires on a list it could not read. + async fn checklist_exists(&self, session_id: &str) -> bool { + match self + .config + .session_manager + .get_session(session_id, false) + .await + { + Ok(session) => match TodoState::try_load(&session.extension_data) { + Ok(state) => state.is_some_and(|state| !state.items.is_empty()), + Err(_) => true, + }, + Err(_) => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conversation::message::Message; + use rmcp::model::CallToolRequestParams; + + // -- classifier ------------------------------------------------------- + + #[test] + fn the_classifier_recognises_requests_that_have_several_steps() { + let cases: &[(&str, MultiStep)] = &[ + ( + "1. create a temp dir 2. write hello.txt into it 3. count its bytes 4. delete it", + MultiStep::List { items: 4 }, + ), + ( + "1. Create a temp dir\n2. Write hello.txt into it\n3. Count its bytes\n4. Delete it", + MultiStep::List { items: 4 }, + ), + ( + "Please do the following:\n- clone the repo\n- build it\n- run the tests", + MultiStep::List { items: 3 }, + ), + ( + "Step 1: fetch the data. Step 2: plot it.", + MultiStep::List { items: 2 }, + ), + ( + "Build a landing page with: 1. a header 2. a pricing table 3. a footer", + MultiStep::List { items: 3 }, + ), + ( + "Create a temp dir, write hello.txt into it, count its bytes and delete it.", + MultiStep::Instructions { count: 4 }, + ), + ( + "Download the CSV, clean the missing values, and plot the distribution.", + MultiStep::Instructions { count: 3 }, + ), + ( + "Create the directory, then write hello.txt into it.", + MultiStep::Sequenced { count: 2 }, + ), + ( + "First download the dataset. After that, normalize the columns.", + MultiStep::Sequenced { count: 2 }, + ), + ( + "Can you run the tests and then fix any failures?", + MultiStep::Sequenced { count: 2 }, + ), + ( + "Write a script that downloads the data, then plot the results", + MultiStep::Sequenced { count: 2 }, + ), + ]; + for (prompt, expected) in cases { + assert_eq!( + classify_request(prompt), + Some(*expected), + "should read as several steps: {prompt:?}" + ); + } + } + + #[test] + fn the_classifier_leaves_single_asks_questions_and_pasted_lists_alone() { + let cases = [ + "", + " ", + "What is the capital of France?", + "Fix the typo in README.md", + "Read README.md and tell me what this project does", + "Explain the steps of glycolysis", + "If the build fails then fix it", + "Python 3.12 is out. Should I upgrade?", + "Thanks, that worked!", + "Which is better? 1. Postgres 2. SQLite", + "Summarize this paper: 1. Introduction 2. Methods 3. Results", + // Two instructions and no word that sequences them. + "Create a new branch and commit these changes", + // One task with requirements, described in the third person. + "Please write a function that parses the file, validates each row, and saves it", + // A how-to question names steps it does not want performed. + "Can you tell me how to create a directory, write a file and then delete it in bash?", + "How do I: 1. create a venv 2. install the deps 3. run the tests?", + // Steps inside a code fence are pasted data. + "Why does this fail?\n```\n1. open the file\n2. write the header\n3. close it\n```", + ]; + for prompt in cases { + assert_eq!( + classify_request(prompt), + None, + "should not read as several steps: {prompt:?}" + ); + } + } + + // -- scope ------------------------------------------------------------ + + #[test] + fn the_gate_runs_only_where_it_can_do_what_it_says() { + let roster = ["developer__shell", TODO_WRITE_TOOL_NAME]; + assert!(enforcement_applies( + BioRouterMode::Auto, + false, + false, + roster + )); + assert!(enforcement_applies( + BioRouterMode::Approve, + false, + false, + roster + )); + // No tool runs in Chat mode, so there is nothing to redirect. + assert!(!enforcement_applies( + BioRouterMode::Chat, + false, + false, + roster + )); + assert!(!enforcement_applies( + BioRouterMode::Auto, + true, + false, + roster + )); + assert!(!enforcement_applies( + BioRouterMode::Auto, + false, + true, + roster + )); + // The capability is off, or its seeding tool is not granted. + assert!(!enforcement_applies( + BioRouterMode::Auto, + false, + false, + ["developer__shell", "todo__todo_update"] + )); + } + + // -- the redirect ----------------------------------------------------- + + fn request(id: &str, name: &str) -> ToolRequest { + ToolRequest { + id: id.to_string(), + tool_call: Ok(CallToolRequestParams { + task: None, + meta: None, + name: name.to_string().into(), + arguments: Some(serde_json::Map::new()), + }), + metadata: None, + tool_meta: None, + } + } + + #[test] + fn the_redirect_refuses_every_non_todo_call_unless_the_batch_seeds_the_list() { + assert_eq!( + redirect_targets(&[ + request("a", "developer__shell"), + request("b", "fixture__step") + ]), + vec!["a".to_string(), "b".to_string()] + ); + // The plan lands in the same step as the work: let the batch run. + for seeding in CHECKLIST_SEEDING_TOOLS { + assert!( + redirect_targets(&[request("a", seeding), request("b", "developer__shell")]) + .is_empty(), + "{seeding} seeds the checklist" + ); + } + // A plan with no checklist does not satisfy the gate, and is not refused. + assert_eq!( + redirect_targets(&[ + request("a", "todo__plan_write"), + request("b", "developer__shell") + ]), + vec!["b".to_string()] + ); + assert!(redirect_targets(&[request("a", FINAL_OUTPUT_TOOL_NAME)]).is_empty()); + assert!(redirect_targets(&[request("a", "todo__todo_update")]).is_empty()); + } + + // -- turn state ------------------------------------------------------- + + fn plan(signal: Option, list_was_empty: bool) -> TurnPlan { + TurnPlan { + signal, + list_was_empty, + start_fingerprint: fingerprint(None), + redirect_spent: false, + list_seen: false, + stop_blocks: 0, + serial: 0, + } + } + + #[test] + fn the_redirect_is_spent_once_and_the_list_disarms_the_gate() { + let registry = PlanningRegistry::default(); + let steps = Some(MultiStep::List { items: 4 }); + + registry.begin("s", Some(plan(steps, true))); + assert_eq!(registry.armed("s"), steps); + assert_eq!(registry.spend_redirect("s"), steps); + assert_eq!(registry.spend_redirect("s"), None, "once per turn"); + assert_eq!(registry.armed("s"), None, "no reminder after the refusal"); + + // A new turn re-arms it. + registry.begin("s", Some(plan(steps, true))); + assert_eq!(registry.armed("s"), steps); + registry.note_list_exists("s"); + assert_eq!(registry.armed("s"), None); + assert_eq!(registry.spend_redirect("s"), None); + + // A one-line turn, or a list that already existed, never arms. + registry.begin("s", Some(plan(None, true))); + assert_eq!(registry.armed("s"), None); + registry.begin("s", Some(plan(steps, false))); + assert_eq!(registry.armed("s"), None); + // …but the stop check still has its baseline. + assert!(registry.stop_state("s").is_some()); + + // A turn the gate does not run for leaves nothing behind. + registry.begin("s", None); + assert_eq!(registry.stop_state("s"), None); + } + + #[test] + fn the_registry_is_bounded_and_evicts_the_oldest_session() { + let registry = PlanningRegistry::default(); + for n in 0..(MAX_TRACKED_SESSIONS + 10) { + registry.begin(&format!("s{n}"), Some(plan(None, true))); + } + let turns = registry.lock(); + assert_eq!(turns.plans.len(), MAX_TRACKED_SESSIONS); + assert!(!turns.plans.contains_key("s0")); + assert!(turns + .plans + .contains_key(&format!("s{}", MAX_TRACKED_SESSIONS + 9))); + } + + // -- the stop check --------------------------------------------------- + + fn checklist(markdown: &str) -> TodoState { + let mut state = TodoState::default(); + state.set_from_markdown(markdown); + state + } + + #[test] + fn unfinished_items_block_the_stop_until_they_are_named() { + let state = checklist( + "- [x] create a temp dir\n- [x] write hello.txt into it\n\ + - [~] count its bytes\n- [ ] delete it", + ); + + let objection = checklist_objection(&state, "All done!").expect("two items are open"); + assert_eq!(objection.open, 2); + assert!(objection + .feedback + .contains("#3 (in progress) count its bytes")); + assert!(objection.feedback.contains("#4 (not started) delete it")); + assert!(objection.feedback.contains("todo__todo_update")); + assert!(objection.notice.contains("#3, #4"), "{}", objection.notice); + + // Naming only one of them is not enough. + assert!(checklist_objection(&state, "#3 is still running.").is_some()); + // Every one, by id… + assert!(checklist_objection( + &state, + "I stopped early: #3 needs the dir to exist and #4 would delete your data." + ) + .is_none()); + // …or by its text. + assert!(checklist_objection( + &state, + "I did not count its bytes, and I did not delete it: the disk is read-only." + ) + .is_none()); + // `#30` is not `#3`. + assert!(checklist_objection(&state, "#30 and #40 are open").is_some()); + } + + #[test] + fn a_finished_or_empty_checklist_never_blocks_and_blocked_items_count_as_open() { + assert!(checklist_objection(&checklist("- [x] one\n- [x] two"), "done").is_none()); + assert!(checklist_objection(&TodoState::default(), "done").is_none()); + let objection = checklist_objection(&checklist("- [x] one\n- [!] ask the user"), "done") + .expect("a blocked item is unfinished"); + assert!(objection.feedback.contains("#2 (blocked) ask the user")); + } + + #[test] + fn the_fingerprint_moves_with_any_change_and_not_otherwise() { + let before = checklist("- [ ] one\n- [ ] two"); + let mut after = before.clone(); + assert_eq!(fingerprint(Some(&before)), fingerprint(Some(&after))); + after.update_item("2", Some(TodoStatus::Completed), None); + assert_ne!(fingerprint(Some(&before)), fingerprint(Some(&after))); + assert_eq!(fingerprint(None), fingerprint(Some(&TodoState::default()))); + } + + #[test] + fn the_final_reply_is_what_the_model_said_after_it_last_heard_anything() { + let conversation = Conversation::new_unvalidated(vec![ + Message::user().with_text("do the thing"), + Message::assistant().with_text("I'll start with #1"), + Message::user().with_text("tool result"), + Message::assistant().with_text("Finished #1."), + ]); + assert_eq!(final_reply_text(&conversation), "Finished #1."); + } +} + +/// The gate driven through the real reply loop: a scripted provider, the real +/// Todo capability, and an in-process fixture tool standing in for work. +#[cfg(test)] +mod agent_loop_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use futures::StreamExt; + use rmcp::handler::server::router::tool::ToolRouter; + use rmcp::handler::server::wrapper::Parameters; + use rmcp::model::{CallToolRequestParams, CallToolResult, Content, ServerCapabilities}; + use rmcp::{object, tool, tool_handler, tool_router}; + + use crate::agents::extension::ExtensionConfig; + use crate::agents::{Agent, AgentConfig, AgentEvent, SessionConfig}; + use crate::config::permission::PermissionManager; + use crate::config::BioRouterMode; + use crate::conversation::message::{Message, MessageContent}; + use crate::model::ModelConfig; + use crate::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; + use crate::providers::errors::ProviderError; + use crate::session::extension_data::{TodoState, TodoStatus}; + use crate::session::session_manager::SessionType; + use crate::session::SessionManager; + use rmcp::model::Tool; + + /// One scripted reply per provider call; records what each call was shown. + struct ScriptedProvider { + script: Vec, + calls: AtomicUsize, + seen: Mutex)>>, + } + + impl ScriptedProvider { + fn new(script: Vec) -> Arc { + Arc::new(Self { + script, + calls: AtomicUsize::new(0), + seen: Mutex::new(Vec::new()), + }) + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + /// Every text the provider was shown on call `n` — the conversation, + /// MOIM included — plus the tool results, flattened. + fn shown(&self, n: usize) -> String { + let seen = self.seen.lock().unwrap(); + seen[n] + .1 + .iter() + .flat_map(|message| message.content.iter()) + .map(|content| match content { + MessageContent::ToolResponse(response) => response + .tool_result + .as_ref() + .map(|result| { + result + .content + .iter() + .filter_map(|c| c.as_text().map(|t| t.text.clone())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(), + other => other.as_text().map(str::to_string).unwrap_or_default(), + }) + .collect::>() + .join("\n") + } + + fn system_prompt(&self, n: usize) -> String { + self.seen.lock().unwrap()[n].0.clone() + } + } + + #[async_trait::async_trait] + impl Provider for ScriptedProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::empty() + } + + fn get_name(&self) -> &str { + "scripted" + } + + fn get_model_config(&self) -> ModelConfig { + ModelConfig::new_or_fail("scripted-model") + } + + async fn complete_with_model( + &self, + _model_config: &ModelConfig, + system: &str, + messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + self.seen + .lock() + .unwrap() + .push((system.to_string(), messages.to_vec())); + let reply = self + .script + .get(n) + .cloned() + .unwrap_or_else(|| Message::assistant().with_text("(script exhausted)")); + Ok(( + reply, + ProviderUsage::new( + "scripted-model".to_string(), + Usage::new(Some(10), Some(5), Some(15)), + ), + )) + } + } + + /// The work: one tool that counts how often it really ran. + #[derive(Clone)] + struct StepServer { + tool_router: ToolRouter, + runs: Arc, + } + + /// A step number, so a script that calls the tool many times does not trip + /// the repetition guard — a different gate, with its own tests. + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] + struct StepArgs { + #[serde(default)] + n: u32, + } + + #[tool_router(router = tool_router)] + impl StepServer { + fn new(runs: Arc) -> Self { + Self { + tool_router: Self::tool_router(), + runs, + } + } + + #[tool(description = "Do one step of the fixture task")] + fn step(&self, args: Parameters) -> Result { + self.runs.fetch_add(1, Ordering::SeqCst); + Ok(CallToolResult::success(vec![Content::text(format!( + "step {} done", + args.0.n + ))])) + } + } + + #[tool_handler(router = self.tool_router)] + impl rmcp::ServerHandler for StepServer { + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } + } + + fn call(id: &str, name: &str, arguments: serde_json::Value) -> Message { + Message::assistant().with_tool_request( + id, + Ok(CallToolRequestParams { + task: None, + meta: None, + name: name.to_string().into(), + arguments: arguments.as_object().cloned(), + }), + ) + } + + struct Fixture { + agent: Agent, + session_id: String, + runs: Arc, + _dirs: Vec, + } + + async fn fixture(todo: bool, provider: Arc) -> Fixture { + let data = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let permissions = tempfile::tempdir().unwrap(); + let session_manager = Arc::new(SessionManager::new(data.path().to_path_buf())); + let agent = Agent::with_config( + AgentConfig::new( + Arc::clone(&session_manager), + Arc::new(PermissionManager::new(permissions.path().to_path_buf())), + None, + BioRouterMode::Auto, + ) + .with_project_hooks(false), + ); + if todo { + agent + .add_extension(ExtensionConfig::Platform { + name: "todo".into(), + description: "todo".into(), + bundled: Some(true), + available_tools: vec![], + }) + .await + .expect("enable the Todo capability"); + } + let runs = Arc::new(AtomicUsize::new(0)); + agent + .extension_manager + .add_inprocess_server("fixture", StepServer::new(Arc::clone(&runs))) + .await + .expect("inject the fixture tool"); + let session = session_manager + .create_session( + work.path().to_path_buf(), + "planning gate".to_string(), + SessionType::User, + ) + .await + .unwrap(); + agent + .update_provider(provider, &session.id) + .await + .expect("bind the scripted provider"); + Fixture { + agent, + session_id: session.id, + runs, + _dirs: vec![data, work, permissions], + } + } + + /// Drive one user turn; return every message the stream yielded. + async fn turn(fixture: &Fixture, prompt: &str) -> Vec { + let stream = fixture + .agent + .reply( + Message::user().with_text(prompt), + SessionConfig { + id: fixture.session_id.clone(), + schedule_id: None, + max_turns: Some(20), + max_tool_calls: None, + budget: None, + retry_config: None, + reasoning_effort: None, + }, + None, + ) + .await + .expect("the turn starts"); + tokio::pin!(stream); + let mut yielded = Vec::new(); + while let Some(event) = stream.next().await { + if let AgentEvent::Message(message) = event.expect("the turn runs") { + yielded.push(message); + } + } + yielded + } + + fn notices(messages: &[Message]) -> Vec { + messages + .iter() + .flat_map(|message| message.content.iter()) + .filter_map(|content| match content { + MessageContent::SystemNotification(notice) => Some(notice.msg.clone()), + _ => None, + }) + .collect() + } + + async fn checklist(fixture: &Fixture) -> TodoState { + let session = fixture + .agent + .config + .session_manager + .get_session(&fixture.session_id, false) + .await + .unwrap(); + TodoState::load(&session.extension_data).unwrap_or_default() + } + + const FOUR_STEPS: &str = + "1. create a temp dir 2. write hello.txt into it 3. count its bytes 4. delete it"; + + #[tokio::test] + async fn a_multi_step_turn_is_planned_redirected_worked_and_finished() { + let provider = ScriptedProvider::new(vec![ + // 0: straight to work — refused once, pointed at the checklist. + call("work-1", "fixture__step", serde_json::json!({"n": 1})), + // 1: the plan. + call( + "plan", + "todo__todo_write", + serde_json::json!({"content": "- [ ] make the dir\n- [ ] write the file"}), + ), + // 2: the same work, which now runs. + call("work-2", "fixture__step", serde_json::json!({"n": 1})), + // 3: stops with the list open and nothing named — sent back. + Message::assistant().with_text("All finished."), + // 4: ticks the items, in one batch. + call( + "tick-1", + "todo__todo_update", + serde_json::json!({"id": "1", "status": "completed"}), + ) + .with_tool_request( + "tick-2", + Ok(CallToolRequestParams { + task: None, + meta: None, + name: "todo__todo_update".into(), + arguments: Some(object!({"id": "2", "status": "completed"})), + }), + ), + // 5: stops with every item completed. + Message::assistant().with_text("Done: made the dir and wrote the file."), + ]); + let fixture = fixture(true, Arc::clone(&provider)).await; + + let yielded = turn(&fixture, FOUR_STEPS).await; + + assert_eq!( + provider.calls(), + 6, + "exactly the scripted turn, no more and no less" + ); + // The prompt states what the gate enforces, because the gate runs. + assert!(provider + .system_prompt(0) + .contains("Biorouter enforces the checklist")); + // 1. The reminder rode the first call's context… + assert!( + provider.shown(0).contains("Planning required"), + "{}", + provider.shown(0) + ); + // 2. …the first work call was refused with a pointer to todo_write… + let after_redirect = provider.shown(1); + assert!( + after_redirect.contains("Not run: this request has a list of 4 steps"), + "{after_redirect}" + ); + assert!(after_redirect.contains("todo__todo_write")); + // …and never ran; the repeat after the plan did, exactly once. + assert_eq!(fixture.runs.load(Ordering::SeqCst), 1); + // The reminder is gone once the list exists. + assert!(!provider.shown(2).contains("Planning required")); + // 3. The early stop was sent back with the open items named. + let after_block = provider.shown(4); + assert!( + after_block.contains("your checklist still has 2 unfinished item(s)"), + "{after_block}" + ); + assert!(after_block.contains("#1 (not started) make the dir")); + let shown_to_user = notices(&yielded); + assert!( + shown_to_user + .iter() + .any(|notice| notice.contains("📋") && notice.contains("#1, #2")), + "{shown_to_user:?}" + ); + // The list the chat summary reads: both items, both ticked. + let state = checklist(&fixture).await; + assert_eq!(state.items.len(), 2); + assert!(state + .items + .iter() + .all(|item| item.status == TodoStatus::Completed)); + } + + #[tokio::test] + async fn a_one_line_turn_gets_no_reminder_no_redirect_and_no_list() { + let provider = ScriptedProvider::new(vec![ + call("work", "fixture__step", serde_json::json!({"n": 1})), + Message::assistant().with_text("Done."), + ]); + let fixture = fixture(true, Arc::clone(&provider)).await; + + turn(&fixture, "Run the fixture step.").await; + + assert_eq!(provider.calls(), 2); + assert!(!provider.shown(0).contains("Planning required")); + assert!(!provider.shown(1).contains("Not run:")); + assert_eq!(fixture.runs.load(Ordering::SeqCst), 1, "the call ran"); + assert!(checklist(&fixture).await.items.is_empty()); + } + + #[tokio::test] + async fn with_the_todo_capability_off_the_plain_behaviour_returns() { + let provider = ScriptedProvider::new(vec![ + call("work", "fixture__step", serde_json::json!({"n": 1})), + Message::assistant().with_text("All finished."), + ]); + let fixture = fixture(false, Arc::clone(&provider)).await; + + turn(&fixture, FOUR_STEPS).await; + + assert_eq!(provider.calls(), 2); + assert!(!provider.shown(0).contains("Planning required")); + assert!(!provider + .system_prompt(0) + .contains("Biorouter enforces the checklist")); + assert_eq!(fixture.runs.load(Ordering::SeqCst), 1, "not redirected"); + } + + /// The stop check's two ways out: naming the open items ends the turn at + /// once, and a model that never does is let go after the cap — which does + /// not reset between blocks, because every block here is followed by tool + /// calls that would reset the Stop-hook counter. + #[tokio::test] + async fn the_stop_check_accepts_named_items_and_gives_up_at_the_cap() { + let seed = call( + "plan", + "todo__todo_write", + serde_json::json!({"content": "- [ ] one\n- [ ] two"}), + ); + + // Named: the open items are explained, so the first stop ends the turn. + let provider = ScriptedProvider::new(vec![ + seed.clone(), + Message::assistant().with_text("I stopped: #1 and #2 need your credentials."), + ]); + let named = fixture(true, Arc::clone(&provider)).await; + let yielded = turn(&named, FOUR_STEPS).await; + assert_eq!(provider.calls(), 2); + assert!(notices(&yielded) + .iter() + .all(|notice| !notice.contains("📋"))); + + // Stubborn: works, stops unnamed, works, stops unnamed, … + let mut script = vec![seed]; + for n in 0..(crate::hooks::STOP_HOOK_BLOCK_CAP + 1) { + script.push(call( + &format!("work-{n}"), + "fixture__step", + serde_json::json!({"n": n}), + )); + script.push(Message::assistant().with_text("All finished.")); + } + let provider = ScriptedProvider::new(script); + let stubborn = fixture(true, Arc::clone(&provider)).await; + let yielded = turn(&stubborn, FOUR_STEPS).await; + let cap = crate::hooks::STOP_HOOK_BLOCK_CAP as usize; + // The seed, then (work, stop) for each block, then the last (work, + // stop) that the cap lets through. + assert_eq!(provider.calls(), 1 + 2 * (cap + 1)); + let shown = notices(&yielded); + assert_eq!( + shown + .iter() + .filter(|notice| notice.contains("asking the agent to finish")) + .count(), + cap, + "{shown:?}" + ); + assert!( + shown + .iter() + .any(|notice| notice.contains("finishing anyway")), + "{shown:?}" + ); + } +} diff --git a/crates/biorouter/src/agents/prompt_manager.rs b/crates/biorouter/src/agents/prompt_manager.rs index c7add748a..0a35680ed 100644 --- a/crates/biorouter/src/agents/prompt_manager.rs +++ b/crates/biorouter/src/agents/prompt_manager.rs @@ -125,6 +125,9 @@ struct SystemPromptContext { is_autonomous: bool, enable_subagents: bool, code_execution_mode: bool, + /// The planning gate runs for this conversation (`agents::planning_gate`): + /// the "Working on Tasks" section then states exactly what it enforces. + checklist_enforcement: bool, } pub struct SystemPromptBuilder<'a, M> { @@ -135,6 +138,7 @@ pub struct SystemPromptBuilder<'a, M> { subagents_enabled: bool, hints: Option, code_execution_mode: bool, + checklist_enforcement: bool, variant: PromptVariant, } @@ -167,6 +171,14 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { self } + /// Whether the planning gate enforces the checklist for this turn. Pass + /// `planning_gate::enforcement_applies`, never a value of your own: the + /// clause this renders is a description of that gate. + pub fn with_checklist_enforcement(mut self, enabled: bool) -> Self { + self.checklist_enforcement = enabled; + self + } + pub fn with_hints(mut self, working_dir: &Path) -> Self { let config = Config::global(); let hints_filenames = config @@ -207,19 +219,21 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { subagents_enabled, hints, code_execution_mode, + checklist_enforcement, variant, } = self; let (extensions_info, hints) = prepare_injected_context(extensions_info, frontend_instructions, hints); let config = Config::global(); let biorouter_mode = config.get_biorouter_mode().unwrap_or(BioRouterMode::Auto); - let context = build_system_prompt_context( + let mut context = build_system_prompt_context( manager, extensions_info, biorouter_mode, subagents_enabled, code_execution_mode, ); + context.checklist_enforcement = checklist_enforcement; let base_prompt = render_base_prompt(manager, variant, &context); append_system_prompt_extras(manager, base_prompt, hints, biorouter_mode) } @@ -352,6 +366,7 @@ fn build_system_prompt_context( is_autonomous: biorouter_mode == BioRouterMode::Auto, enable_subagents: subagents_enabled, code_execution_mode, + checklist_enforcement: false, } } @@ -481,6 +496,7 @@ impl PromptManager { subagents_enabled: false, hints: None, code_execution_mode: false, + checklist_enforcement: false, variant: PromptVariant::Default, } } @@ -924,6 +940,26 @@ mod tests { assert!(resources_only.contains("Extension Manager operations are not available")); } + /// The checklist clause describes the planning gate, so it renders exactly + /// when the gate runs (`planning_gate::enforcement_applies`) and never + /// otherwise — a prompt promising a refusal the turn does not make is the + /// defect this flag exists to prevent. + #[test] + fn the_checklist_clause_renders_only_while_the_planning_gate_runs() { + let manager = PromptManager::with_timestamp(DateTime::::from_timestamp(0, 0).unwrap()); + + let off = manager.builder().build(); + assert!(!off.contains("Biorouter enforces the checklist"), "{off}"); + + let on = manager.builder().with_checklist_enforcement(true).build(); + assert!(on.contains("Biorouter enforces the checklist"), "{on}"); + assert!(on.contains("your first action is `todo__todo_write`")); + assert!(on.contains("That refusal happens once per turn")); + assert!(on.contains("names each unfinished item by its `#N` id")); + // It extends the planning bullet rather than replacing it. + assert!(on.contains("plan before acting")); + } + /// Contract test for the agentic-behavior clauses added to `system.md`. /// Each assertion guards one intentional instruction against silent /// removal/regression. These are the table-stakes behaviors the prompt diff --git a/crates/biorouter/src/agents/reply_parts.rs b/crates/biorouter/src/agents/reply_parts.rs index 4a5dfe798..9c9c2270e 100644 --- a/crates/biorouter/src/agents/reply_parts.rs +++ b/crates/biorouter/src/agents/reply_parts.rs @@ -255,6 +255,16 @@ fn coerce_tool_arguments( /// `every_tool_absent_from_the_code_execution_catalogue_stays_directly_callable` /// below. /// +/// The five Todo tools are the second superset exemption: in the catalogue AND +/// kept, for a reason about models rather than plumbing. A planning tool that +/// is reachable only by writing JavaScript inside `execute_code` is one a model +/// does not reach for — measured in the 2026-09-10 composer QA run, where the +/// collapsed roster had 18 tools, no `todo__*`, and the model built a checklist +/// exactly once, when told to. They are cheap structural calls, the same class +/// as the platform tools, and the planning gate (`agents::planning_gate`) +/// points a multi-step turn at `todo__todo_write` by name, which only works if +/// the model can call it. +/// /// Both name forms of the spawn tool are kept — models strip prefixes. /// /// [`ExtensionManager::get_prefixed_tools_excluding`]: crate::agents::ExtensionManager::get_prefixed_tools_excluding @@ -297,6 +307,9 @@ pub(crate) fn survives_code_execution_filter( // roster" — which is the state that reaches nowhere, and the state this // tool shipped in for exactly one live run. || crate::security::knowledge_delete::is_knowledge_delete_tool(tool_name) + // The checklist: exact names, never a `todo__` prefix — see + // `TODO_TOOL_NAMES` for why the prefix is not the builtin's to claim. + || crate::agents::todo_extension::is_todo_tool_name(tool_name) } fn code_execution_mode_is_active(loaded: bool, tools: &[Tool]) -> bool { @@ -467,6 +480,26 @@ impl Agent { &model_config.model_name, ); + let is_subagent = matches!( + self.config + .session_manager + .get_session(session_id, false) + .await + .ok() + .map(|session| session.session_type), + Some(SessionType::SubAgent) + ); + // The same predicate, over the same roster, that decides whether the + // turn enforces anything (`Agent::begin_planning_turn`) — so the prompt + // can never describe a gate this turn does not run. Read before the + // toolshim branch below empties `tools`. + let checklist_enforcement = crate::agents::planning_gate::enforcement_applies( + self.config.biorouter_mode, + is_subagent, + bridge_replaces_tool_surface, + tools.iter().map(|tool| tool.name.as_ref()), + ); + let prompt_manager = self.prompt_manager.lock().await; let enable_subagents = match active_bridge_plan { Some(plan) => plan.delegation_available, @@ -477,20 +510,12 @@ impl Agent { .with_extensions(extensions_info.into_iter()) .with_frontend_instructions(self.frontend_instructions.lock().await.clone()) .with_code_execution_mode(code_execution_active) + .with_checklist_enforcement(checklist_enforcement) .with_hints(working_dir) .with_enable_subagents(enable_subagents) .with_prompt_variant(prompt_variant) .build(); - let is_subagent = matches!( - self.config - .session_manager - .get_session(session_id, false) - .await - .ok() - .map(|session| session.session_type), - Some(SessionType::SubAgent) - ); if is_subagent { system_prompt.push_str("\n\n"); system_prompt.push_str(SUBAGENT_STEERING_INSTRUCTIONS); @@ -1279,6 +1304,29 @@ mod tests { )); } + /// The checklist stays a direct call in Code Execution mode. Before this, + /// `todo__*` collapsed into the JS catalogue with everything else and a + /// model reached it only by scripting — which it did once, when told to. + #[test] + fn the_code_execution_filter_keeps_every_todo_tool_directly_callable() { + let prefix = format!("{CODE_EXECUTION_EXTENSION}__"); + let no_frontend = HashSet::new(); + for name in crate::agents::todo_extension::TODO_TOOL_NAMES { + assert!( + survives_code_execution_filter(name, &prefix, &no_frontend), + "{name} is a cheap structural call the planning gate points at by name, so \ + Code Execution mode must keep it directly callable" + ); + } + // Exact names, not a prefix: a third-party server keyed `todo` gets no + // exemption from sharing the builtin's key. + assert!(!survives_code_execution_filter( + "todo__some_other_tool", + &prefix, + &no_frontend + )); + } + /// The CLASS guard, and the thing that was missing while issue #141 was /// closed for exactly one of the families it applies to. /// diff --git a/crates/biorouter/src/agents/schedule_tool.rs b/crates/biorouter/src/agents/schedule_tool.rs index 34b9a73c8..3a2d0bf5e 100644 --- a/crates/biorouter/src/agents/schedule_tool.rs +++ b/crates/biorouter/src/agents/schedule_tool.rs @@ -134,7 +134,7 @@ fn permission_mode_label() -> &'static str { /// Clock times are this computer's local time because that is how the engine /// reads them (`Job::new_async_tz(.., Local)` in `scheduler.rs`), and day-of-week /// numbers follow the engine's parser (`croner`): 0 and 7 are Sunday. -pub(crate) fn describe_cron(expression: &str) -> String { +pub fn describe_cron(expression: &str) -> String { let quoted = || format!("on the cron schedule `{}`", expression.trim()); let fields: Vec<&str> = expression.split_whitespace().collect(); let (second, minute, hour, day, month, weekday) = match fields.as_slice() { @@ -1077,6 +1077,15 @@ mod tests { Ok(()) } + async fn kill_running_job_in_session( + &self, + sched_id: &str, + expected_session_id: Option<&str>, + ) -> Result<(), SchedulerError> { + self.record(format!("kill {sched_id} in {expected_session_id:?}")); + Ok(()) + } + async fn get_running_job_info( &self, sched_id: &str, diff --git a/crates/biorouter/src/agents/todo_extension.rs b/crates/biorouter/src/agents/todo_extension.rs index 79bacd606..0d6e7cb37 100644 --- a/crates/biorouter/src/agents/todo_extension.rs +++ b/crates/biorouter/src/agents/todo_extension.rs @@ -15,6 +15,30 @@ use tokio_util::sync::CancellationToken; pub static EXTENSION_NAME: &str = "todo"; +/// The Todo tools as the model calls them: the extension key, `__`, the tool. +/// +/// Spelled out rather than matched by a `todo__` prefix, because the prefix is +/// not this builtin's to own: a user who disables the capability and installs +/// an MCP server keyed `todo` would otherwise inherit every exemption these +/// names carry. `the_todo_tool_names_are_the_tools_this_extension_lists` pins +/// the list to [`TodoClient::get_tools`], so a sixth tool cannot ship unnamed. +pub const TODO_TOOL_NAMES: [&str; 5] = [ + "todo__todo_write", + "todo__todo_add", + "todo__todo_expand", + "todo__todo_update", + "todo__plan_write", +]; + +/// The tool that seeds a checklist, and the one the planning gate points a +/// multi-step turn at (`agents::planning_gate`). +pub const TODO_WRITE_TOOL_NAME: &str = "todo__todo_write"; + +/// Is `tool_name` one of this capability's tools, as the model calls it? +pub fn is_todo_tool_name(tool_name: &str) -> bool { + TODO_TOOL_NAMES.contains(&tool_name) +} + /// Default cap on the number of items a checklist may hold (per session). const DEFAULT_MAX_ITEMS: usize = 200; @@ -726,9 +750,12 @@ impl McpClientTrait for TodoClient { .await .ok()?; - // Only the live plan/task state belongs here; the behavioral rule (plan - // up front, keep a todo list) lives in system.md so it holds even - // without this extension. See BR-4 / BR-60. + // Only the live plan/task state belongs here. The behavioural rule + // lives in two places, neither of them this extension: system.md + // states it, so it holds even without this extension (BR-4 / BR-60), + // and the planning gate (`agents::planning_gate`) enforces it for a + // multi-step turn — adding its own "write the checklist first" line to + // the same MOIM block while this one has nothing to render. let state = extension_data::TodoState::load(&metadata.extension_data)?; if state.is_empty() { return None; @@ -778,6 +805,28 @@ mod tests { .join("\n") } + /// `TODO_TOOL_NAMES` carries exemptions (the Code Execution filter, the + /// planning gate), so it must be exactly this extension's tools under the + /// key the manager gives them — no more, no fewer. + #[test] + fn the_todo_tool_names_are_the_tools_this_extension_lists() { + let key = crate::config::extensions::name_to_key(EXTENSION_NAME); + let mut listed: Vec = TodoClient::get_tools() + .iter() + .map(|tool| format!("{key}__{}", tool.name)) + .collect(); + listed.sort(); + let mut named: Vec = TODO_TOOL_NAMES.iter().map(|n| n.to_string()).collect(); + named.sort(); + assert_eq!(listed, named); + assert!(is_todo_tool_name(TODO_WRITE_TOOL_NAME)); + assert!( + !is_todo_tool_name("todo_write"), + "only the name the model calls" + ); + assert!(!is_todo_tool_name("todo__something_else")); + } + #[tokio::test] async fn all_advertised_todo_tools_dispatch_and_reinject_their_state() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/biorouter/src/agents/tool_execution.rs b/crates/biorouter/src/agents/tool_execution.rs index 88df0009c..56bd9b886 100644 --- a/crates/biorouter/src/agents/tool_execution.rs +++ b/crates/biorouter/src/agents/tool_execution.rs @@ -144,6 +144,16 @@ pub(crate) fn denied_response_text( { result.reason.clone() } + // The planning gate: nobody declined, and the reason IS the instruction + // — write the checklist, then repeat the call. `DECLINED_RESPONSE` here + // would answer the redirect with "the user has declined", which is both + // false and unactionable, and would silently remove the one behaviour + // the gate exists to deliver. + Some(result) + if result.inspector_name == crate::agents::planning_gate::PLANNING_GATE_NAME => + { + result.reason.clone() + } _ => DECLINED_RESPONSE.to_string(), } } diff --git a/crates/biorouter/src/catalog_search.rs b/crates/biorouter/src/catalog_search.rs index cfaf4ef11..7aee905e4 100644 --- a/crates/biorouter/src/catalog_search.rs +++ b/crates/biorouter/src/catalog_search.rs @@ -174,6 +174,52 @@ fn words(text: &str) -> impl Iterator + '_ { .map(str::to_lowercase) } +/// Does `label` say nothing that `license` does not — is every word of it a word +/// of the licence? A catalog whose entries carry a licence drops such a label +/// when it assembles an entry's searchable text. +/// +/// The licence itself is not a searchable field: every entry in the BAAM +/// registry is Apache-2.0, so it separates nothing, and both catalog searches +/// leave the field out for that reason. +/// +/// ⚠ **Leaving the FIELD out was not enough.** A registry republishes the licence +/// as one of the entry's own tag chips — and, for a skill, again among its +/// keywords — and labels are searched, rightly: `MCP`, `ELN` and `Imaging` are +/// exactly what a tag is for. Measured in the Browse-extensions modal on +/// 2026-09-12 against the live 37-entry registry, with the field already gone: +/// `PACS` → 31 of 37, `pac` → 31, `apache` → 31, and not one of the 31 about +/// PACS. The three counts agreeing is the identification — `PACS` reaches `pac` +/// through the plural fallback in [`term_strength`], `pac` is inside `apache`, +/// and 31 rows wear an `Apache-2.0` chip. Removing the field had moved the defect +/// one field over, where a test asserting "the licence is not searched" still +/// passed. +/// +/// Compared by WORDS rather than by equality, because the second spelling is not +/// the first: the tag is `Apache-2.0` and the keyword is `apache`. An equality +/// test drops the tag and keeps the keyword, which is the same half-fix again. +/// +/// What this deliberately does not do: drop every label (`MCP`, `Imaging`, `ELN`, +/// `Registry` are real search value), or name a licence in the matcher +/// (`Apache`, `MIT` — the next licence reopens the hole). The cost of the word +/// test is a licence id built from a topical word — `Python-2.0`, `Ruby`, +/// `PostgreSQL` — on an entry that also tags itself with that word; the tag is +/// then dropped for saying only what the licence says. No entry in the shipped +/// registry is such a case (measured over all 166: the rule drops the 129 licence +/// labels and nothing else), and an equality test pays a smaller version of the +/// same cost. +pub(crate) fn names_only_the_license(label: &str, license: &str) -> bool { + let license_words: Vec = words(license).collect(); + let mut label_words = words(label); + match label_words.next() { + // An empty label says nothing at all, which is not the same as saying + // only the licence: leave it, so the rule stays about the licence. + None => false, + Some(first) => { + license_words.contains(&first) && label_words.all(|word| license_words.contains(&word)) + } + } +} + /// The distinct terms of `query`, in the order written, without filler. fn terms(query: &str, noise: &[&str]) -> Vec { let mut all: Vec = Vec::new(); @@ -556,4 +602,45 @@ mod tests { assert!(search.terms.is_empty()); assert_eq!(ids(&search), ["complex-plots", "prose-only", "r-scripting"]); } + + /// The rule a catalog applies to its own labels. Both spellings the BAAM + /// registry publishes go, which is the whole point — `Apache-2.0` is the tag + /// and `apache` is the keyword, and an equality test would keep the second + /// and leave `PACS` matching 49 skills through it. + #[test] + fn a_label_naming_only_the_licence_is_recognised_in_either_spelling() { + for label in [ + "Apache-2.0", + "apache", + "APACHE", + "apache 2.0", + "2.0", + "Apache/2.0", + ] { + assert!( + names_only_the_license(label, "Apache-2.0"), + "`{label}` says nothing `Apache-2.0` does not" + ); + } + // A label that says anything else stays searchable, including one that + // merely contains a word of the licence. + for label in [ + "Apache Spark", + "MCP", + "ELN", + "Imaging", + "Registry", + "apachex", + ] { + assert!( + !names_only_the_license(label, "Apache-2.0"), + "`{label}` says more than the licence" + ); + } + // An empty label says nothing at all, which is not the same as saying + // only the licence; and an entry with no licence has none to drop. + assert!(!names_only_the_license("", "Apache-2.0")); + assert!(!names_only_the_license(" - ", "Apache-2.0")); + assert!(!names_only_the_license("Apache-2.0", "")); + } } diff --git a/crates/biorouter/src/marketplace.rs b/crates/biorouter/src/marketplace.rs index ba894b424..c43a797ec 100644 --- a/crates/biorouter/src/marketplace.rs +++ b/crates/biorouter/src/marketplace.rs @@ -8,7 +8,9 @@ use futures::StreamExt; use serde::Deserialize; use url::Url; -use crate::catalog_search::{rank, CatalogSearch, Weight, EXTENSION_NOISE, SKILL_NOISE}; +use crate::catalog_search::{ + names_only_the_license, rank, CatalogSearch, Weight, EXTENSION_NOISE, SKILL_NOISE, +}; use crate::config::paths::Paths; use crate::privacy::affiliation::InstitutionId; use crate::privacy::{ExtensionAffiliation, ProviderTier}; @@ -133,7 +135,13 @@ impl MarketplaceCatalog { (entry.organization.as_str(), Weight::Label), (entry.description.as_str(), Weight::Prose), ]; - fields.extend(entry.tags.iter().map(|tag| (tag.as_str(), Weight::Label))); + fields.extend( + entry + .tags + .iter() + .filter(|tag| !names_only_the_license(tag, &entry.license)) + .map(|tag| (tag.as_str(), Weight::Label)), + ); fields }, ) @@ -153,11 +161,18 @@ impl MarketplaceCatalog { (entry.category.as_str(), Weight::Label), (entry.description.as_str(), Weight::Prose), ]; - fields.extend(entry.tags.iter().map(|tag| (tag.as_str(), Weight::Label))); + fields.extend( + entry + .tags + .iter() + .filter(|tag| !names_only_the_license(tag, &entry.license)) + .map(|tag| (tag.as_str(), Weight::Label)), + ); fields.extend( entry .keywords .iter() + .filter(|keyword| !names_only_the_license(keyword, &entry.license)) .map(|keyword| (keyword.as_str(), Weight::Label)), ); fields @@ -1033,6 +1048,131 @@ mod tests { assert!(phrase.len() >= 2, "{phrase:?}"); } + /// A licence is not searchable, and leaving the FIELD out did not make that + /// true. Measured in the Browse-extensions modal on 2026-09-12 against the + /// live 37-entry registry, with the field already gone: `PACS` → 31 of 37, + /// `pac` → 31, `apache` → 31, `Apache-2.0` → 32, empty → 37, `zzzznope` → 0. + /// The three counts agreeing is the identification — `PACS` reaches `pac` + /// through the plural fallback, `pac` is inside `apache`, and 31 rows + /// republish `Apache-2.0` as one of their own TAG chips, which are searched. + /// None of the 31 was about PACS. + /// + /// What this must NOT do is narrow the substring rule: `pac` is inside + /// "PacBio", "package" and "workspace", and those hits stay. + #[test] + fn a_licence_republished_as_a_label_is_not_searchable_through_it() { + let catalog = MarketplaceCatalog::from_bytes(EMBEDDED_REGISTRY).unwrap(); + + // A word of a licence, as a whole label. Spelled out here rather than + // taken from `catalog_search` so this test cannot be satisfied by the + // same mistake the fix makes. + let is_a_licence_word = |license: &str, label: &str| { + license + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .any(|word| word.eq_ignore_ascii_case(label)) + }; + + // Guard. If the registry stops republishing its licence as a label there + // is nothing here to refuse, and every assertion below would pass while + // proving nothing — which is exactly how the fix before this one looked + // covered. Three counts, because there are three label paths. + let tagged_extensions = catalog + .browse_extensions(ProviderTier::Private) + .iter() + .filter(|entry| { + entry + .tags + .iter() + .any(|tag| tag.eq_ignore_ascii_case(&entry.license)) + }) + .count(); + let tagged_skills = catalog + .browse_skills() + .iter() + .filter(|entry| { + entry + .tags + .iter() + .any(|tag| tag.eq_ignore_ascii_case(&entry.license)) + }) + .count(); + let keyworded_skills = catalog + .browse_skills() + .iter() + .filter(|entry| { + entry + .keywords + .iter() + .any(|keyword| is_a_licence_word(&entry.license, keyword)) + }) + .count(); + assert!( + tagged_extensions >= 2 && tagged_skills >= 2 && keyworded_skills >= 2, + "the shipped registry no longer republishes its licence as a label \ + (extensions tagged {tagged_extensions}, skills tagged {tagged_skills}, skills \ + keyworded {keyworded_skills}) — this test would pass vacuously" + ); + + // `apache` occurs nowhere in the registry except each entry's own + // licence, so it finds nothing at all. Measured before the fix: 31 + // extensions and 49 skills, none of them about Apache anything. + let extension_ids = |search: &CatalogSearch<'_, MarketplaceExtensionDescriptor>| { + search + .hits + .iter() + .map(|hit| hit.entry.registry_id.clone()) + .collect::>() + }; + assert_eq!( + extension_ids(&catalog.search_extensions(ProviderTier::Private, "apache")), + Vec::::new() + ); + assert_eq!( + skill_ids(&catalog.search_skills("apache")), + Vec::::new() + ); + + // The query as the QA run typed it. The plural fallback and the + // substring rule both stay: a skill that really says PACS is found, and + // a skill whose only `pac` was `Apache-2.0` is not. + let pacs = skill_ids(&catalog.search_skills("PACS")); + assert!( + pacs.contains(&"biomedical-imaging-pathology".to_owned()), + "a skill whose keywords say `pacs` must still be found: {pacs:?}" + ); + assert!( + pacs.contains(&"long-read-sequencing".to_owned()), + "`pac` inside `PacBio` is the substring rule working, not the defect: {pacs:?}" + ); + for licence_only in ["empirical-research-router", "causal-identification-gates"] { + assert!( + !pacs.contains(&licence_only.to_owned()), + "`{licence_only}`'s only `pac` is its `Apache-2.0` label: {pacs:?}" + ); + } + let pacs_extensions = + extension_ids(&catalog.search_extensions(ProviderTier::Private, "PACS")); + for licence_only in ["benchlingagent", "dnanexusagent", "omeroagent"] { + assert!( + !pacs_extensions.contains(&licence_only.to_owned()), + "`{licence_only}` is not about PACS; its only `pac` is `Apache-2.0`: \ + {pacs_extensions:?}" + ); + } + + // The browse case is untouched — the licence label is dropped from what + // is SEARCHED, not from the catalog. + assert_eq!( + catalog.search_skills("").len(), + catalog.browse_skills().len() + ); + assert_eq!( + catalog.search_extensions(ProviderTier::Private, "").len(), + catalog.browse_extensions(ProviderTier::Private).len() + ); + } + /// The extension catalog shares the matcher, and the caller filter runs /// before the ranking: a public caller's multi-word query can match the /// private row's words and still never be shown it. diff --git a/crates/biorouter/src/privacy/alt_provider.rs b/crates/biorouter/src/privacy/alt_provider.rs index 20d02bf60..85ddd042b 100644 --- a/crates/biorouter/src/privacy/alt_provider.rs +++ b/crates/biorouter/src/privacy/alt_provider.rs @@ -15,11 +15,45 @@ //! (`agents/knowledge_tool.rs`) prefers a target KB's `default_model` over //! the agent's own provider for scheduled jobs. //! +//! Site 3 is reached through exactly one function, `build_model_ref_provider`, +//! by BOTH knowledge paths: `platform__ingest_source`'s `model` argument (a +//! provider name the MODEL wrote) and a base's stored `default_model` on a +//! scheduled digest. That is deliberate — they end in the same knowledge-base +//! ratchet, so a third knowledge path cannot be added without passing the gate. +//! //! A fourth site, the HTTP knowledge macros (`routes/knowledge.rs`'s -//! `build_completer`), is deliberately **not** here: it has a knowledge-base id -//! and no session at all, so the predicate it needs is the KB-keyed one Task 10C -//! installs, not this session-keyed one. Putting a `SessionClassification` check -//! there would be a type error, not merely a misfiling. +//! `build_completer`), is deliberately **not** here, and neither is its CLI twin +//! (`biorouter-cli/src/commands/knowledge.rs`'s `build_completer`, whose +//! provider comes from `--provider`): both have a knowledge-base id and no +//! session at all, so the predicate they need is the KB-keyed one Task 10C +//! installs (`assert_macro_target_reachable` → `knowledge::tier::assert_reachable`), +//! not this session-keyed one. Putting a `SessionClassification` check there +//! would be a type error, not merely a misfiling. The person who typed the +//! provider name is also the one DR-16 rules may raise a tier, which is the +//! substantive half of the distinction rather than the typing one. +//! +//! # How to re-derive this list rather than trust it +//! +//! Grep the workspace for `providers::create`, `create_from_persisted` and +//! `create_with_named_model`, then keep only the calls where BOTH hold: the +//! provider is *not* the one the session row records, and a session's content +//! reaches it. Everything else falls out for a nameable reason, and the reasons +//! are worth knowing because each looks like a Gate H site from the grep alone: +//! +//! * **It binds, so Gate A owns it** — `Agent::update_provider` and the turn +//! barrier, the CLI session builder, `routes/agent.rs`, `commands/web.rs`, +//! `biorouter-acp`, `scheduler.rs`, and `workspace_set_tools`' provider switch +//! (which additionally asks `tool_bind_allowed`, DR-16's half — see +//! [`assert_alt_provider_matches_session`], which asks the same predicate). +//! * **It is the spawn**, whose own gate already refuses BOTH directions +//! (`subagent_tool::apply_settings_overrides`). +//! * **No session content reaches it** — `config_management.rs`'s provider +//! validation and `auto_detect.rs`'s availability probe send a canned request. +//! * **It is the app bind**, which DR-21 confines to `app_provider_bind` (see +//! the ⚠ note where `routes/apps.rs` deliberately does *not* import `create`). +//! +//! Re-derived this way on 2026-09-12: the three sites above, plus the two +//! KB-keyed exemptions, are the whole list. //! //! # What this gate does NOT do: it never raises the floor //! @@ -34,10 +68,38 @@ //! is refused above — and it is the same class as the one-turn window //! `Agent::reply` documents at its ratchet (O5). Recorded here because Task 19 //! did not close it and nothing else in this module would say so. +//! +//! # Two halves, because "harmless" depends on where the tier lands +//! +//! [`assert_alt_provider_allowed`] refuses only the DOWNWARD choice, because +//! that is the only one that discloses anything: a MORE private provider cannot +//! leak the session it is handed. Sites 1 and 2 above take that half, and it is +//! the right one for them — plan mode's answer and a hook's output land in +//! `self.messages`, where the residual is the under-classification recorded +//! above and the provider was named by a person's own configuration. +//! +//! [`assert_alt_provider_matches_session`] is the STRICTER half, and site 3 +//! needs it: a knowledge ingest does not merely read the session, it writes into +//! a **knowledge base**, whose tier is a permanent, monotone ratchet over the +//! callers that write to it (`biorouter-mcp`'s `knowledge::tier`). The chosen +//! provider's tier — not the session's classification — is what +//! `SourceIngestArgs::caller_capability` carries into that ratchet, so the +//! "harmless" upward choice permanently privatises a base a **public** chat +//! owns, and that chat is then refused at every KB read choke point. The user +//! loses a base to a decision nobody asked them about. +//! +//! So on that half the tiers must MATCH. It is the same ruling the spawn gate +//! reached for the same reason (`subagent_tool::apply_settings_overrides` +//! refuses `spawn_upgrade` AND `spawn_downgrade`): R4 says a public parent may +//! not gain private reach, DR-19 supplies the initiator R4 never named, and a +//! tool call has no person on the other end to consent. DR-16 rules the raise +//! the user's decision, and SD-8 rules that a control which can never work here +//! says so rather than asking — `is_user_action` is false for every tool call, +//! so a proof check here could only ever refuse. use anyhow::{anyhow, Result}; -use super::{bind_allowed, SessionClassification}; +use super::{bind_allowed, tool_bind_allowed, SessionClassification}; use crate::providers::base::Provider; /// Refuse to hand a session's content to a provider that is not the one bound to @@ -64,7 +126,105 @@ pub fn assert_alt_provider_allowed( // DR-15's master opt-out, read INSIDE the gate. A direct read, not a // `CallCapability`: this gate fires where nothing is bound, so there is no // admitted tool call whose capability it could inherit. - if !super::privacy_tiers_enabled() || bind_allowed(provider.tier(), session) { + if !super::privacy_tiers_enabled() { + return Ok(()); + } + refuse_downward(what, provider, session, env_key_to_name) +} + +/// [`assert_alt_provider_allowed`] **plus** the upward half, for an alternate +/// provider whose tier does not stay in this process: it becomes the ratchet +/// input of a knowledge base's permanent classification. +/// +/// The arguments and the downward sentence are the sibling's, unchanged and +/// undoubled — this is that gate AND one more rule, not a second spelling of it, +/// so the two can never disagree about the cell they share. What it adds is the +/// raise: a provider more private than the session is refused, because +/// `caller_capability` is what `knowledge::tier::raise_unlocked` ratchets on and +/// a ratchet is not undoable. A public chat that names a private model would +/// mark its own base private for good and then be refused at +/// `knowledge::tier::assert_reachable` — a loss of access to the user's own +/// notes that no one was asked about, delivered by a tool call. +/// +/// ⚠ **Do not "simplify" this to the sibling.** The cell that differs — +/// `(session = Public, provider = Private)` — is `Ok` there on purpose and is +/// the whole finding here; `only_a_private_session_on_a_public_provider_is_refused` +/// and [`the_upward_choice_is_refused_only_on_the_ratcheting_half`] pin both +/// answers side by side. +/// +/// It is also the same shape as `workspace_set_tools`' provider switch, which +/// asks [`bind_allowed`] and then [`tool_bind_allowed`] for the same two +/// sentences. That is not a coincidence to be tidied away later: both surfaces +/// are a MODEL asking for a tier it was not given, so they must answer with one +/// set of cells. This function therefore *calls* [`tool_bind_allowed`] rather +/// than re-spelling `is_private() == is_private()`, and a change to DR-16's rule +/// lands on both sites or neither. +/// +/// [`the_upward_choice_is_refused_only_on_the_ratcheting_half`]: tests::the_upward_choice_is_refused_only_on_the_ratcheting_half +pub fn assert_alt_provider_matches_session( + what: &str, + provider: &dyn Provider, + session: SessionClassification, + env_key_to_name: &str, +) -> Result<()> { + // One read of DR-15's switch for both rules, for the same reason + // `tier::assert_reachable` reads it once for both of its axes. + // + // ⚠ It is a DIRECT read, and on one of this gate's two callers that is a + // departure from the house rule — measured, bounded and recorded rather than + // half-fixed. The rule (see `privacy::CallCapability`) is that a gate on a + // TOOL-CALL path asks the once-per-call `cap.enforced()` instead of + // re-reading the flag, because `privacy_tiers_enabled()` is a runtime + // `AtomicBool` that `POST /config/upsert`'s gated arm can flip mid-call. + // `platform__ingest_source` IS a tool call and does hold a sampled + // `CallCapability` (its `resolve_target_kb` already uses it), so this read is + // a second one; the scheduled-digest caller threads no capability at all, so + // it could only ever read directly. + // + // Why the residual is not worth the threading: the harmful outcome needs the + // switch OFF **here** (to admit the raise) and ON again at the write (to + // ratchet), because `knowledge::tier::raise_unlocked` asks + // `ratchets_are_live()` for itself. A flip in one direction produces no loss, + // and both directions inside one ingest means the user turned protection off + // and back on while a model was resolving a provider. Threading a capability + // through two callers where only one has one, to close that, is a larger + // change with a worse failure mode than the race it removes. If a future + // caller gives this gate a capability on BOTH paths, take it — do not add a + // parameter only the first path can fill. + if !super::privacy_tiers_enabled() { + return Ok(()); + } + refuse_downward(what, provider, session, env_key_to_name)?; + // DR-16's half, asked through the predicate that already states it + // (`privacy::tool_bind_allowed`) instead of a second spelling of its cells. + // Reached only when `refuse_downward` — i.e. `bind_allowed` — already + // passed, so the only way this can be false is the RAISE; the sentence for + // the downward cell belongs to `refuse_downward` and is not repeated here. + if tool_bind_allowed(provider.tier(), session) { + return Ok(()); + } + Err(anyhow!( + "This chat is public, so {what} cannot run on `{}`, which is a private model: a \ + knowledge base takes the tier of the most private model that writes to it, so this \ + would mark the base private permanently and lock this public chat out of its own \ + notes. Nothing was written. Do not retry with the same model; the answer will not \ + change. Set {env_key_to_name} to a public model, or stop and ask the user to \ + continue this work in a private chat.", + provider.get_name() + )) +} + +/// Gate A's rule, and the one sentence that states it. Shared so both public +/// entry points above refuse the downward choice in the same words — and so +/// [`bind_allowed`] keeps exactly one caller in this file, which the guard +/// census counts. +fn refuse_downward( + what: &str, + provider: &dyn Provider, + session: SessionClassification, + env_key_to_name: &str, +) -> Result<()> { + if bind_allowed(provider.tier(), session) { return Ok(()); } Err(anyhow!( @@ -130,8 +290,11 @@ mod tests { assert!(err.contains("BIOROUTER_PLANNER_PROVIDER"), "{err}"); // ...and the other three are allowed, or this is not a barrier but an - // outage. A public session is unaffected in BOTH directions: upward is - // Gate A's business, not this one's. + // outage. A public session is unaffected in BOTH directions HERE: the + // upward choice discloses nothing, so on a path whose tier stops in this + // process it is Gate A's business and not this one's. Where that tier + // becomes a knowledge base's permanent classification it is refused, and + // the test below is the other half of this pair. for (session, tier) in [ (Private, ProviderTier::Private), (Public, ProviderTier::Public), @@ -144,4 +307,95 @@ mod tests { ); } } + + /// The ratcheting half. Three cells agree with the sibling above and the + /// fourth — a PUBLIC session naming a PRIVATE provider — is the finding: + /// allowed there, refused here. + /// + /// Stated as both the four cells and the rule, so a third tier cannot be + /// added while satisfying the cells. + #[test] + fn the_upward_choice_is_refused_only_on_the_ratcheting_half() { + use SessionClassification::{Private, Public}; + + let err = assert_alt_provider_matches_session( + "ingesting these sources", + &provider_at(ProviderTier::Private), + Public, + "this tool's `model` argument", + ) + .expect_err("a public chat may not privatise its base by naming a private model") + .to_string(); + // A refusal names what it refused, why, and the way out. + assert!(err.contains("public"), "{err}"); + assert!(err.contains("private model"), "{err}"); + assert!(err.contains("planner"), "the provider must be named: {err}"); + assert!(err.contains("ingesting these sources"), "{err}"); + assert!(err.contains("`model` argument"), "{err}"); + assert!( + err.contains("Do not retry"), + "a refusal the model will retry is a loop: {err}" + ); + + // The downward cell is still the sibling's sentence, not a second one: + // one rule, one wording, whichever entry point asked. + let downward = assert_alt_provider_matches_session( + "ingesting these sources", + &provider_at(ProviderTier::Public), + Private, + "KEY", + ) + .expect_err("a private chat on a public model must still be refused") + .to_string(); + assert_eq!( + downward, + assert_alt_provider_allowed( + "ingesting these sources", + &provider_at(ProviderTier::Public), + Private, + "KEY" + ) + .expect_err("the sibling refuses the same cell") + .to_string(), + "the two entry points must refuse the downward choice in the same words" + ); + + // Sideways, both directions of the pair: the only choices that remain, + // and the ones that must keep working. + for (session, tier) in [ + (Private, ProviderTier::Private), + (Public, ProviderTier::Public), + ] { + assert!( + assert_alt_provider_matches_session( + "ingesting these sources", + &provider_at(tier), + session, + "KEY" + ) + .is_ok(), + "{session:?} + {tier:?} is sideways, not a crossing" + ); + } + + // The rule, not the cells — and stated as the predicate rather than as a + // second spelling of its comparison. This gate admits exactly what the + // model-facing BIND predicate admits, because both surfaces are a model + // asking for a tier nobody granted it; `workspace_set_tools`' provider + // switch is the other caller. The four cells are pinned above, so this + // adds the thing the cells cannot say: that the two policies are one. + for (session, tier) in [ + (Public, ProviderTier::Public), + (Public, ProviderTier::Private), + (Private, ProviderTier::Public), + (Private, ProviderTier::Private), + ] { + assert_eq!( + assert_alt_provider_matches_session("x", &provider_at(tier), session, "KEY") + .is_ok(), + tool_bind_allowed(tier, session), + "{session:?} + {tier:?}" + ); + } + } } diff --git a/crates/biorouter/src/privacy/config_keys.rs b/crates/biorouter/src/privacy/config_keys.rs index 7d5fbe719..0cedb3dba 100644 --- a/crates/biorouter/src/privacy/config_keys.rs +++ b/crates/biorouter/src/privacy/config_keys.rs @@ -42,6 +42,37 @@ pub const CAPABILITY_CONFIG_KEYS: &[&str] = &[ /// Every other key the tier-input files read, each with the reason it does not /// determine capability. A key must be in exactly one of these two lists. pub const NOT_CAPABILITY_CONFIG_KEYS: &[(&str, &str)] = &[ + // The other half of `/agent/start`'s bind, and the 2026-09-12 review's + // Finding 2: it was in NEITHER list, so any caller holding only the daemon + // secret could write it without proof, on every daemon including the + // desktop's. Classified rather than guarded, because the classification is + // what the guard would have to be justified by, and it comes out the other + // way: no `tier()` implementation reads the model name. All five tier-input + // providers were checked — both Versa modules resolve + // `ucsf_gateway_tier(endpoint)`, `ollama` and `llamacpp` resolve + // `self_hosted_tier(base_url)`, and `LeadWorkerProvider` takes the `least` of + // its two halves — and a model name is a *string*, so it cannot smuggle a + // persisted provider binding either: those live in + // `ModelConfig::request_params`, which `ModelConfig::new` leaves empty. + // + // What an unproven write to it DOES permit is an integrity and availability + // problem, recorded so nobody mistakes it for nothing: + // every new chat starts on a model the operator did not choose (a cheaper or + // weaker one, or a different Versa deployment inside the same private + // gateway), or on none at all, because `configured_new_session_provider` + // requires both halves and answers `400` when only one is set. Neither moves + // a tier. ⚠ **On SD-12's keyless path it is nevertheless pinned to the launch + // configuration** (`biorouter_server::launch::pinned_config_keys`) — not + // because it decides capability, but because the exemption there is for the + // operator's own declaration and this is half of it. + // + // Read through the `config_value!` macro (base.rs), like `BIOROUTER_PROVIDER`, + // so the literal never appears in a `get_param(` call and the scan below + // cannot see it. Seeded, and the test asserts the seed survives. + ( + "BIOROUTER_MODEL", + "names which model runs, never which tier: no `tier()` reads the model name", + ), ("BIOROUTER_CONTEXT_LIMIT", "token budget, not a tier input"), ( "BIOROUTER_LEAD_TURNS", @@ -98,11 +129,42 @@ pub const NOT_CAPABILITY_CONFIG_KEYS: &[(&str, &str)] = &[ "VERSA_BEDROCK_REGION", "SigV4 signing region; the endpoint, not the region, decides where a request goes", ), - ("BEDROCK_MAX_RETRIES", "retry policy"), - ("BEDROCK_INITIAL_RETRY_INTERVAL_MS", "retry policy"), - ("BEDROCK_BACKOFF_MULTIPLIER", "retry policy"), - ("BEDROCK_MAX_RETRY_INTERVAL_MS", "retry policy"), - ("BEDROCK_OPERATION_TIMEOUT_SECS", "transport timeout"), + // ⚠ These five are the `BEDROCK_*` keys the 2026-09-11 namespacing did NOT + // split, and the fact that they are still SHARED deserves saying rather + // than being inferred from their absence above. `versa_bedrock.rs` (Private) + // and `bedrock.rs` / `formats/bedrock.rs` (Public) all read the same five + // names, so one write tunes both cards at once. That is the exact shape of + // the cross-card bleed `VERSA_BEDROCK_ENDPOINT` and `VERSA_BEDROCK_REGION` + // were namespaced to end — so the reason these were left shared has to be + // a positive one, not an oversight. + // + // It is that they reach nothing a tier depends on. All four retry keys are + // read in one place, `load_retry_config`, and go into a `RetryConfig`; + // `BEDROCK_OPERATION_TIMEOUT_SECS` is read in `load_operation_timeout_secs` + // and becomes a deadline. None of them contributes to the resolved endpoint + // `tier()` asks about, and none of them takes part in signing or + // credentials. They decide how patiently a request is retried and how long + // it may take — not where it goes or who it claims to be. + ( + "BEDROCK_MAX_RETRIES", + "retry policy, shared with the public card", + ), + ( + "BEDROCK_INITIAL_RETRY_INTERVAL_MS", + "retry policy, shared with the public card", + ), + ( + "BEDROCK_BACKOFF_MULTIPLIER", + "retry policy, shared with the public card", + ), + ( + "BEDROCK_MAX_RETRY_INTERVAL_MS", + "retry policy, shared with the public card", + ), + ( + "BEDROCK_OPERATION_TIMEOUT_SECS", + "transport timeout, shared with the public card", + ), ]; /// The files whose `get_param` reads the scan covers: every provider file Task @@ -219,6 +281,21 @@ mod tests { // survives. assert!(CAPABILITY_CONFIG_KEYS.contains(&"BIOROUTER_PROVIDER")); assert_eq!(CAPABILITY_CONFIG_KEYS.len(), 5); + // The same, for the other half of `/agent/start`'s bind. Seeded into the + // NOT list by the 2026-09-12 review's Finding 2, which found it in + // neither — see its row for why the classification comes out that way. + assert!( + NOT_CAPABILITY_CONFIG_KEYS + .iter() + .any(|(key, _why)| *key == "BIOROUTER_MODEL"), + "BIOROUTER_MODEL is unclassified again: it is half of the bind /agent/start performs, \ + so leaving it out of both lists is how it went unreviewed the first time" + ); + assert!( + !is_capability_key("BIOROUTER_MODEL"), + "BIOROUTER_MODEL was made a capability key; no `tier()` reads the model name, so this \ + would make every model switch a user act without protecting a tier" + ); // …and the other way round: every classified key is still READ by a // tier-input file. Without this, a read that goes away leaves its row @@ -228,7 +305,13 @@ mod tests { .iter() .copied() .chain(NOT_CAPABILITY_CONFIG_KEYS.iter().map(|(key, _why)| *key)); - for key in classified.filter(|key| *key != "BIOROUTER_PROVIDER") { + // + // The two `config_value!` keys are excused, for the reason given above + // each of them: the scan reads `get_param("…")` literals, and neither + // literal exists in the source. + for key in + classified.filter(|key| !matches!(*key, "BIOROUTER_PROVIDER" | "BIOROUTER_MODEL")) + { assert!( scanned.iter().any(|read| read == key), "{key} is classified but no tier-input file reads it; delete its row" diff --git a/crates/biorouter/src/privacy/mod.rs b/crates/biorouter/src/privacy/mod.rs index 7ba404b44..ad0a64e0b 100644 --- a/crates/biorouter/src/privacy/mod.rs +++ b/crates/biorouter/src/privacy/mod.rs @@ -47,9 +47,9 @@ pub mod system_auth_windows; pub mod visibility; pub use affiliation::{CrossAffiliation, ExtensionAffiliation, ModelAffiliation}; -pub use alt_provider::assert_alt_provider_allowed; +pub use alt_provider::{assert_alt_provider_allowed, assert_alt_provider_matches_session}; pub use capability::CallCapability; -pub use config_keys::is_capability_key; +pub use config_keys::{is_capability_key, CAPABILITY_CONFIG_KEYS}; pub use extensions::{ classify_extension, classify_extension_entry, private_extension_ids, resolve_extension, ExtensionClassification, diff --git a/crates/biorouter/src/prompts/system.md b/crates/biorouter/src/prompts/system.md index b35c36470..7b388e08a 100644 --- a/crates/biorouter/src/prompts/system.md +++ b/crates/biorouter/src/prompts/system.md @@ -143,6 +143,17 @@ session-scoped tool state; do not imply that Extension Manager is the only such through them in order, and keep track of progress so nothing is dropped. When todo/plan tools are available, keep a living plan and a per-item checklist: update each item's status as you go (in progress → completed) rather than rewriting the whole list, and before yielding confirm every item is completed or say why not. +{% if checklist_enforcement %} +- Biorouter enforces the checklist when a request has several steps (a numbered or bulleted list of actions, three or + more instructions, or instructions joined by "then", "after that", "finally" or "steps"): + - While the checklist is empty, your first action is `todo__todo_write`, with one `- [ ]` item per step. The first + other tool call you make in that turn is refused with a pointer back to it. That refusal happens once per turn: if + you have a reason not to keep a checklist, say so and repeat the call, and it runs. + - As you work, mark each item `in_progress` when you start it and `completed` when it is done, with + `todo__todo_update`. + - A turn that created or changed the checklist cannot end while items are unfinished, unless your final message + names each unfinished item by its `#N` id and says why it is not done. Otherwise you are sent back to finish. +{% endif %} - Once you start a task, carry it through to completion before yielding. Don't stop half-done, and don't gold-plate beyond what was asked. - Before editing a file, read the relevant parts, and don't guess its contents. Don't fabricate file paths, APIs, or diff --git a/crates/biorouter/src/providers/claude_code.rs b/crates/biorouter/src/providers/claude_code.rs index 596f3fe3b..c411386df 100644 --- a/crates/biorouter/src/providers/claude_code.rs +++ b/crates/biorouter/src/providers/claude_code.rs @@ -134,6 +134,40 @@ pub const CLAUDE_CODE_DEFAULT_MODEL: &str = "claude-fable-5-1"; pub const CLAUDE_CODE_DOC_URL: &str = "https://code.claude.com/docs/en/headless"; +/// The sentence to append to a failed turn when the model name is the likely +/// cause. Codex's twin, `codex::unknown_model_hint`, and written to the same +/// rules; read that one for the reasoning about hinting on the failure path +/// rather than refusing before the call. +/// +/// The case for it is *stronger* here than next door, and the reason is recorded +/// at length in `known_models` above: `claude --model X -p` **accepts an unknown +/// id and merely warns** — +/// +/// "X" is not a model this version of Claude Code recognizes, so auto-compact +/// will keep this session within 200k tokens +/// +/// — so a typo neither fails loudly nor gets a pointer. When the turn does then +/// end badly, nothing in the message names the model, and the frame above it +/// invites a retry that cannot come true. Codex got this hint; the structurally +/// identical failures here did not. +/// +/// ⚠ **Only ever additive to text the vendor already produced.** It never +/// replaces a real explanation, and it is empty for a listed model, so a genuine +/// outage on a known id reads exactly as it did before. +fn unknown_model_hint(model: &str) -> String { + let known = known_models(); + if known.iter().any(|m| m.name == model) { + return String::new(); + } + let names: Vec<&str> = known.iter().map(|m| m.name.as_str()).collect(); + format!( + " — and `{model}` is not one of the models this build knows Claude Code to \ + offer ({}). `claude` accepts an unrecognized name with only a warning, so \ + a typo fails here rather than at the point it was made", + names.join(", ") + ) +} + /// Models advertised in the picker, with each id's measured context window. /// /// `ProviderMetadata::with_models` is used rather than `::new` because `::new` @@ -609,6 +643,7 @@ impl ClaudeCodeProvider { .or_else(|| Some(stderr.trim().to_string())) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "`claude` reported an error".into()); + let detail = format!("{detail}{}", unknown_model_hint(model)); let category = result .get("subtype") .and_then(Value::as_str) @@ -622,9 +657,10 @@ impl ClaudeCodeProvider { .unwrap_or_default() .to_string(); if text.trim().is_empty() { - return Err(ProviderError::RequestFailed( - "`claude` returned an empty response".into(), - )); + return Err(ProviderError::RequestFailed(format!( + "`claude` returned an empty response{}", + unknown_model_hint(model) + ))); } let usage = parse_usage(result.get("usage")); @@ -1267,7 +1303,7 @@ async fn pump_claude_stdout(inputs: PumpInputs) { // The authoritative usage (and any failure) goes last, so it is the // snapshot the agent keeps. - let terminal = resolve_terminal(terminal, stderr_task).await; + let terminal = resolve_terminal(terminal, stderr_task, &model_name).await; let _ = out_tx.send(terminal.map(|usage| (None, Some(usage), None))); } @@ -1279,6 +1315,7 @@ async fn pump_claude_stdout(inputs: PumpInputs) { async fn resolve_terminal( terminal: Option>, stderr_task: tokio::task::JoinHandle, + model: &str, ) -> Result { match terminal { Some(terminal) => terminal, @@ -1288,11 +1325,17 @@ async fn resolve_terminal( None => { let detail = stderr_task.await.unwrap_or_default(); let detail = detail.trim(); - Err(ProviderError::RequestFailed(if detail.is_empty() { + let base = if detail.is_empty() { "`claude` produced no result".to_string() } else { format!("`claude` produced no result: {detail}") - })) + }; + // The most anonymous failure this provider has — a child that said + // nothing at all — so it is the one that most needs the model named. + Err(ProviderError::RequestFailed(format!( + "{base}{}", + unknown_model_hint(model) + ))) } } } @@ -2133,6 +2176,77 @@ mod tests { assert_eq!(parse_usage(None).total_tokens, None); } + /// Codex's `a_failed_turn_names_an_unknown_model_and_the_ones_that_exist`, + /// for the provider that needs it more. `claude` accepts an unrecognized + /// `--model` with only a warning, so a typo produces a turn that fails with + /// nothing in it naming the model — and this provider is the only thing in + /// the stack that knows which names it believes Claude Code offers. + #[test] + fn a_failed_turn_names_an_unknown_model_and_the_ones_that_exist() { + let hint = unknown_model_hint("claude-opus-99"); + assert!( + hint.contains("claude-opus-99"), + "the hint must name the model that was asked for: {hint}" + ); + // ⚠ Assert against the parenthesised catalog only, never the whole hint. + // The hint embeds the id that was asked for, and a plausible typo shares + // a prefix with a real id — so a bare `hint.contains("claude-opus")` + // passes on a hint that lists no models at all. Codex's twin carries the + // same warning for the same reason. + let catalog = hint + .split_once('(') + .unwrap_or_else(|| panic!("the hint must carry a parenthesised catalog: {hint}")) + .1; + for expected in known_models().iter().map(|m| m.name.clone()) { + assert!( + catalog.contains(&expected), + "the fix has to be in the message: {expected} is missing from {hint}" + ); + } + } + + /// ⚠ And it must stay SILENT for a model that is known, or every unrelated + /// failure — a rate limit, a dropped connection — gains a paragraph about + /// model names and sends the reader after the wrong thing. + #[test] + fn a_known_model_adds_nothing_to_a_failure() { + for m in known_models() { + assert_eq!( + unknown_model_hint(&m.name), + "", + "{} is a declared model and must not be second-guessed", + m.name + ); + } + } + + /// The hint reaches the message a user actually sees. An empty answer from + /// the child is the most anonymous failure this provider has, and it named + /// nothing at all before. + #[test] + fn an_empty_answer_on_an_unknown_model_says_which_model() { + let lines = vec![ + r#"{"type":"result","subtype":"success","is_error":false,"result":"","usage":{}}"# + .to_string(), + ]; + let error = provider() + .parse_result_object("claude-opus-99", &lines, "", exit_ok()) + .expect_err("an empty answer is a failure"); + let text = error.to_string(); + assert!(text.contains("claude-opus-99"), "{text}"); + assert!(text.contains("only a warning"), "{text}"); + + // And a listed model's identical failure is untouched. + let known = provider() + .parse_result_object(CLAUDE_CODE_DEFAULT_MODEL, &lines, "", exit_ok()) + .expect_err("an empty answer is a failure") + .to_string(); + assert!( + known.ends_with("`claude` returned an empty response"), + "a known model's failure must read as it always did: {known}" + ); + } + /// A real captured `result` frame parses, and the usage row is attributed to /// this provider rather than left for the model name to decide. #[test] diff --git a/crates/biorouter/src/providers/coding_agent/bridge.rs b/crates/biorouter/src/providers/coding_agent/bridge.rs index 8ae34f5d6..b0ae9b035 100644 --- a/crates/biorouter/src/providers/coding_agent/bridge.rs +++ b/crates/biorouter/src/providers/coding_agent/bridge.rs @@ -705,10 +705,18 @@ impl BridgeGrant { let request = UserActionRequest::ToolApproval(ToolApprovalRequest { tool_name: call.name.to_string(), arguments: arguments.clone(), - prompt: Some(format!( - "{} asked to run this through Biorouter.", - self.child_label() - )), + // ⚠ **`prompt` is the inspector's field — "why you are being asked" — + // and nothing else may borrow it.** This used to carry " asked + // to run this through Biorouter", which is framing, not a finding; the + // card reads any prompt as a SECURITY FINDING, so every bridged call + // arrived under a warning banner with "Always Allow" withheld. The + // card was telling the truth about the field it was given. + // + // The attribution belongs on the card, but it needs a field of its + // own (`requestedBy`) plumbed through `ActionRequiredData` and the + // OpenAPI client. Until then it is logged rather than dressed up as + // an inspector's verdict. + prompt: None, risk: Some(self.tool_risks.risk_for(&call.name)), preview: crate::conversation::tool_preview::ToolPreview::for_tool_call( &call.name, &arguments, @@ -729,15 +737,19 @@ impl BridgeGrant { UserActionOutcome::Approved { permission } => { tracing::info!( tool_name = %name, + child = self.child_label(), ?permission, "a bridged tool call was approved by the user" ); + self.record_lasting_decision(&call.name, &permission).await; Ok(()) } // `AlwaysDeny` and `DenyOnce` read the same to the child: it may not // run this. The *scope* of the refusal is the permission store's - // business, not the child's. - UserActionOutcome::Denied { .. } => { + // business, not the child's — which is why the store is written + // before the refusal is worded. + UserActionOutcome::Denied { permission } => { + self.record_lasting_decision(&call.name, &permission).await; Err(format!("`{name}` was refused: you did not approve it.")) } other => Err(format!( @@ -749,6 +761,37 @@ impl BridgeGrant { } } + /// Write a decision the user meant to LAST into the permission store. + /// + /// The outcome's `permission` carries the scope of the answer — its own doc + /// says it "distinguishes a one-off from an `AlwaysAllow` the caller may want + /// to record" — and on the agent's own path `handle_approved_and_denied_tools` + /// records it. The bridge only logged it, so "Always Allow" on a bridged card + /// granted a single call and the next identical one asked again: a card + /// offering a lasting decision it could not keep. An existing entry was + /// always honoured (the permission inspector reads the store before the card + /// is ever raised); what was missing was writing one. + /// + /// A one-off (`AllowOnce` / `DenyOnce`) is deliberately not recorded: that is + /// an answer about this call, not a rule. + async fn record_lasting_decision( + &self, + tool_name: &str, + permission: &crate::permission::Permission, + ) { + use crate::config::permission::PermissionLevel; + use crate::permission::Permission; + + let level = match permission { + Permission::AlwaysAllow => PermissionLevel::AlwaysAllow, + Permission::AlwaysDeny => PermissionLevel::NeverAllow, + _ => return, + }; + self.inspections + .update_permission_manager(tool_name, level) + .await; + } + /// What to call the child on the approval card. /// /// The user is being asked to approve a call *they* did not make, so the card diff --git a/crates/biorouter/src/providers/mod.rs b/crates/biorouter/src/providers/mod.rs index 5e2a39b63..8a52ceea1 100644 --- a/crates/biorouter/src/providers/mod.rs +++ b/crates/biorouter/src/providers/mod.rs @@ -136,6 +136,26 @@ pub(crate) fn is_loopback_host(url: &str) -> bool { } } +/// The phrase a provider uses when a credential was **never set**, as distinct +/// from "the credential store refused to hand it over". The two need opposite +/// responses from the user — add the key, versus do NOT re-enter it and answer +/// the Keychain prompt — and `versa_bedrock::from_env` carries a comment saying +/// exactly that about its own two arms. +/// +/// ⚠ **It exists so the wording has ONE spelling.** The `anyhow::Error` that +/// leaves `from_env` has already discarded the `ConfigError` behind it, so a +/// caller further out — `biorouter-cli`'s `keyring_advice`, which decides whether +/// to print three lines about the system keychain — has nothing but the text to +/// go on. A literal repeated at both ends is a literal that drifts at one end; +/// the producers format with this constant and the consumer matches on it. +pub const CREDENTIAL_NEVER_SET: &str = "is not configured"; + +/// Whether `text` is a provider saying a credential was never set. See +/// [`CREDENTIAL_NEVER_SET`] for why this is a wording check and not a type one. +pub fn says_credential_never_set(text: &str) -> bool { + text.contains(CREDENTIAL_NEVER_SET) +} + /// The tier of a provider that reaches the UCSF gateway and nothing else. /// /// Demotion only, never promotion: each Versa provider's endpoint is diff --git a/crates/biorouter/src/providers/versa_azure.rs b/crates/biorouter/src/providers/versa_azure.rs index 7c681196d..95d119d3e 100644 --- a/crates/biorouter/src/providers/versa_azure.rs +++ b/crates/biorouter/src/providers/versa_azure.rs @@ -415,7 +415,9 @@ impl VersaAzureProvider { VersaAzureCredentialSource::ApiKey => { let key = config .get_secret::("VERSA_AZURE_API_KEY") - .map_err(|_| anyhow::anyhow!("VERSA_AZURE_API_KEY is not configured"))?; + .map_err(|_| { + anyhow::anyhow!("VERSA_AZURE_API_KEY {}", super::CREDENTIAL_NEVER_SET) + })?; anyhow::ensure!(!key.trim().is_empty(), "VERSA_AZURE_API_KEY is empty"); Some(key) } diff --git a/crates/biorouter/src/providers/versa_bedrock.rs b/crates/biorouter/src/providers/versa_bedrock.rs index 0a78d9c76..03f4b90d5 100644 --- a/crates/biorouter/src/providers/versa_bedrock.rs +++ b/crates/biorouter/src/providers/versa_bedrock.rs @@ -183,7 +183,8 @@ impl VersaBedrockProvider { match config.get_secret::(name) { Ok(value) => Ok(value), Err(crate::config::ConfigError::NotFound(_)) => Err(anyhow::anyhow!( - "{name} is not configured. Add it under Versa API Bedrock in Settings." + "{name} {}. Add it under Versa API Bedrock in Settings.", + super::CREDENTIAL_NEVER_SET )), Err(error) => Err(anyhow::anyhow!( "Could not read {name} from the credential store: {error}\n\n\ diff --git a/crates/biorouter/src/scheduler.rs b/crates/biorouter/src/scheduler.rs index 5f59cfd42..4c1ae16dd 100644 --- a/crates/biorouter/src/scheduler.rs +++ b/crates/biorouter/src/scheduler.rs @@ -1952,18 +1952,68 @@ impl Scheduler { /// window: reaching it means the run already finished (or that this process /// is not the one running it), and the caller must be told. pub async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> { + self.kill_running_job_inner(sched_id, None).await + } + + /// [`kill_running_job`](Self::kill_running_job), but it stops the run ONLY + /// while that run is still the one in `expected_session_id`. + /// + /// Issue #56. A stop of a scheduled run is gated on the chat the run is in + /// (`routes::session_reach::work_reach`), and the gate has to read that chat + /// out of the scheduler before it can decide — so between the decision and + /// the kill there is a gap, and a **schedule id is stable across runs while + /// `current_session_id` is not**. Run N in a public chat can therefore end + /// and run N+1 in a *private* chat begin inside that gap, and an unchecked + /// kill would land on the run nobody authorized. + /// + /// Passing the chat the caller was actually admitted to closes it: if the + /// run has changed under the decision, this refuses instead of stopping the + /// wrong one. `None` means "do not check" and is what the unchecked + /// [`kill_running_job`](Self::kill_running_job) passes. + pub async fn kill_running_job_in_session( + &self, + sched_id: &str, + expected_session_id: Option<&str>, + ) -> Result<(), SchedulerError> { + self.kill_running_job_inner(sched_id, Some(expected_session_id)) + .await + } + + /// ⚠ **`jobs` is acquired ONCE and held across the check AND the cancel.** + /// The two used to be separate acquisitions — the running check released the + /// lock and `running_tasks` was taken afterwards — which is the window the + /// doc on [`kill_running_job_in_session`](Self::kill_running_job_in_session) + /// describes. There is deliberately **no `.await` between the lock and the + /// `token.cancel()`**; adding one re-opens it and nothing in the type system + /// will say so. `running_tasks` is a std mutex taken inside the `jobs` + /// guard, which is the same order [`claim_run_slot`] and + /// [`Scheduler::run_now`] use, so the nesting cannot deadlock. + async fn kill_running_job_inner( + &self, + sched_id: &str, + expected_session_id: Option>, + ) -> Result<(), SchedulerError> { self.sync_if_unknown(sched_id).await; - { - let jobs_guard = self.jobs.lock().await; - match jobs_guard.get(sched_id) { - Some((_, job)) if !job.currently_running => { - return Err(SchedulerError::AnyhowError(anyhow!( - "Schedule '{}' is not running", - sched_id - ))); + let jobs_guard = self.jobs.lock().await; + match jobs_guard.get(sched_id) { + None => return Err(SchedulerError::JobNotFound(sched_id.to_string())), + Some((_, job)) if !job.currently_running => { + return Err(SchedulerError::AnyhowError(anyhow!( + "Schedule '{}' is not running", + sched_id + ))); + } + Some((_, job)) => { + if let Some(expected) = expected_session_id { + if job.current_session_id.as_deref() != expected { + return Err(SchedulerError::AnyhowError(anyhow!( + "Schedule '{}' is no longer running the run this request was \ + authorized against: it has started a new run since. Nothing was \ + cancelled.", + sched_id + ))); + } } - None => return Err(SchedulerError::JobNotFound(sched_id.to_string())), - _ => {} } } @@ -1980,6 +2030,7 @@ impl Scheduler { None => false, } }; + drop(jobs_guard); if !cancelled { return Err(SchedulerError::AnyhowError(anyhow!( @@ -2505,6 +2556,15 @@ impl SchedulerTrait for Scheduler { self.kill_running_job(sched_id).await } + async fn kill_running_job_in_session( + &self, + sched_id: &str, + expected_session_id: Option<&str>, + ) -> Result<(), SchedulerError> { + self.kill_running_job_in_session(sched_id, expected_session_id) + .await + } + async fn get_running_job_info( &self, sched_id: &str, diff --git a/crates/biorouter/src/scheduler_trait.rs b/crates/biorouter/src/scheduler_trait.rs index 04aca04bb..5dd9b7408 100644 --- a/crates/biorouter/src/scheduler_trait.rs +++ b/crates/biorouter/src/scheduler_trait.rs @@ -38,6 +38,19 @@ pub trait SchedulerTrait: Send + Sync { async fn update_schedule(&self, sched_id: &str, new_cron: String) -> Result<(), SchedulerError>; async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError>; + /// Stop a run ONLY while it is still the run in `expected_session_id`. + /// + /// Issue #56. A stop is gated on the chat the run is in, and resolving that + /// chat is a separate read from the kill — so a schedule (whose id is stable + /// across runs) can start a *different* run, in a different chat, in the + /// gap. Callers that gated pass the chat they were admitted to; `None` means + /// the run names no chat. Implementors MUST refuse rather than stop a run + /// that no longer matches. + async fn kill_running_job_in_session( + &self, + sched_id: &str, + expected_session_id: Option<&str>, + ) -> Result<(), SchedulerError>; async fn get_running_job_info( &self, sched_id: &str, diff --git a/crates/biorouter/src/session/session_manager.rs b/crates/biorouter/src/session/session_manager.rs index bbe83fce6..17581f66d 100644 --- a/crates/biorouter/src/session/session_manager.rs +++ b/crates/biorouter/src/session/session_manager.rs @@ -4912,54 +4912,19 @@ impl SessionStorage { ) } - /// Issue #56 §15 — the classification backfill, from every provenance the - /// database actually records. - /// - /// ⚠ **It belongs to the numbered migration arms and to the fresh-database - /// import, and to nothing else.** [`Self::ensure_privacy_schema`] runs on - /// **every** startup, and that remains the wrong home even now that the - /// statements are declassification-guarded: the guard makes a re-run - /// *non-destructive*, it does not make a per-launch re-scan of every row a - /// thing this code should do, and one guard standing between a per-startup - /// statement and the user's declassifications is one mechanism too few. - /// `the_backfill_runs_from_the_migration_arms_and_the_import_and_nowhere_else` - /// pins the call sites. - /// - /// **Two evidence sources, in this order.** - /// - /// 1. [`Self::backfill_update_sql`] — the row's bound provider. What issue - /// #56 shipped, unchanged apart from the declassification guard. - /// 2. [`Self::backfill_turn_history_update_sql`] — the `token_events` turn - /// ledger. This is finding 9's fix: `provider_name` is the LAST binding, - /// so a chat that ran on Ollama and was later switched to Claude read - /// `anthropic` and backfilled public with a private transcript. The ledger - /// still holds its Ollama turns. See that function for why those rows are - /// stamped `turn:` rather than `backfill:`. - /// - /// The order is load-bearing for the counts, not for the outcome: the second - /// statement's `AND privacy_tier = 'public'` means it can only see rows the - /// first left alone, which is what makes the two counts disjoint. + /// Bring the shapes the backfill reads up to date, and say whether the turn + /// ledger can be read at all. /// - /// Still fails OPEN where it has nothing (DR-10). A fail-CLOSED backfill - /// (NULL provider plus at least one message ⇒ private) was rejected: a user - /// who has only ever used a commercial provider would find a large slice of - /// their history marked private on first launch, refused on the model they - /// normally use, with only an irreversible declassification as the exit, one - /// chat at a time. + /// `Ok(None)` means the backfill must be **skipped**: this database's + /// `sessions` has no `provider_name`, so no row's tier can be inferred. + /// `Ok(Some(turn_ledger))` means it may run, with `turn_ledger` saying whether + /// the second evidence source is usable. /// - /// The residual, narrower than it was but still real: a session whose private - /// turns predate `token_events.provider` (migration 11) and which was later - /// rebound to a public provider records the private work in neither column, - /// and backfills public. There is no transcript scan and there will not be - /// one. `docs/security/privacy-tiers-migration.md` says this to the user. - /// - /// `AND privacy_tier = 'public'` is not redundant: a database that reached an - /// arm with the columns already present (BR-71's number collision is exactly - /// that case) can hold rows a running build already raised, and the ratchet - /// must never be walked backwards or re-stamped with a weaker provenance. - async fn backfill_privacy_from_recorded_provenance( - pool: &Pool, - ) -> Result { + /// Split out of [`Self::backfill_privacy_from_recorded_provenance`] so that + /// function stays under `clippy::too_many_lines`. Every guard here exists + /// because this arm must not assume an earlier arm ran — read the comments + /// before removing one; each is a failed startup that happened. + async fn prepare_backfill_shape(pool: &Pool) -> Result> { // Shape-guarded for the same reason `ensure_privacy_schema` is: this arm // must not assume an earlier arm ran. `provider_name` arrives in // migration 6, so every database that walked the ladder has it — but a @@ -4974,7 +4939,7 @@ impl SessionStorage { "issue #56: skipping the privacy backfill; this database's `sessions` \ table has no `provider_name`, so no row's tier can be inferred" ); - return Ok(BackfillCounts::default()); + return Ok(None); } // ⚠ The declassification guard reads `classification_audit`, so BOTH @@ -5025,6 +4990,60 @@ impl SessionStorage { only each row's bound provider, so chats that switched providers may stay public" ); } + Ok(Some(turn_ledger)) + } + + /// Issue #56 §15 — the classification backfill, from every provenance the + /// database actually records. + /// + /// ⚠ **It belongs to the numbered migration arms and to the fresh-database + /// import, and to nothing else.** [`Self::ensure_privacy_schema`] runs on + /// **every** startup, and that remains the wrong home even now that the + /// statements are declassification-guarded: the guard makes a re-run + /// *non-destructive*, it does not make a per-launch re-scan of every row a + /// thing this code should do, and one guard standing between a per-startup + /// statement and the user's declassifications is one mechanism too few. + /// `the_backfill_runs_from_the_migration_arms_and_the_import_and_nowhere_else` + /// pins the call sites. + /// + /// **Two evidence sources, in this order.** + /// + /// 1. [`Self::backfill_update_sql`] — the row's bound provider. What issue + /// #56 shipped, unchanged apart from the declassification guard. + /// 2. [`Self::backfill_turn_history_update_sql`] — the `token_events` turn + /// ledger. This is finding 9's fix: `provider_name` is the LAST binding, + /// so a chat that ran on Ollama and was later switched to Claude read + /// `anthropic` and backfilled public with a private transcript. The ledger + /// still holds its Ollama turns. See that function for why those rows are + /// stamped `turn:` rather than `backfill:`. + /// + /// The order is load-bearing for the counts, not for the outcome: the second + /// statement's `AND privacy_tier = 'public'` means it can only see rows the + /// first left alone, which is what makes the two counts disjoint. + /// + /// Still fails OPEN where it has nothing (DR-10). A fail-CLOSED backfill + /// (NULL provider plus at least one message ⇒ private) was rejected: a user + /// who has only ever used a commercial provider would find a large slice of + /// their history marked private on first launch, refused on the model they + /// normally use, with only an irreversible declassification as the exit, one + /// chat at a time. + /// + /// The residual, narrower than it was but still real: a session whose private + /// turns predate `token_events.provider` (migration 11) and which was later + /// rebound to a public provider records the private work in neither column, + /// and backfills public. There is no transcript scan and there will not be + /// one. `docs/security/privacy-tiers-migration.md` says this to the user. + /// + /// `AND privacy_tier = 'public'` is not redundant: a database that reached an + /// arm with the columns already present (BR-71's number collision is exactly + /// that case) can hold rows a running build already raised, and the ratchet + /// must never be walked backwards or re-stamped with a weaker provenance. + async fn backfill_privacy_from_recorded_provenance( + pool: &Pool, + ) -> Result { + let Some(turn_ledger) = Self::prepare_backfill_shape(pool).await? else { + return Ok(BackfillCounts::default()); + }; let private = sqlx::query(&Self::backfill_update_sql()) .execute(pool) diff --git a/crates/biorouter/tests/agent.rs b/crates/biorouter/tests/agent.rs index 184da441a..cca7a9757 100644 --- a/crates/biorouter/tests/agent.rs +++ b/crates/biorouter/tests/agent.rs @@ -164,6 +164,14 @@ mod tests { Ok(()) } + async fn kill_running_job_in_session( + &self, + _sched_id: &str, + _expected_session_id: Option<&str>, + ) -> Result<(), SchedulerError> { + Ok(()) + } + async fn get_running_job_info( &self, _sched_id: &str, diff --git a/crates/biorouter/tests/privacy_guard_wiring.rs b/crates/biorouter/tests/privacy_guard_wiring.rs index d6f735666..3aaaa3610 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -164,8 +164,18 @@ const REGISTRY: &[Guard] = &[ ident: "may_read", defined_in: VISIBILITY, decides: "READ ⇔ VIS: whether a caller of tier C may read a session classified T", - status: Status::WiredThrough("refuse_unless_readable"), + // It was `WiredThrough("refuse_unless_readable")` until `biorouter web` became + // its first caller outside this file; the in-file row below still holds. + status: Status::Wired, sites: &[ + Site { + file: "crates/biorouter-cli/src/commands/web.rs", + counts: c(1, 0, 1), + kind: SiteKind::Guard, + what: "`refuse_turn_unless_reachable`, the gate on `biorouter web`'s WebSocket: \ + a message there runs a turn in whichever chat it names, so the page must \ + be able to read that chat. Plus its import", + }, Site { file: "crates/biorouter-mcp/src/memory/mod.rs", counts: c(2, 0, 0), @@ -238,12 +248,21 @@ const REGISTRY: &[Guard] = &[ spawned, read everything else — is retired: an agent may inject into any \ conversation, and the tier is the only boundary", status: Status::Wired, - sites: &[Site { - file: "crates/biorouter/src/agents/workspace_extension.rs", - counts: c(1, 0, 0), - kind: SiteKind::Guard, - what: "the shared writable adapter used by send_prompt, set_tools and close", - }], + sites: &[ + Site { + file: "crates/biorouter-cli/src/commands/web.rs", + counts: c(1, 0, 1), + kind: SiteKind::Guard, + what: "`refuse_turn_unless_reachable`, the write half: a `biorouter web` message \ + is written into the chat it names. Plus its import", + }, + Site { + file: "crates/biorouter/src/agents/workspace_extension.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "the shared writable adapter used by send_prompt, set_tools and close", + }, + ], }, Guard { ident: "requires_first_crossing_approval", @@ -316,26 +335,51 @@ const REGISTRY: &[Guard] = &[ // line. Counted honestly rather than folded into the call, so that a // handler which imported the module and never called the gate would // read as refs-only and stand out. + Site { + file: "crates/biorouter-server/src/routes/active_work.rs", + counts: c(0, 3, 0), + kind: SiteKind::Unrelated, + what: "the MODULE qualifier on `session_reach::HttpCaller`, `http_caller` and \ + `work_reach`. `GET /active_work` filters through `lists_work` and its \ + cancel asks `work_reach`, which calls this function for the work's chat; \ + neither route calls it directly, which is what refs-only records here", + }, Site { file: "crates/biorouter-server/src/routes/agent.rs", - counts: c(5, 5, 0), - kind: SiteKind::Guard, - what: "`POST /agent/resume`, `POST /agent/update_from_session`, `POST \ - /agent/update_working_dir` and `GET /agent/callable_tool_count`, plus \ - the shared `authorize_agent_control` 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. \ - ⚠ The count went 4 → 5 on 2026-09-12, and the justification is a \ - ROUTE that had no gate at all rather than a second gate on a guarded \ - one: an SD-8 review of #260 found `callable_tool_count` answering any \ - caller that could name a chat, through `get_or_create_agent`, which \ - CREATES an agent for a session that has none — so the fix is the same \ - reach gate, placed before the fetch for the reason \ - `agent_add_extension` states at its own", + counts: c(6, 6, 0), + 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 — and, \ + since QA's 2026-09-10 M2, `GET /agent/tools` (a private chat's \ + private-extension tool names, handed to a secret-only caller while \ + `add_extension` on the same chat refused) and `GET \ + /agent/callable_tool_count`, both of which mint an agent for the chat \ + they name. \ + ⚠ The count went 4 → 6, and BOTH justifications are routes that had no \ + gate at all rather than second gates on guarded ones: `/agent/tools` \ + answered any caller that could name a chat, and the SD-8 review of #260 \ + found `callable_tool_count` doing the same through `get_or_create_agent`, \ + which CREATES an agent for a session that has none — so each fix is the \ + same reach gate, placed before the fetch for the reason \ + `agent_add_extension` states at its own. \ + On a daemon that holds no user-action key `authorize_agent_control` 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/knowledge.rs", + counts: c(1, 6, 0), + kind: SiteKind::Guard, + what: "`POST /knowledge/bases/{id}/ingest-conversation`, one call inside the \ + loop over the chats the request names, before any transcript is read: \ + the route streams what a model makes of those chats back to its caller, \ + and a caller holding only the daemon secret could name a private model \ + (QA 2026-09-10 H2). The other five refs are the MODULE qualifier on \ + `session_reach::gate_knowledge_base`, `http_caller` (three handlers) and \ + `HttpCaller` — names that live beside the gate, not the gate", }, Site { file: "crates/biorouter-server/src/routes/mod.rs", @@ -356,13 +400,50 @@ const REGISTRY: &[Guard] = &[ `/interrupt` keeps the user-action proof on every daemon and reaches no \ reach gate at all", }, + Site { + file: "crates/biorouter-server/src/routes/schedule.rs", + counts: c(0, 5, 0), + kind: SiteKind::Unrelated, + what: "the MODULE qualifier five times, on no occasion this function. Once on \ + `session_reach::http_caller`, which filters `GET /schedule/{id}/sessions` \ + — a listing, gated by `lists_session`. Once on \ + `session_reach::work_reach` for `POST /schedule/{id}/kill`: the stop \ + resolves the run to its chat and asks THAT function, exactly as `POST \ + /active_work/{id}/cancel` does for the same kill, so neither route is \ + the easier way to stop a private chat's run. Once more on the same \ + function for `GET /schedule/{id}/inspect`, which hands back the chat a \ + run is in. The last two are `GET /schedule/list`'s redaction: the \ + qualifier on `http_caller`, and on the `HttpCaller` TYPE in \ + `redact_unreachable_chats`'s signature — a type, not a decision", + }, Site { file: "crates/biorouter-server/src/routes/session.rs", - counts: c(2, 2, 0), + counts: c(8, 10, 0), kind: SiteKind::Guard, what: "`GET /sessions/{id}` (the transcript) and `GET /sessions/{id}/export` \ - (the same transcript, `to_string_pretty`); the export sibling was \ - ungated until this sweep", + (the same transcript, `to_string_pretty`), and — QA 2026-09-10 F0 and \ + the sweep it asked for — every other route that names a chat: `DELETE \ + /sessions/{id}` (measured deleting a private chat the read refused, four \ + of four), `PUT …/name`, `PUT …/user_workflow_values`, the in-place arm \ + of `POST …/edit_message` (it truncates), `GET …/extensions` and `GET \ + …/usage`. Ten refs: the module qualifier on each of the eight calls, \ + and on `http_caller` for the two listings", + }, + Site { + file: "crates/biorouter-server/src/routes/skills.rs", + counts: c(1, 1, 0), + kind: SiteKind::Guard, + what: "`POST /skills/session`, which writes a skill's instructions into the \ + named chat's next turn (QA 2026-09-10, F0's sweep)", + }, + Site { + file: "crates/biorouter-server/src/routes/workflow.rs", + counts: c(1, 2, 0), + kind: SiteKind::Guard, + what: "`POST /workflows/create`, which loads the named chat's whole transcript \ + and returns a workflow a model wrote from it (QA 2026-09-10, F0's \ + sweep). The second ref is the module qualifier on `http_caller`, which \ + filters the knowledge bases the workflow names", }, Site { file: "crates/biorouter-server/src/routes/session_events.rs", @@ -398,10 +479,12 @@ const REGISTRY: &[Guard] = &[ }, Site { file: SESSION_REACH, - counts: c(2, 0, 0), + counts: c(3, 0, 0), kind: SiteKind::Guard, what: "`gate_knowledge_active`, whose GET query and POST body branches each \ - invoke the same reach gate", + invoke the same reach gate; and `work_reach`, which asks it for the chat a \ + piece of running work belongs to, so stopping a chat's work is gated by \ + the very call that gates reading it", }, ], }, @@ -429,10 +512,209 @@ const REGISTRY: &[Guard] = &[ status: Status::WiredThrough("session_reach"), sites: &[Site { file: SESSION_REACH, - counts: c(1, 0, 0), + counts: c(4, 0, 0), kind: SiteKind::Guard, what: "`session_reach` itself, which is this predicate plus the two lookups that \ - feed it", + feed it; and since QA's 2026-09-10 sweep `HttpCaller::admits` (a listing is \ + the rows this decision admits, one at a time — the one spelling that both \ + `lists_session` and, for running work, `lists_work` are) and \ + `HttpCaller::reach_knowledge_base` (the same decision with a knowledge base \ + as the target); and `work_reach`'s arm for running work that names no chat, \ + whose target is `Unreadable` because there is no chat to resolve. ONE \ + decision, four subjects: a second spelling of it is what this census exists \ + to stop", + }], + }, + Guard { + ident: "http_caller", + defined_in: SESSION_REACH, + decides: "who is asking, resolved ONCE per request: the stated capability, the \ + user-action proof, DR-15's switch, and — on a serve daemon only — the \ + operator's tier for a request carrying the served document's cookie", + status: Status::Wired, + sites: &[ + Site { + file: "crates/biorouter-server/src/routes/active_work.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`GET /active_work`, every running shell command and subagent prompt on \ + the machine, each row shown by the chat it belongs to — resolved once for \ + the whole list", + }, + Site { + file: "crates/biorouter-server/src/routes/knowledge.rs", + counts: c(3, 0, 0), + kind: SiteKind::Guard, + what: "`GET /knowledge/bases` (a private base omitted from a caller that \ + cannot open it) and both halves of `/knowledge/active` (the selection \ + filtered, and a write unable to move what its caller cannot see)", + }, + Site { + file: "crates/biorouter-server/src/routes/schedule.rs", + counts: c(2, 0, 0), + kind: SiteKind::Guard, + what: "`GET /schedule/{id}/sessions`, a schedule's runs by name and directory; \ + and `GET /schedule/list`, which resolves the caller ONCE for the whole \ + listing and then redacts each row's chat-naming fields", + }, + Site { + file: "crates/biorouter-server/src/routes/session.rs", + counts: c(2, 0, 0), + kind: SiteKind::Guard, + what: "`GET /sessions` and `GET /sessions/sidebar` — QA 2026-09-10 M1, every \ + chat on the machine, titled, to a secret-only caller", + }, + Site { + file: SESSION_REACH, + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`gate_knowledge_base`, the layer on every `/knowledge/bases/{id}` route", + }, + Site { + file: "crates/biorouter-server/src/routes/workflow.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`POST /workflows/create`, whose workflow names the chat's knowledge \ + bases — filtered to the ones its caller may open", + }, + ], + }, + Guard { + ident: "lists_session", + defined_in: SESSION_REACH, + decides: "whether a listing may show a caller a chat of a given classification: \ + exactly the singular gate's answer for that row, so omission and never \ + redaction", + status: Status::Wired, + sites: &[ + Site { + file: "crates/biorouter-server/src/routes/schedule.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`GET /schedule/{id}/sessions`, filtered BEFORE its limit", + }, + Site { + file: "crates/biorouter-server/src/routes/session.rs", + counts: c(3, 0, 0), + kind: SiteKind::Guard, + what: "`GET /sessions`, and `GET /sessions/sidebar` twice: once to take the \ + one-query fast path for a caller shown every row, once per row of the \ + scan that pages a filtered view without ragged pages or a count oracle", + }, + ], + }, + Guard { + ident: "lists_work", + defined_in: SESSION_REACH, + decides: "whether a listing of RUNNING WORK may show a caller a row belonging to a given \ + chat — or to none. The chat is resolved metadata-only, and a row naming no \ + chat, or one that cannot be read, is answered as a private chat's row: its \ + command came from some chat and nothing says whose", + status: Status::Wired, + sites: &[ + Site { + file: "crates/biorouter-server/src/routes/active_work.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`visible_items`, which `GET /active_work` passes every row through — \ + background jobs, foreground commands, subagents, detached turns and scheduled \ + runs alike — after one `http_caller` for the whole list", + }, + Site { + file: "crates/biorouter-server/src/routes/schedule.rs", + counts: c(2, 0, 0), + kind: SiteKind::Guard, + what: "`redact_unreachable_chats`, twice: `GET /schedule/list` asks once for a \ + row's `current_session_id` and once for its `creator_session_id`, \ + because the two can name DIFFERENT chats. ⚠ Reusing this predicate \ + rather than writing a `may_name_chat` beside it is the point — 'may this \ + caller be told this chat exists' must have ONE spelling, and a second \ + one is the drift this census exists to catch. What differs is only what \ + a `false` does: `/active_work` drops the row, this drops the field, \ + because a schedule is not a chat and an idle one names none", + }, + ], + }, + Guard { + ident: "work_reach", + defined_in: SESSION_REACH, + decides: "whether an HTTP caller naming RUNNING WORK by its own handle may stop it: the \ + work's chat through `session_reach` itself, and work that names no chat — or a \ + handle that names nothing — as an unreadable target, refused in the same words", + status: Status::Wired, + sites: &[ + Site { + file: "crates/biorouter-server/src/routes/active_work.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`POST /active_work/{id}/cancel`, after the id is resolved to its chat \ + (the registry entry's, or the running schedule's) and before the \ + registry's cancel action or the scheduler's kill. It stopped any chat's \ + work for a caller holding only the daemon secret", + }, + Site { + file: "crates/biorouter-server/src/routes/schedule.rs", + counts: c(2, 0, 0), + kind: SiteKind::Guard, + what: "`POST /schedule/{id}/kill`, which stops the SAME run as the cancel route \ + above, reached by the schedule id instead of the work handle. ⚠ This \ + second call site is not redundancy: while it was missing, the gate on \ + the row above protected nothing for its `sched:` arm, because a caller \ + refused there re-issued the request one URL over and stopped the run \ + anyway. Both now resolve the run to its chat first, and both pass that \ + chat to `kill_running_job_in_session` so a run that changed under the \ + decision is refused rather than stopped. The second call is `GET \ + /schedule/{id}/inspect`, which answered any secret-holder with the chat \ + a schedule is running in — the association the listing beside it \ + redacts, handed over whole one route away", + }, + ], + }, + Guard { + ident: "reach_knowledge_base", + defined_in: SESSION_REACH, + decides: "whether an HTTP caller naming a knowledge base may reach it: the chat gate's \ + decision with the base's tier as the target, an absent or malformed id \ + answered as a private one", + status: Status::Wired, + sites: &[ + Site { + file: "crates/biorouter-server/src/routes/knowledge.rs", + counts: c(4, 0, 0), + kind: SiteKind::Guard, + what: "the bases listing's filter, the selection response's filter, and \ + `POST /knowledge/active`'s two uses: the refusal for pinning a base the \ + caller cannot reach, and the predicate `set_selection_within` merges by", + }, + Site { + file: SESSION_REACH, + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`gate_knowledge_base`, which asks it for every `/knowledge/bases/{id}` \ + route before the handler runs", + }, + Site { + file: "crates/biorouter-server/src/routes/workflow.rs", + counts: c(2, 0, 0), + kind: SiteKind::Guard, + what: "`POST /workflows/create`: a generated workflow's visible bases, and its \ + default one", + }, + ], + }, + Guard { + ident: "gate_knowledge_base", + defined_in: SESSION_REACH, + decides: "the knowledge-base reach gate, as ONE route layer on the sub-router holding \ + exactly the routes that name a base by `{id}`", + status: Status::Wired, + sites: &[Site { + file: "crates/biorouter-server/src/routes/knowledge.rs", + counts: c(0, 1, 0), + kind: SiteKind::Guard, + what: "`route_layer(from_fn_with_state(svc, session_reach::gate_knowledge_base))` \ + in `knowledge::router`. A REFERENCE, as `gate_knowledge_active`'s is: a \ + middleware never appears with parentheses", }], }, Guard { @@ -442,10 +724,12 @@ const REGISTRY: &[Guard] = &[ status: Status::WiredThrough("session_reach"), sites: &[Site { file: SESSION_REACH, - counts: c(1, 0, 0), + counts: c(2, 0, 0), kind: SiteKind::Guard, what: "`session_reach`'s tier lookup, deliberately `with_messages: false` so \ - resolving a tier is never the way to load the transcript being refused", + resolving a tier is never the way to load the transcript being refused; and \ + `HttpCaller::lists_work`'s, for a row of running work that names a chat — \ + the same lookup, so an unreadable chat fails closed there too", }], }, // ----------------------------------------------------- extension tiering @@ -596,6 +880,39 @@ const REGISTRY: &[Guard] = &[ }, ], }, + Guard { + ident: "assert_alt_provider_matches_session", + defined_in: "crates/biorouter/src/privacy/alt_provider.rs", + decides: "Gate H's RATCHETING half: whether a provider named for a knowledge ingest may \ + differ in tier from the session whose content it will fold into a base", + status: Status::Wired, + sites: &[ + Site { + file: "crates/biorouter/src/agents/knowledge_tool.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`build_model_ref_provider`, the ONE Gate H site both knowledge paths \ + share — `platform__ingest_source`'s `model` argument (a provider name \ + the MODEL wrote) and a base's stored `default_model` on a scheduled \ + digest. Its laxer sibling `assert_alt_provider_allowed` is what used to \ + be here, and it refuses only the DOWNWARD choice: the tier that comes \ + back becomes `caller_capability`, crosses to `caller_is_private` and \ + lands in `knowledge::tier::raise_unlocked`, a permanent monotone \ + ratchet — so the upward choice Gate A calls harmless let a PUBLIC chat \ + privatise its own base for good and then be refused at every KB read \ + choke point. If this row ever reads ZERO calls, that hole is open again \ + and no behavioural test elsewhere will say so, because the sibling \ + predicate is still correct for the two paths that keep it", + }, + Site { + file: "crates/biorouter/src/privacy/mod.rs", + counts: c(0, 0, 1), + kind: SiteKind::Guard, + what: "the `pub use` beside its sibling's, so `crate::privacy::` is the one \ + path both halves of Gate H are called through", + }, + ], + }, Guard { ident: "bind_allowed", defined_in: "crates/biorouter/src/privacy/mod.rs", @@ -666,20 +983,37 @@ const REGISTRY: &[Guard] = &[ decides: "Gate A's rule PLUS DR-16's, for the surface a MODEL asks the bind on: it may \ LOWER a conversation's capability, never RAISE it", status: Status::Wired, - sites: &[Site { - file: "crates/biorouter/src/agents/workspace_extension.rs", - counts: c(1, 0, 0), - kind: SiteKind::Guard, - what: "`workspace_set_tools`' pre-flight, beside its `bind_allowed` sibling and \ - after it, so the more specific refusal owns the downward case. This is the \ - predicate's ONLY caller by design and not by accident: `bind_allowed` \ - permits every bind onto a public conversation (Gate A refuses only the \ - downward one), which let a public-tier model hand any conversation it \ - could write to a private provider — Private capability, and a permanent \ - ratchet of that conversation's stored `privacy_tier` on its next turn. If \ - this row ever reads ZERO calls, that hole is open again and no behavioural \ - test elsewhere will say so, because the predicate itself is still correct", - }], + sites: &[ + Site { + file: "crates/biorouter/src/agents/workspace_extension.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`workspace_set_tools`' pre-flight, beside its `bind_allowed` sibling and \ + after it, so the more specific refusal owns the downward case. \ + `bind_allowed` permits every bind onto a public conversation (Gate A \ + refuses only the downward one), which let a public-tier model hand any \ + conversation it could write to a private provider — Private capability, \ + and a permanent ratchet of that conversation's stored `privacy_tier` on \ + its next turn. If this row ever reads ZERO calls, that hole is open \ + again and no behavioural test elsewhere will say so, because the \ + predicate itself is still correct", + }, + Site { + file: "crates/biorouter/src/privacy/alt_provider.rs", + counts: c(1, 0, 1), + kind: SiteKind::Guard, + what: "`assert_alt_provider_matches_session`, Gate H's ratcheting half, plus \ + its import. ⚠ This row is why the sibling row above no longer claims to \ + be this predicate's only caller: DR-16's rule now has TWO model-facing \ + surfaces, a bind (`workspace_set_tools`' provider switch) and a \ + construction that binds nothing (a knowledge ingest's `model` argument, \ + whose tier ratchets a knowledge base instead of a session). Both CALL \ + this predicate rather than re-spelling `is_private() == is_private()`, \ + so the two can never answer the raise cell differently. A new site that \ + inlines the comparison would satisfy every behavioural test and be \ + invisible here, which is exactly the drift this census exists to see", + }, + ], }, Guard { ident: "privacy_refusal", diff --git a/crates/biorouter/tests/privacy_toggle.rs b/crates/biorouter/tests/privacy_toggle.rs index 1b5ee82d4..4fe9e5aa3 100644 --- a/crates/biorouter/tests/privacy_toggle.rs +++ b/crates/biorouter/tests/privacy_toggle.rs @@ -709,6 +709,19 @@ async fn the_master_toggle_governs_every_gate_in_both_directions() { "BIOROUTER_PLANNER_PROVIDER", ) .is_err()); + // 10b H's RATCHETING half — the same gate on the path where the chosen + // provider's tier does not stop in this process but becomes a knowledge + // base's permanent classification. It has its OWN toggle read (it must: + // it refuses a cell the sibling allows), so the sibling's assertion above + // says nothing about it, and the cell tested here is exactly the one they + // disagree on — a PRIVATE provider named by a PUBLIC chat. + assert!(biorouter::privacy::assert_alt_provider_matches_session( + "ingesting these sources", + private_provider().as_ref(), + SessionClassification::Public, + "this tool's `model` argument", + ) + .is_err()); // 13 spawn (Task 23) — the spawn matrix's THREE decisions, all hanging off // one toggle read inside `apply_settings_overrides`. @@ -864,6 +877,16 @@ async fn the_master_toggle_governs_every_gate_in_both_directions() { "BIOROUTER_PLANNER_PROVIDER", ) .is_ok()); + // 10b: the ratcheting half goes quiet too. Its early return is its own line + // of code, so a gate that read the switch once for the pair — or not at + // all on this half — would still pass the assertion above it. + assert!(biorouter::privacy::assert_alt_provider_matches_session( + "ingesting these sources", + private_provider().as_ref(), + SessionClassification::Public, + "this tool's `model` argument", + ) + .is_ok()); // 13 spawn: all three decisions go quiet, and the third is the one a flipped // `partition` sense would hide — the child now inherits the private // extension instead of losing it. diff --git a/docs/agent-loop/designs/br71-execution-plan.md b/docs/agent-loop/designs/br71-execution-plan.md index b1a73f3f2..43a979ce3 100644 --- a/docs/agent-loop/designs/br71-execution-plan.md +++ b/docs/agent-loop/designs/br71-execution-plan.md @@ -1490,7 +1490,7 @@ with no error anywhere. ```bash cargo test -p biorouter --lib session::session_manager -cargo test -p biorouter --lib agents::knowledge_tool knowledge::conversation_ingest +cargo test -p biorouter --lib -- agents::knowledge_tool knowledge::conversation_ingest ``` Expected: **PASS**, including the pre-existing migration tests. Two of them name a @@ -3705,7 +3705,7 @@ does need adding is `async-trait`, in Task 9; see there.) - [ ] **Step 5: Run tests** -Run: `cargo test -p biorouter-server --lib workspace::turn state::tests::turn_guard_exposes_its_turn_id` +Run: `cargo test -p biorouter-server --lib -- workspace::turn state::tests::turn_guard_exposes_its_turn_id` Expected: `test result: ok. 6 passed` (three lifecycle tests, the seed-is-not-a-write test, the abort classifier, and the `TurnGuard::turn_id` accessor). @@ -5235,7 +5235,7 @@ just the new file) ```bash cargo test -p biorouter-server --lib routes::reply -cargo test -p biorouter-server --lib routes::session_events workspace:: +cargo test -p biorouter-server --lib -- routes::session_events workspace:: cargo test -p biorouter-server --lib # every server unit test cargo test -p biorouter --lib agents::agent # the agent side of the turn contract @@ -11547,7 +11547,7 @@ for Task 24's `workspace_open` line. - [ ] **Step 6: Run tests** -Run: `cargo test -p biorouter --lib agents::agent agents::workspace_extension agents::extension_manager` +Run: `cargo test -p biorouter --lib -- agents::agent agents::workspace_extension agents::extension_manager` Expected: PASS (5 new agent tests, including the persistence exclusion; the `available_tools` tests at `extension_manager.rs:2456-2545` still green — this task relies on them, it does not change them). @@ -11773,7 +11773,7 @@ five fields used above.) - [ ] **Step 3: Run to verify failure** -Run: `BIOROUTER_PATH_ROOT=$(mktemp -d) cargo test -p biorouter --lib agents::workspace_extension agents::subagent_tool agents::agent` +Run: `BIOROUTER_PATH_ROOT=$(mktemp -d) cargo test -p biorouter --lib -- agents::workspace_extension agents::subagent_tool agents::agent` Expected: FAILURES — but **not** "no `subagent` tool on the extension": Task 18 Step 4 already appended `create_subagent_tool(&[])` to `get_tools()`, so `the_workspace_extension_advertises_the_spawn_tool_under_its_existing_name`'s diff --git a/docs/agent-loop/hooks/hooks-reference.md b/docs/agent-loop/hooks/hooks-reference.md index c35768b38..ac3df5081 100644 --- a/docs/agent-loop/hooks/hooks-reference.md +++ b/docs/agent-loop/hooks/hooks-reference.md @@ -279,6 +279,27 @@ spelling is accepted as an alias. - **PostToolUse blocks are capped** at 3 consecutive blocks per session (see [Blocking a tool result](#blocking-a-tool-result-posttooluse)). +### Built-in checks that run before your Stop hooks + +When the agent tries to finish a turn, Biorouter runs its own checks first, in +this order, and only then consults your `Stop` hooks: + +1. the done gate, when you configured one (`BIOROUTER_DONE_GATE`); +2. the self-critique pass, when you enabled it; +3. the Todo checklist check: a turn that created or changed the checklist may + not end while items are unfinished, unless the final message names each open + item. See [What Biorouter enforces](../../extensions/built-in/todo.md#what-biorouter-enforces). + +A built-in check that blocks ends that stop attempt, so your hooks run on the +next one. The checklist check runs ahead of your hooks because it is +deterministic and cheap, while a command hook may run a whole test suite; there +is no point paying for a hook on a stop that is already refused. It has its own +budget, 5 blocks per turn, which does not reset when the agent runs tools and +does not count against the Stop-hook cap above. Its feedback reaches the model +the same way a Stop-hook block does: as hidden feedback, with a notice for you. +It stands aside while a `/goal` is active, because the goal's own judge decides +then. + ### Scheduling of observe-only events **Observe-only events run detached, but are not discarded.** `Notification`, diff --git a/docs/agent-loop/subagents.md b/docs/agent-loop/subagents.md index 7fcbc6181..849c5ae22 100644 --- a/docs/agent-loop/subagents.md +++ b/docs/agent-loop/subagents.md @@ -76,7 +76,7 @@ It is a cap on the burst, not a running total of open tabs. Each slot is release History hides subagent runs by default, so your session list stays a list of *your* conversations. Turn on **Show subagent runs** in History and each child appears nested under the conversation that spawned it, with a live marker while it is still running. -From the CLI, `biorouter session list --subagents` does the same, `biorouter session attach` joins a live child (`--of` to pick one by parent, `--read-only` to watch without steering), and `biorouter session cancel` stops it. +From the CLI, `biorouter session list --subagents` does the same, `biorouter session attach` joins a live child (`--of` to pick one by parent, `--read-only` to watch without steering), and `biorouter session cancel` stops it. Steering or stopping a child needs proof that a person acted: a daemon started with a user-action key asks the terminal for the key first, and one started without — `biorouter serve` — refuses, and says so; `--read-only` still follows the child there. See [the user-action key](workspace-control.md#as-subcommands-you-type). ## Internal subagents diff --git a/docs/agent-loop/workspace-control.md b/docs/agent-loop/workspace-control.md index bf3fe34e9..add9c9d7b 100644 --- a/docs/agent-loop/workspace-control.md +++ b/docs/agent-loop/workspace-control.md @@ -212,8 +212,28 @@ Give `session export` an identifier. With neither `--session-id` nor `--name` it `session list --subagents` reads the store for the rows but has to ask the daemon who is still live, so it marks each run `● live`, `○ done`, or — when it could not ask — `· state unknown`. It deliberately does *not* blame a missing daemon for that third state: a stripped `BIOROUTER_SERVER__SECRET_KEY` (which is what an agent-spawned shell gets) produces it with a daemon running perfectly well. The actual reason is printed once on stderr, so `--format json` on stdout stays clean. -The four daemon-bound commands need one running. Steering and cancellation also -require a user-action key whose SHA-256 digest is handed to the daemon on stdin: +The four daemon-bound commands need one running. It listens on `127.0.0.1:3000` unless +`BIOROUTER_PORT` says otherwise, and `send`, `watch`, `attach` and `cancel` authenticate with the +same `BIOROUTER_SERVER__SECRET_KEY`. `biorouterd` invents a random key when that variable is unset, +in which case no client can authenticate — so set it on both sides. A mismatch shows up as HTTP 401. + +Whether stopping and steering also need a **user-action key** depends on how the daemon was +started, and the command asks the daemon rather than you: + +- **A daemon started with a key** — its SHA-256 digest piped to `biorouterd` on stdin, as below, + which is how you start the daemon the desktop app shares with your terminal — wants the raw key + before it lets anyone stop or steer a turn, and before it takes a message into a subagent's + session, or into a private chat when your terminal runs a public model. When it refuses a + request for want of the key, the command asks you for it once, without echo, and makes the + request again with it. `attach` asks as it joins, before it reads anything you type, and + checks the key there rather than at your first steer. +- **A daemon started without one** — `biorouter serve`, or `biorouterd agent` with nothing piped in + — has nothing to check a key against, so you are never asked for one. It lets you stop and steer + any chat it lets you reach (a public one always, a private one when your terminal runs a private + model), except a subagent's: stopping, steering or sending to a subagent needs proof that a person + acted, which only a daemon holding a key can check, and the command prints that daemon's refusal + saying so ([SD-11](../deployment/serve-decisions.md#sd-11--stop-and-steering-work-on-a-daemon-with-no-key-a-subagents-tab-stays-the-persons)). + `attach --read-only` still follows such a session. ```bash read -r -s action_key @@ -222,17 +242,12 @@ printf '%s' "$action_key" | shasum -a 256 | cut -d ' ' -f 1 | \ BIOROUTER_SERVER__SECRET_KEY= biorouterd agent ``` -It listens on `127.0.0.1:3000` unless `BIOROUTER_PORT` says otherwise, and `send`, `watch`, -`attach` and `cancel` authenticate with the same `BIOROUTER_SERVER__SECRET_KEY`. `biorouterd` -invents a random key when that variable is unset, in which case no client can authenticate — so -set it on both sides. A mismatch shows up as HTTP 401. - -`session attach` prompts for the raw user-action key, without echo, before it permits steering; -`session cancel` does the same. `attach --read-only`, `watch`, and `send` do not need that proof. -Trusted automation can pipe the key as the first line with `--user-action-key-stdin`; never put it -in argv, an environment variable, config, or logs. The raw key is held in a zeroizing CLI buffer -and sent only on the attached event stream, `/interrupt`, `/agent/cancel`, or the attached -subagent's `/reply`, while the daemon retains only its digest. +`watch` and `attach --read-only` never send the key. Trusted automation can supply it as the first +line of stdin with `--user-action-key-stdin` (on `send`, `attach` and `cancel`), which sends it at +once instead of waiting for the daemon to ask; never put it in argv, an environment variable, +config, or logs. The raw key is held in a zeroizing CLI buffer and sent only on the attached event +stream, `/interrupt`, `/agent/cancel` and `/reply` — and only when you supplied it or the daemon +asked for it — while the daemon retains only its digest. Use `session attach` rather than `session --resume` on a session that is running right now: resuming opens a second agent on the same conversation, and the two do not share the daemon's turn lock. diff --git a/docs/cli/command-reference.md b/docs/cli/command-reference.md index b7c13fb48..749e03cd5 100644 --- a/docs/cli/command-reference.md +++ b/docs/cli/command-reference.md @@ -321,6 +321,19 @@ BIOROUTER_SERVER__SECRET_KEY= biorouter session watch A mismatched key surfaces as `HTTP 401` with that hint attached. +**The user-action key, when the daemon has one.** A daemon started with a user-action key (its +SHA-256 digest piped to `biorouterd agent` on stdin) wants the raw key before it lets anyone stop or +steer a turn, and before it takes a message into a subagent's session. `send`, `attach` and `cancel` +never ask for it up front: each makes its request without the key, and only if the daemon refuses +it for want of one asks you for it once, without echo, and tries again. `attach` asks as it joins, +before it reads anything you type. A daemon started without a key — `biorouter serve`, or +`biorouterd agent` with nothing piped in — is never asked about one. Such a daemon refuses every +request to stop, steer or send to a subagent's session, and the command prints its reason. To supply the key +without a prompt, pass `--user-action-key-stdin` and pipe the raw key as the first line of stdin. +Never put it in an argument, an environment variable or a config file. See +[Workspace control](../agent-loop/workspace-control.md#as-subcommands-you-type) for how to start a +daemon with a key. + ### session watch [options] Stream a session's live events into your terminal — the same frames biorouter Desktop renders, printed as lines. Watching is read-only: it never writes to the conversation, and stopping the watch never stops the session. @@ -359,6 +372,7 @@ Send a prompt into an existing session and stream the resulting turn, without op **Options:** - **`--no-wait`**: Return as soon as the daemon accepts the turn, printing `[started] turn in session `, instead of streaming it to completion +- **`--user-action-key-stdin`**: Read the raw user-action key from the first line of stdin instead of being asked for it when the daemon wants it. See [the user-action key](#live-session-commands-and-the-daemon) **Usage:** @@ -391,6 +405,7 @@ Join a session that is running *right now*. `attach` prints the conversation so - **`--name `**: Attach by session name instead of ID. Refuses, and lists the candidates, if several sessions share that name - **`--of `**: Attach to the running subagent of this parent session. Errors, listing what it found, if that parent has no subagent with a turn in flight or has more than one - **`--read-only`**: Observe only — do not read stdin and do not send anything +- **`--user-action-key-stdin`**: Read the raw user-action key from the first line of stdin, before the lines you steer with, instead of being asked for it when the daemon wants it. See [the user-action key](#live-session-commands-and-the-daemon) Give **exactly one** target: a session ID, `--name`, or `--of`. Passing none, or more than one, is an error. `biorouter session list --subagents` lists the sessions and subagent runs you can address. @@ -424,6 +439,10 @@ Stop the turn a session is currently running. This is the same action as the Sto - **``** (required): The session whose running turn should be stopped +**Options:** + +- **`--user-action-key-stdin`**: Read the raw user-action key from the first line of stdin instead of being asked for it when the daemon wants it. See [the user-action key](#live-session-commands-and-the-daemon) + Cancelling is idempotent: a session with no turn in flight is not an error, it reports `nothing to cancel: this session had no turn in flight`. **Usage:** @@ -707,6 +726,8 @@ No running Biorouter could be reached from this terminal (no daemon answered on This is always the case next to the desktop app. The app's own daemon uses a random port and a new secret every launch, so a terminal cannot reach it. It is also always the case in an agent's shell, because the daemon's secret is never passed to a tool. A running Biorouter polls the file and usually picks up the change within a couple of seconds, but the promise is 60. `sessions` reads the session store directly and never needs a daemon. +**A change made in the file needs a person.** A scheduled job is a standing unattended agent run, so `add`, `remove` and `run-now` print what will happen and ask for confirmation before writing the file — and refuse, with exit 2 and nothing written, when stdin and stderr are not both terminals. There is no `--yes`: a flag that skipped the question would be a flag an agent could pass. `list` and `sessions` only read, and ask nothing. To make the change from a script, reach a daemon instead: with `BIOROUTER_SERVER__SECRET_KEY` and `BIOROUTER_PORT` pointing at a running one, the daemon makes the change and no terminal is involved. + ### mcp Run an enabled MCP server specified by `` (e.g. `'Google Drive'`). MCP is the Model Context Protocol, the standard biorouter extensions speak. @@ -800,7 +821,9 @@ The printed URL carries an access token as `?t=`, minted per launch and s ### web -> **Deprecated.** Use [`serve`](#serve) instead. `web` serves a minimal standalone chat page rather than the Biorouter interface, and its default port collides with `biorouterd`'s. It is kept for now and unchanged; new deployments should not use it. +> **Deprecated.** Use [`serve`](#serve) instead. `web` serves a minimal standalone chat page rather than the Biorouter interface, and its default port collides with `biorouterd`'s. It is kept for now; new deployments should not use it. +> +> Since 2026-09-11 it lists no chats and returns no transcripts, and it opens a private chat only if it started that chat itself while running a private model. Continue any other private chat in the desktop app. [SD-13](../deployment/serve-decisions.md#sd-13--biorouter-web-serves-no-transcripts-and-opens-no-private-chat-it-did-not-start) records why. Start a new session in biorouter Web, a lightweight web-based interface launched via the CLI that mirrors the desktop app's chat experience. diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index a56a4ebf8..a8d31c884 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -201,7 +201,7 @@ restart. | Variable | Purpose | Values | Default | |----------|---------|---------|---------| | `BIOROUTER_SERVE_UI` | Directory holding the built browser interface for the daemon to serve. When it is unset the daemon serves no interface and answers only its API, which is what the desktop app wants — Electron loads the interface itself. `biorouter serve` sets it on the daemon it starts, and reads it as well: without `--web-dir`, which takes precedence, a non-blank value is the directory `serve` uses, and one with no `index.html` stops `serve` with an error rather than being skipped. | Path to a directory containing an `index.html` | Unset (no interface served) | -| `BIOROUTER_BROWSER_TOKEN` | The access token a browser presents to be handed the interface. Opening the address exchanges it for a session cookie — every time it is presented, until the daemon stops; it is not single-use — and it authenticates nothing else. When it is unset no token is required, which is only correct for a loopback bind — `biorouter serve` refuses to expose an untokened port. `biorouter serve --token ` sets it; otherwise the command generates a new one per launch. | Token string (`biorouter serve` uses 64 hexadecimal characters) | Unset (no token required) | +| `BIOROUTER_BROWSER_TOKEN` | The access token a browser presents to be handed the interface. Opening the address exchanges it for a session cookie — every time it is presented, until the daemon stops; it is not single-use — and it authenticates nothing else. When it is unset no token is required, which is only correct for a loopback bind — `biorouter serve` refuses to expose an untokened port. `biorouter serve` **reads it as well**: without `--token`, which takes precedence, a non-blank value (trimmed) is the token it uses and hands the daemon, and the banner says the token came from here rather than claiming it is new. With nothing named, `serve` mints one per launch. `--no-token` removes it from the daemon's environment, so a token this shell exports cannot leave the daemon demanding one the printed address does not carry. | Token string (`biorouter serve` mints 64 hexadecimal characters) | Unset (no token required) | > **Warning.** `BIOROUTER_BROWSER_TOKEN` is a credential. Put it in a file readable only by the > service user rather than on a command line, where `ps` shows it to every user on the host. diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 9a6160499..48142ac44 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -26,7 +26,7 @@ any deployment live in [configuration](../configuration/environment-variables.md | [Headless Linux deployment](headless-linux.md) | Running `biorouter serve` as a long-lived service on a Linux host with no graphical desktop: the CLI-only packages, the systemd unit, migrating secrets onto the host, and network exposure. | | [Reaching a private chat from a script](programmatic-session-access.md) | The `X-Caller-Provider` header: how a monitoring dashboard, a CI job or a shell script reads and follows a **private** conversation over the HTTP API, what the header is not (it is not authentication), and which routes honour it. | | [How browser-served Biorouter is built](serve-architecture.md) | Developer-facing architecture: what the daemon does with a web directory, how a browser is authenticated, and what the retired front door was replaced by. | -| [Decisions behind `biorouter serve`](serve-decisions.md) | The nine decision records governing the serving path — why a browser session cannot change its model, why the bind defaults to loopback, why the standalone binary was retired, and why the launch token is reusable until the daemon stops. | +| [Decisions behind `biorouter serve`](serve-decisions.md) | The ten decision records governing the serving path — why a browser session cannot change its model, why the bind defaults to loopback, why the standalone binary was retired, why the launch token is reusable until the daemon stops, and which chats the deprecated `biorouter web` may still open. | ## Related documentation diff --git a/docs/deployment/browser-access.md b/docs/deployment/browser-access.md index 68a9bafa7..dbf544a2b 100644 --- a/docs/deployment/browser-access.md +++ b/docs/deployment/browser-access.md @@ -22,7 +22,9 @@ first and then [Headless Linux deployment](headless-linux.md). ## Quickstart Choose the provider and model **before** you start serving — a browser session cannot change them -(see [The model is fixed before you start](#the-model-is-fixed-before-you-start)): +(see [The model is fixed before you start](#the-model-is-fixed-before-you-start)). Either kind +works: a commercial model, or a private one your institution hosts or that runs on the machine, +which is the choice to make for patient data. ```bash biorouter configure @@ -112,15 +114,22 @@ carries a credential instead. discarded the cookie can all open the same address. Anyone holding the address can do the same, and stopping `serve` is how you revoke it ([decision SD-9](serve-decisions.md#sd-9--the-launch-token-works-until-the-daemon-stops-it-is-not-single-use) records why it is not single-use). -- **The cookie gates the document and nothing else.** It is not accepted as authentication on any - API route. From the moment the page loads, the interface presents the daemon's secret key as a - header, exactly as the desktop application does. +- **The cookie gates the document, and authenticates nothing else.** It is not accepted as + authentication on any API route. From the moment the page loads, the interface presents the + daemon's secret key as a header, exactly as the desktop application does. Its one other effect + narrows rather than admits: it tells the daemon a request came from the page it served, which + decides whether private chats and knowledge bases are listed there + ([decision SD-10](serve-decisions.md#sd-10--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else)). - **Opening the address without the token** returns a short page saying the link needs its access token. Open the full address the command printed, `?t=` included. To keep one address working across restarts — a bookmark, or a service that restarts — pass the -same token each time with `--token `, or set `BIOROUTER_BROWSER_TOKEN` for the daemon. Treat that -value like a password: anyone who has it has the same access you do. +same token each time with `--token `, or set `BIOROUTER_BROWSER_TOKEN` in the environment `serve` +runs in. `serve` uses it as given (trimmed), hands it to the daemon, and says on the banner that the +token came from there rather than claiming it is new; `--token` wins when both are set. A service +unit should use the variable, from a file only the service user can read, rather than the flag, +which `ps` shows to every user on the host. Treat that value like a password: anyone who has it has +the same access you do. `--no-token` turns the gate off entirely. It is accepted **only** for a loopback bind, where the only callers are processes on the same machine, and `serve` prints a line saying so: @@ -195,6 +204,21 @@ provider is chosen once, at the terminal, and the tier that choice implies holds session in that daemon. A run started against an institutional model is private for its whole life; one started against a commercial model is public for its whole life. Neither can drift. +What that means for a chat you start in the browser: + +- **It starts on the configured model**, private or not, with nothing asked of you — choosing it + at the terminal was the decision. +- **A private model makes it private with its first reply.** The tab you are in keeps working with + it: the browser tells the daemon which model it runs under, and a chat is open to anything + running under a model at least as private as the chat. +- **Nothing in the browser can move it onto a different private model.** The configured one is the + only private model a chat started here ever reaches. +- **A private chat started in the desktop application opens here only when the host's model is + private too.** On a host configured with a commercial model it stays out of reach, with a card + saying so; open it in the desktop application instead. + +The reasoning is recorded as [decision SD-12](serve-decisions.md#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does). + **The fix is to choose the provider before you start serving:** ```bash @@ -214,7 +238,7 @@ differs: | Area | In a browser | |---|---| -| Chat, sessions, history, extensions, skills, knowledge bases, workflows | Work as they do in the desktop application. | +| Chat, sessions, history, extensions, skills, knowledge bases, workflows | Work as they do in the desktop application, for everything public, with two differences that follow from the fixed model. A new chat starts on the host's configured model, and a private chat opens only on a host whose configured model is private — see [The model is fixed before you start](#the-model-is-fixed-before-you-start) and [decision SD-12](serve-decisions.md#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does). **Private** chats and knowledge bases appear in History and the Knowledge view only when the provider you configured is private, and being listed does not by itself make a private chat openable — see [decision SD-10](serve-decisions.md#sd-10--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). | | 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. | @@ -246,6 +270,13 @@ you ran, and finds it either next to that CLI or next to the application the CLI recorded). If the application has moved or been reinstalled since, run `biorouter setup-path` again from inside it — `\resources\bin\biorouter.exe setup-path`. +**A message does not start a chat.** The composer keeps what you typed, and a notice in the corner +says why. The commonest cause is on the host rather than in the browser — for example *Failed to +configure the selected provider for the new chat: Configuration value not found: +OPENAI_API_KEY* means the model chosen with `biorouter configure` has no credential on the serving +machine. Fix it there, restart `serve`, and open the new address it prints. **Copy error** on the +notice copies the daemon's own words, for a bug report. + **The tab says the link needs its access token.** The `?t=` part was dropped — from a copy-paste, a chat client shortening the link, or a bookmark saved after the redirect. Use the full address as printed. If the launch has since restarted, the token has changed; read the new one from the @@ -263,6 +294,15 @@ origin it was given. browser is on a different computer, its local files are not visible to the agent; copy them to the serving machine first. +**A private chat or knowledge base you can see in the desktop app is missing from the browser.** +Private chats and knowledge bases are shown in the browser only when the provider `serve` was +started with is itself private, meaning institution-hosted or running on this machine, and only +when `serve` was started with its access token (the default). The desktop app proves a person is at +the keyboard; a browser cannot, so it is given the reach of the model its daemon runs on and no +more. On a private provider the chat is listed, but being listed does not by itself let the +browser open, rename or delete it; when it cannot, open the chat in the desktop app. The reasoning is +[decision SD-10](serve-decisions.md#sd-10--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). + ### When the interface cannot be found A directory you name is used as named, or not at all. `--web-dir ` wins when it is given; diff --git a/docs/deployment/headless-linux.md b/docs/deployment/headless-linux.md index 5eb118ba3..60cd5049d 100644 --- a/docs/deployment/headless-linux.md +++ b/docs/deployment/headless-linux.md @@ -141,6 +141,12 @@ sudo chmod 600 /etc/biorouter/env Generate the token with something that is actually random, for example `openssl rand -hex 32`. +`serve` reads `BIOROUTER_BROWSER_TOKEN` from the environment the unit gives it and uses that token +as the address's `?t=`, so the URL below is the one it prints. (Until 2026-09 it did not: it minted +a random token over the operator's own and answered the published address with 401. Pass `--token` +only from an interactive shell — on a service's `ExecStart` it would be visible in `ps` to every +user on the host.) + ```ini # /etc/systemd/system/biorouter.service [Unit] diff --git a/docs/deployment/programmatic-session-access.md b/docs/deployment/programmatic-session-access.md index 281f8954a..010337229 100644 --- a/docs/deployment/programmatic-session-access.md +++ b/docs/deployment/programmatic-session-access.md @@ -53,7 +53,10 @@ case with its own logic — it is that rule, stated on a request. The header is read by [`crates/biorouter-server/src/routes/session_reach.rs`](../../crates/biorouter-server/src/routes/session_reach.rs); `biorouter session watch`, `send` and `attach` already send it, which is why those commands reach a -private chat from a terminal that can never prove a human is present. +private chat from a terminal that can never prove a human is present. So does the browser interface +`biorouter serve` serves, naming the model the host was configured with +([SD-12](serve-decisions.md#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does)): +a browser, like a terminal, can never carry the proof, and runs the model its host chose. ## What the header is *not* @@ -67,9 +70,13 @@ which got the answer backwards in both directions: a terminal running an institu refused, while the desktop app was admitted for the same chat while running a public one. **It is not a way to raise or lower a tier.** Reaching a chat and *reclassifying* one are separate -decisions. Raising a session's classification, declassifying it, and binding a private model all -still require proof that the person at the keyboard acted (`X-User-Action`), and no header changes -that. A capability is a fact about a model; neither of those is a decision a model may make. +decisions. Raising a session's classification, declassifying it, and binding a private model to a +chat all still require proof that the person at the keyboard acted (`X-User-Action`), and no header +changes that. A capability is a fact about a model; neither of those is a decision a model may make. +The one bind that needs no proof is not a header's doing either: on a daemon with no user-action +key, a **new** chat starts on the model the operator configured, private or not, because choosing +it with `biorouter configure` was the decision +([SD-12](serve-decisions.md#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does)). **It is not a per-request opt-out.** There is no header that turns the gate off. The only machine-wide switch is the privacy master switch, which lives in its own record beside @@ -175,8 +182,50 @@ 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. | +| `DELETE /sessions/{id}` | Deletes the chat. Ungated until 2026-09-10, when QA deleted a private chat the read refused (F0). | +| `PUT /sessions/{id}/name` · `PUT /sessions/{id}/user_workflow_values` | Renames the chat; rewrites its workflow values and re-applies the workflow. | +| `POST /sessions/{id}/edit_message` with `editType: edit` | Truncates the chat's history in place. (`diverge` keeps its stricter gate: see below.) | +| `GET /sessions/{id}/extensions` · `GET /sessions/{id}/usage` | The chat's enabled extensions, and its per-model token counts. | +| `GET /agent/tools` · `GET /agent/callable_tool_count`, naming a `session_id` | The chat's tool surface. Both build an agent for the chat, so both are gated before that happens. | +| `POST /workflows/create` | A workflow a model writes from the chat's whole transcript. | +| `POST /skills/session` | The chat's per-chat skill overrides. | +| `POST /knowledge/bases/{id}/ingest-conversation` | Every chat the request names, each checked before any transcript is read. | +| `POST /active_work/{id}/cancel` | Stops one chat's running work: a shell command, a subagent, a detached turn or a scheduled run. The id names the work, not the chat, so the daemon looks up the chat that owns it and applies this gate to that chat before anything stops. | +| `POST /schedule/{id}/kill` | Stops a schedule's run. Reaches the SAME kill as the row above by the schedule id instead of the work handle, so leaving it open made that row bypassable by a one-word change of URL. The run is resolved to its chat and gated on it, and the stop is checked against the run it was authorized against — a schedule that has started a different run since is refused, not stopped. | +| `GET /schedule/{id}/inspect` | Names the chat a schedule is running in, and when the run started. Gated on that chat; a private chat's run, an idle schedule and an absent one answer alike. | | `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. | +Each of these refuses a caller exactly as `GET /sessions/{id}` does, with the same status and the +same words, and answers a chat that does not exist the same way. Deleting, renaming or editing a +chat is never easier than reading it, and neither is stopping its work. + +**Listings and knowledge bases apply the same rule.** They do not refuse a list; they leave out what +the caller could not open: + +| Route | What a caller without the header or the proof gets | +|---|---| +| `GET /schedule/list` | **Every** schedule, including idle and paused ones — but with `current_session_id` and `creator_session_id` omitted from any row naming a chat the caller could not open. ⚠ The one listing that REDACTS FIELDS instead of dropping rows, because a schedule is not a chat: it names chats, and an idle one names none, so a row-level rule would empty the Schedules view for every ordinary caller rather than close an association. | +| `GET /sessions`, `GET /sessions/sidebar`, `GET /schedule/{id}/sessions` | The public chats only. A private chat is omitted, never redacted. It is not shown with its title removed. The sidebar still pages cleanly: follow `next_offset` as returned rather than computing it. | +| Every `/knowledge/bases/{id}…` route: pages, graph, history, location, export, preview, and the writes | A private base is refused with a knowledge-base twin of the chat refusal. A base that does not exist, and a malformed id, get the same refusal. | +| `GET /knowledge/bases`, `GET`/`POST /knowledge/active` | The public bases only. A write to the selection cannot hide, reveal or unpin a base the caller cannot see. | +| `GET /active_work` | The running work of public chats only. Each row carries its chat's `sessionId` and a `title` and `detail` holding the shell command or task prompt, which is the chat's content. A row whose chat is private, or cannot be read, is omitted. So is a row that names no chat at all (see below). | + +A browser pointed at `biorouter serve` is a special case of this, described in +[decision SD-10](serve-decisions.md#sd-10--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). +`GET /active_work` is a listing, so the served interface keeps its operator's reach there. Stopping +one piece of that work names one chat, so it does not. + +**Work that names no chat is treated as a private chat's work.** A registry row can be missing its +chat: work registered from outside any chat, or a schedule that has started but not yet opened its +chat. Such a row still carries a command or a prompt from some chat, and nothing says which. So +`GET /active_work` shows it only to a caller that would be shown a private chat, and `POST +/active_work/{id}/cancel` refuses it in the same words as a private chat. A handle that names +nothing is refused the same way, so a refusal does not say whether the work exists. Background +jobs and foreground commands used to register without their chat, so this rule alone would have +hidden every shell command, a public chat's included, from any caller not shown private chats. The +shell now records the chat that ran each command, from the chat id Biorouter's MCP client attaches +to every tool call, which leaves this rule to work that genuinely has no chat. + ## What the header does *not* cover Being explicit about this is the point of listing it. These routes address or expose sessions and do @@ -195,27 +244,24 @@ it would be wrong: | `POST /agent/call_tool` | Privacy Gate C at the extension-manager dispatch point, plus the uninspected-boundary refusals. | | `POST /agent/read_resource` | Gate C's sibling at the extension-manager resource read. Like `call_tool` it has no caller identity, so it declares `CallCapability::public_enforced()` rather than sampling the named session: naming a private chat buys nothing, and a private extension is refused with `403`. | -**Ungated, and low-yield.** These name a session but return only its tool surface, not its contents: -`GET /agent/tools`, `GET /agent/callable_tool_count`, `GET /skills/catalog`, `POST /skills/refresh`. -They are listed as a measurement, not as a ruling — nothing in the source records a decision to -exempt them, so read this row as "not gated" rather than "deliberately not gated". `POST -/agent/read_resource` was on this list until 2026-09-09 and is now gated; the row above says how. +**Ungated, and low-yield.** These name a session but return only skill state, not its contents: +`GET /skills/catalog`, `POST /skills/refresh`. They are listed as a measurement, not as a ruling: +nothing in the source records a decision to exempt them, so read this row as "not gated" rather +than "deliberately not gated". `POST /agent/read_resource` was on this list until 2026-09-09 and is +now gated, as the row above explains. `GET /agent/tools` and `GET /agent/callable_tool_count` were on +it until 2026-09-10. QA then measured the first handing a private chat's private-connector tool names +to a caller holding only the secret (M2), and both are now gated. **Ungated, and a known residual.** These reach or describe a private session without the gate. None -returns a transcript, so none is the boundary this feature defends — but none is closed either, and -a reader should not infer from this page that the surface is complete: +returns a transcript, so none is the boundary this feature defends. None is closed either, and a +reader should not infer from this page that the surface is complete: | Route | What an ungated caller gets | |---|---| -| `GET /sessions`, `GET /sessions/sidebar` | Every session on the machine — id, name, working directory and tier. Enumerates wholesale; recorded as an open residual in `session_reach.rs`. | -| `GET /sessions/running` | The ids of sessions with a turn in flight. | -| `GET /active_work` | Every running background job, subagent, detached turn and scheduled run — with `sessionId`, and a `title`/`detail` that carries the **shell command or task prompt**. This is content rather than metadata, and it is not named in `session_reach.rs`'s residual list. | -| `POST /active_work/{id}/cancel` | Cancels any of the above by its registry id. The id is not a session id, so the gate cannot be applied without a reverse lookup. | -| `GET /sessions/{id}/usage` | Per-model token counts for a named session, and a `200`/`404` that tells the caller whether the id exists. | -| `GET /sessions/{id}/extensions` | The session's enabled extension list. | -| `PUT /sessions/{id}/name`, `PUT /sessions/{id}/user_workflow_values`, `DELETE /sessions/{id}` | Renames, edits workflow values, or deletes the session. | -| `POST /skills/session` | Rewrites a session's per-chat skill overrides. | -| `GET /schedule/{id}/inspect`, `POST /schedule/{id}/run_now`, `POST /schedule/create` | Inspects or launches scheduled work that may run in a private session. | +| `GET /sessions/running` | The ids of sessions with a turn in flight. Left unfiltered on purpose: `biorouter session list` reads it to report whether a run is still going, and a filtered answer would report a running private chat as finished. | +| `GET /sessions/changes` | For the ids a caller names, and any other row that changed, the provider, model and tier columns. Metadata, not titles or transcripts. | +| `GET /sessions/insights`, `GET /sessions/activity` | Machine-wide counts and per-day usage. Aggregates that name no chat. | +| `POST /schedule/{id}/run_now`, `POST /schedule/create` | Launch scheduled work that may run in a private session. | The daemon has no principal, so none of this is a *tier* bypass in the strict sense — a caller holding the secret is already inside. It is the same open problem as diff --git a/docs/deployment/serve-architecture.md b/docs/deployment/serve-architecture.md index dcd96ea47..0c6becf9e 100644 --- a/docs/deployment/serve-architecture.md +++ b/docs/deployment/serve-architecture.md @@ -130,6 +130,13 @@ API routes: doing so would make every API route reachable by a cookie the browse automatically, which is a cross-site request forgery surface that the header scheme does not have. Keeping the cookie's job to one request means `check_token` is unchanged. +The cookie has one other reader, and it narrows rather than admits. An API request that has +already passed `check_token` and also carries the cookie came from the document this daemon +served, so the listing and knowledge-base gates give it the tier of the provider the operator +configured. A request holding only the secret is a public caller there. The transcript gate never +reads the cookie. See +[decision SD-10](serve-decisions.md#sd-10--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). + > **Warning.** `check_token` records a failed attempt for every request without the secret and > refuses after twenty inside sixty seconds, keyed on the peer address. The browser-token check > must not feed that same counter — a mistyped URL would otherwise lock the user out of their own diff --git a/docs/deployment/serve-decisions.md b/docs/deployment/serve-decisions.md index c62d8832b..d3f9ac70c 100644 --- a/docs/deployment/serve-decisions.md +++ b/docs/deployment/serve-decisions.md @@ -1,10 +1,11 @@ # Decisions behind `biorouter serve` > **What this is.** The decision records governing browser-served Biorouter — why the daemon -> serves the interface itself, why a browser session cannot change its model, why the -> standalone `biorouter-headless` binary was retired, and how long the launch token stays good -> for. Each record states the ruling, the alternatives it displaced, and the consequence a -> future change would have to accept. +> serves the interface itself, why a browser session cannot change its model yet starts every +> chat on the one the operator chose, why the standalone `biorouter-headless` binary was +> retired, how long the launch token stays good for, and which chats the deprecated +> `biorouter web` may still open. Each record states the ruling, the alternatives it +> displaced, and the consequence a future change would have to accept. > **Status:** Current. > **Audience:** developers working on the daemon, the CLI, or release packaging; agents making > changes anywhere near the serving path. @@ -18,8 +19,9 @@ 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), 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) +must say so before the user reaches for it (SD-8), the reason the controls that stop and settle +a turn answer to the reach gate there instead (SD-11), and the reason the one model the +operator chose must not need that proof at all (SD-12). 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 @@ -53,6 +55,12 @@ anyone opens a tab — and the tier that choice implies holds for every session A run started against an institutional Bedrock model is private for its whole life; one started against a commercial model is public for its whole life. Neither can drift. +> ⚠ **The first half of that sentence was unreachable until SD-12.** A `serve` daemon holds no +> user-action key, and the new-chat bind asked for that key's proof before binding a private +> model — so with an institutional model configured, no chat could be started at all, and the +> interface showed nothing (the 2026-09-10 QA run, finding F1). See +> [SD-12](#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does). + **Displaced alternatives.** - *Mint a digest scoped to a loopback bind.* Rejected: it makes the guarantee depend on the @@ -332,7 +340,8 @@ can never half-believe a person is reachable. **Ruling.** `GET /?t=` exchanges the token for the session cookie every time it is presented, not only the first time. The exchange takes the token out of the address bar; it does not consume it. The token stops working when the daemon stops — which SD-7 ties to `serve` -stopping — or, for one passed with `--token`, when a different one is passed. +stopping — or, for a token the operator chose (`--token`, or `BIOROUTER_BROWSER_TOKEN` in the +environment `serve` runs in), when a different one is chosen. **Why.** The token was first described as "spent on the first request", and that was never true: the 2026-09-10 QA run redeemed one token four more times after the first and got a 303 each time. @@ -346,8 +355,14 @@ had without breaking what the product promises: - **The supported uses need a second redemption.** A second browser, or a colleague on a shared host, where everyone who opens the address is the same user; the same browser after it has dropped its session cookie, which carries no expiry and may be discarded when the browser - closes; and a bookmark of an address fixed with `--token`, which + closes; and a bookmark of an address fixed with `--token` or `BIOROUTER_BROWSER_TOKEN`, which [browser access](browser-access.md) offers precisely so that the address survives restarts. + A service unit is the case that needs the variable rather than the flag: nobody is watching its + terminal for a new token, and a flag on `ExecStart` is visible in `ps` to every user on the + host. ⚠ `serve` ignored that variable and minted a token over it until 2026-09-12, which made + the systemd recipe in [headless Linux](headless-linux.md) unusable as written; honouring it + needed no new ruling, because a fixed operator-chosen token is the shape this record already + provides for. - **Things other than people fetch links.** A browser prefetching a pasted address, or a chat client unfurling it, would spend a single-use link before anyone clicked it. @@ -364,12 +379,81 @@ consumed. **Consequence to accept.** The address `serve` prints is a bearer credential for as long as the daemon runs. Revoking it means stopping `serve` — which is why SD-7 requires that the daemon never -outlive it — and, for an address fixed with `--token`, choosing a new token. Treat it like the +outlive it — and, for an address fixed by the operator, choosing a new token and restarting. Treat it like the password it is. `the_token_is_not_consumed_by_the_exchange` in `routes::web_ui` pins the behaviour, so changing it means revisiting this record, not making a quiet fix. --- +## SD-10 — The served interface keeps its operator's reach on listings and knowledge bases, and gains nothing else + +**Ruling (2026-09-11).** Since the privacy fix for QA findings H2 and M1 (2026-09-10), every +daemon route that lists chats, or names, lists or reads a knowledge base, answers a caller that +holds only the daemon secret as a **public model**: private chats are left out of lists, and a +private knowledge base is refused. The desktop application is told apart by the proof-of-user +header it sends. A `serve` daemon holds no such proof (SD-7), so it recognises its **own +interface** another way. A request that carries the served document's session cookie, on a daemon +started with a browser token, is given the tier implied by the provider the operator configured +(SD-1). That tier is read once at launch: the declared tier of the configured provider, reduced with +`least` over a configured lead provider, which is the reduction a bound lead/worker pair gets. + +- On a **private** provider (institution-hosted, or local), the History list and the Knowledge + view show private chats and knowledge bases, as they did before the fix. +- On a **public** provider they show public ones only. That is also what any caller holding just + the secret sees. + +**Why.** SD-1 already makes every session in a `serve` daemon run on the operator's provider, so +that provider's tier is the only capability the interface can be said to have. The cookie is what +separates the interface from anything else holding the secret. Without it the fix would have had +to go one of two ways, and both are wrong. One strips an operator on a private provider of their +own history and knowledge, which is a hard regression. The other hands every holder of the secret +the operator's reach, which reopens H2 on every `serve` daemon. + +**What it does not do.** + +- **It reaches no private transcript.** The transcript gate, and every route that names one chat + (open, export, the live event stream, delete, rename, and the rest), never read this standing. + They judge a `serve` browser exactly as they judged it before this ruling: on the proof it + carries, which is none (SD-7), and on the capability it states with `X-Caller-Provider`, which + they judge as they judge any caller's. An interface that states no capability — the case this + ruling was written against — sees private chats in its History list that it cannot open, delete + or rename. That is SD-7's limitation, left where this ruling found it, and it keeps deleting a + chat from ever being easier than reading it. Letting the transcript gate honour the cookie + itself would be the first time a gate widened. It is an **open decision**, recorded here and not + taken. +- **It is not authentication, and not a proof of a person.** `biorouter serve` passes both the + secret and the browser token in the daemon's environment. A caller that can read one can read + the other, which is the residual the `X-Caller-Provider` header already carries + ([issue #47](https://github.com/BaranziniLab/biorouter/issues/47)). It never satisfies a + proof-of-user check, so SD-1 and SD-8 stand exactly as they were. +- **It follows the address, not a person.** The token is not single-use (SD-9), so every browser + that opens the address `serve` printed gets this standing: a second browser, a colleague on a + shared host, a bookmark of an address fixed with `--token`. That is no more than the address + gave before this ruling, when these listings and knowledge-base routes were open to any caller + holding the secret the served document carries. Revoking the standing means what revoking the + address means: stopping `serve`. +- **A `--no-token` daemon gives it to nobody.** Without a token there is no cookie, and the + interface cannot be told apart from any other local caller. Such a daemon shows public chats and + knowledge bases only. +- **It creates no cross-site request forgery surface.** The cookie is `SameSite=Strict`, so no + cross-site request carries it, and every API request still needs `X-Secret-Key` to reach this + standing at all. It can only narrow a caller that already holds the secret, never admit one that + does not. + +**Consequence to accept.** Two `serve` daemons on one machine, configured with providers of +different tiers, show different subsets of one shared history and knowledge store. That follows +from SD-1, which already made the provider a property of the daemon rather than of the tab. + +Implemented in `crates/biorouter-server/src/auth.rs` (`install_served_operator`, +`served_operator_capability`) and `routes::session_reach::HttpCaller`. Pinned in two places, +each asserting both halves — the interface keeps its listing and knowledge-base reach, and the +cookie gains it no transcript: `a_served_interface_keeps_its_listing_reach_and_gains_no_transcript` +in `routes::session_reach`'s lib tests, which is the copy CI runs, and +`crates/biorouter-server/tests/serve_operator_reach.rs`, which adds the keyless arm — a daemon with +no user-action key, as `serve` really starts it. + +--- + ## 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 @@ -438,7 +522,7 @@ changing direction. The nearest thing that caller already has is cancel-then-rep 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::authorize_steer` carries the same reasoning at the code.) +`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 @@ -485,7 +569,7 @@ answers in writing ([privacy tiers §3.1](../security/privacy-tiers.md#31-the-re | `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` | + | `biorouter session cancel`, `attach` and `send` → these routes, and `/reply` | user at a terminal | the routes' own gates: the CLI sends the proof only when the person supplied it or a daemon that holds a key refused without it (see *The terminal, since* below) | `commands/session_watch.rs::with_key_if_wanted` | 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 @@ -535,9 +619,7 @@ report, not the time. **Not decided here.** An ordinary browser chat's steer control is 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. +have it say so first. The terminal's half of this was closed since, below. **Decided since, under SD-8.** This record left a subagent's tab in a browser offering a composer, a steer and a Stop that all refuse, and said SD-8 required them to say so before the click. @@ -546,6 +628,405 @@ with what the interface now does instead, is written up in [SD-8](#sd-8--a-control-that-can-never-work-here-says-so-rather-than-failing-on-click). Nothing about the refusals above changed. +**The terminal, since.** `biorouter session cancel`, `attach` and `send` used to demand the +user-action key from the terminal before they sent anything, so against a keyless daemon they +refused locally the requests this record admits: the browser's defect, one client over. A terminal +cannot ask a daemon whether it holds a key, so each command now lets the daemon answer. It sends its +request without the proof, unless the person supplied the key on stdin (`--user-action-key-stdin`), +and asks the person for the key only when the answer is the empty 403 of a daemon that holds one; +then it sends once more, with the key. A refusal that carries a sentence, which is every refusal a +keyless daemon gives on these routes, is printed instead, because no key would change it. + +- `attach` asks as it joins, with an empty steer: the gate answers before `/interrupt` reads the + text, and empty text is refused before anything is touched. It cannot wait for the first real + steer, because by then stdin carries the person's messages, and a hidden prompt would have to share + it with them. +- `send` asks the same question after a refused `/reply`, because `/reply`'s own refusal for a + subagent's session is an empty 403 on either kind of daemon and cannot say which kind this is. + +Nothing is relaxed. The daemon stays the boundary, and every refusal the terminal reads is given +before the route touches the turn, so sending the request a second time cannot deliver anything +twice. A subagent's session still needs the proof. The raw key still comes only from the +terminal or from stdin, never from argv, the environment, config or logs, and it is now sent only +when the person supplied it or a daemon asked for it. Keeping the local refusal and rewording it to +say what to do was rejected, because it would ask the person whether the daemon holds a key, and +the daemon answers that itself. The shapes the terminal reads are pinned from the daemon's side, in +`routes::reply`'s keyed tests and in `tests/turn_control_no_user_key.rs`; the reader is +`key_verdict` in `commands/session_watch.rs`. + +## SD-12 — A new chat starts on the operator's model without a proof, and nothing else does + +**Ruling.** On a daemon that holds no user-action key — the one `biorouter serve` starts (SD-7), +or a `biorouterd` started by hand — `POST /agent/start` binds the operator's configured provider +to the new chat without asking for proof of a person, whether that provider is public or private, +**for as long as that configuration is still the one the daemon was launched with**. Five things +hold beside it: + +- **The exemption is pinned to the launch configuration.** A daemon samples the + capability-deciding configuration once, before any route is mounted, and the exemption applies + only while the live values still match it. If any of them has moved, the bind is refused with a + 409 that names the key and says to restart the daemon. `biorouter_server::launch`. +- **A daemon that expected a key and did not get one keeps the refusal.** A launcher that hands + over a digest declares so in the environment (`BIOROUTER_USER_ACTION_EXPECTED`), so + "no proof can ever exist here" is distinguishable from "the proof went missing". The second is + a fault to repair, and it keeps the pre-SD-12 behaviour — every private new chat refused, now in + a sentence that says why — plus a startup `ERROR` naming the consequence. +- On that daemon, `POST /agent/update_provider` refuses every move onto a private model, whatever + the chat runs on now. The configured model is the only private model a chat there can reach. +- A daemon that holds a key — the desktop application's — is unchanged. Its renderer sends the + proof on every start, and a start that lacks it is refused as before. +- The browser interface states the host's configured model on the requests that reach into a chat + (`X-Caller-Provider`), the way `biorouter session` already does from a terminal, so a chat its + first reply made private stays reachable from the tab that started it. + +**Why.** The configured model is the person's decision, made out of band. SD-1 already says the +tier that choice implies *holds for every session in that daemon*, and open question 24 of the +privacy plan already put the raise at the moment the choice is written — *a raise of every future +session*. A new chat taking that tier is the choice being honored, not a switch. DR-16 governs +raising a chat that exists, and a chat that did not exist a moment ago has nothing to raise. + +> ⚠ **The first version of this record justified the exemption with a claim the tree contradicts, +> and the correction is the load-bearing part.** It said: *"`/agent/start` names no provider: it +> binds `BIOROUTER_PROVIDER`, which only a proven person may write over HTTP."* The HTTP doors to +> that key really are closed — `/config/set_provider` 409s on a keyless daemon, and `/config/upsert` +> and `/config/remove` both gate `privacy::is_capability_key` behind the proof — but **`config.yaml` +> is not an HTTP resource.** §9.5's general filesystem read/write deny, DR-14, is recorded +> **DEFERRED** in [privacy tiers, *"Did not ship"*](../security/privacy-tiers.md#what-shipped-and-what-did-not); +> the agent holds `developer__shell`; and `Config`'s value cache is keyed on a `FileStamp` it +> re-`stat`s on every read, so the file is reloaded live and the bind reads whatever it says **at +> request time**. A model with a shell on a keyless daemon whose operator had configured a *public* +> default could therefore write a private provider into that file and `POST /agent/start` to get a +> 200 and Private capability — unlocking `chatrecall` over private chats, private knowledge bases +> and an unfiltered Gate E roster with an extension set of its own choosing — where `main` answers +> 409. That is not the operator's choice being honored; it is the door's own text being rewritten by +> the thing the door constrains. +> +> So the exemption is **pinned** rather than re-derived per request. The bind still reads the +> configuration — an operator who edits the file and restarts is served, which is the whole point of +> restarting — but the *exemption* survives only while the capability-deciding values still match +> what this daemon started with. That makes SD-1's sentence true of the door rather than merely +> asserted near it, and it makes a model's ability to write `config.yaml` irrelevant here. + +**What is pinned, and why that list.** `privacy::CAPABILITY_CONFIG_KEYS` verbatim — +`BIOROUTER_PROVIDER`, `BIOROUTER_LEAD_MODEL`, `BIOROUTER_LEAD_PROVIDER`, `OLLAMA_HOST`, +`LLAMACPP_EXTERNAL_HOST` — plus `BIOROUTER_MODEL`. Reusing the list `/config/upsert` already guards +is deliberate: `privacy::config_keys`'s scan of the tier-input files is what keeps it honest, so a +key that starts deciding capability is pinned here without anyone remembering to, and a second +hand-written list would be a third answer to a question that already has two agreeing ones. +⚠ **The provider's name alone would not have been enough:** `self_hosted_tier` reads `ollama` as +Private exactly while its host is loopback, so flipping `OLLAMA_HOST` moves the tier with +`BIOROUTER_PROVIDER` untouched — the same escalation through a different key. + +`BIOROUTER_MODEL` is pinned for a different reason and is **not** a capability key. No `tier()` +implementation reads the model name — all five were checked: both Versa modules resolve +`ucsf_gateway_tier(endpoint)`, `ollama` and `llamacpp` resolve `self_hosted_tier(base_url)`, and the +lead/worker composite takes the `least` of its two halves. Writing it cannot move a tier; it decides +which model runs, and `/agent/start` binds *both* halves of the operator's declaration +(`configured_new_session_provider` requires the provider **and** the model), so the pin covers what +the operator actually declared. Its classification, with that reasoning, is a row in +`privacy::config_keys::NOT_CAPABILITY_CONFIG_KEYS`; what it permits without a proof is an integrity +and availability question — silently downgrading every new chat to a different model, or making new +chats fail outright — not a tier one. + +On a daemon with no key, asking for the proof can only refuse everyone. The 2026-09-10 QA run +measured it: with an institutional model configured, every new chat on a `serve` daemon was +refused 409, in a sentence written for a model that pointed at a model picker SD-1 disables, and +the interface showed nothing at all. A control nobody can pass is not a boundary; it is the +product not working. + +The other two halves close what the exemption would otherwise open. Without the rule on +`/agent/update_provider`, a chat bound to the private default could be moved to a different private +model — `Private → Private`, which DR-16's raise predicate calls sideways and allows — that nobody +configured. Without the capability statement, the tab that started a chat lost it after one reply: +the reply makes the chat private (the classification ratchets on the turn, never on the bind), and a +keyless daemon reaches a private chat only for a caller whose stated capability covers it. Measured: +the chat's next request answered 403 with nothing stated, and 200 with the host's provider stated. + +**Why not on every daemon.** On a daemon that holds a key the proof costs the person nothing — the +renderer attaches it to every start — and it still refuses a caller that cannot present it. That +includes a model holding the daemon secret, which AR-11 found recoverable and which could otherwise +mint a private-capability chat through `/agent/start` with an extension set of its own choosing. +Relaxing the gate there buys the person nothing and gives that model something. + +**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)): + +- *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 get the configured model and nothing else. +- *What else reaches a chat running on the configured private model:* + + | Door | Proof asked | Changed here | + |---|---|---| + | `workspace_open { new: … }` — binds the machine default through `restore_provider_from_session` | None, on every daemon (privacy tiers, "Did not ship") | No | + | `POST /agent/restart` on a row that names no provider — `restore_provider_from_session` falls back to the configured default | None | No | + | An app session's creation bind (DR-21) | None, deliberately | No | + | `POST /agent/update_provider` onto a private model | The proof; on a keyless daemon, refused outright | Yes | + | `POST /config/set_provider`, and `/config/upsert` or `/config/remove` on a capability key | The proof (SD-1, open question 24) | No — the configured model stays the operator's to choose | + +**Displaced alternatives.** + +- *Keep the refusal, and explain it in the interface.* Rejected. SD-8's explanation is for a + control that can never work; this one is the product's core. A `serve` deployment whose only + model is institutional would be a chat application that cannot chat. +- *Exempt every new chat, whatever provider it asks for.* Rejected. The operator's choice is what + makes the bind legitimate, so a provider the request picked would be a switch. `/agent/start` + names none today, and a field that ever let it name one must not inherit this exemption. +- *Exempt the configured model on every daemon.* Rejected; see *Why not on every daemon*. +- *Leave the justification as written and record the hole in the consequences.* Rejected. It would + have meant writing down that this door grants Private capability to anything with a shell — a + statement that is true and that nobody reading the ruling would expect from it. The cost of + closing it is one configuration comparison per new chat. +- *Bind the launch snapshot itself and never read the file again.* Rejected, though it is the + narrower rule. It also makes a **deliberate** operator edit silently ineffective: the daemon would + keep serving the old provider with nothing to say about it. Pinning the exemption rather than the + binding fails loudly instead, and names the key that moved. +- *Treat a desktop daemon whose key never arrived as a keyless deployment.* Rejected — Finding 3 of + the 2026-09-12 review. `UserActionProof::NoKeyInstalled` means only *"this process read no valid + 32-byte digest off stdin"*, which a desktop spawn satisfies when `userActionKey` is undefined + (`stdin` is `end()`ed empty, so `hex::decode("")` yields an empty vector that is not 32 bytes) or + when the daemon's bounded 2s stdin read times out. On `main` that degradation was loud and safe. + Letting it inherit the exemption would have turned a repairable fault into a silent relaxation + announced by one `WARN` in a log nobody reads. The launcher declares its intent instead, which is + the only signal that can tell the two apart, and the declaration can make this daemon *stricter* + only — so reading it from the environment is safe even though the model can see it. +- *Let a keyless daemon treat its configured model as the capability of any request that states + none.* Rejected. An absent header resolving to Public is the fail-safe the reach gate is built on, + and a default that raised it would speak for every caller rather than for the client that says + what it runs. + +**Consequence to accept.** On a keyless daemon whose configured model *is* private, a model running +in a chat on that daemon — a public-model chat resumed from the shared session store — that has +recovered the daemon secret can start a private-capability chat through `/agent/start` with +extensions it chose, and can reach private chats by stating the host's provider. It could already +do the first through `workspace_open { new }`, and the second by spelling a provider name (the +header is not authentication, as `session_reach.rs` records); and the filesystem read-deny that +would stop it carrying anything back out did not ship. It is recorded rather than closed: on a +daemon that cannot tell a person from a model, closing it means refusing the person. What the pin +changes is that this is now confined to the tier the operator *launched* the daemon on — a public +deployment cannot be turned into a private-capability one from inside a chat. + +⚠ **And the pin is not DR-14.** It closes one door's stated guarantee, not the general property a +reader might take from it. A shell is still a shell: a model that can write `config.yaml` can read +the session store (`~/.config/biorouter/sessions/`) and the knowledge bases +(`~/.config/biorouter/knowledge/`) as ordinary files, and can start a second `biorouterd` of its +own with any configuration it likes. The privacy barrier is safety before it is security — it stops +mistakes reaching the wrong model — and on a machine where DR-14 is deferred, the part of this door +worth guarding is the Gate C / Gate E *roster*: private connectors (UCSF OMOP, CDW, SPOKE) whose +credentials live in the operating system's credential store rather than on disk. That is what the +pin protects, and it is the honest scope of the claim. + +And one visible change: a browser tab on a host configured with a private model now opens private +chats started in the desktop application on the same machine, which it was refused before. That is +the reach rule — *the caller's capability must be at least the chat's classification* — admitting +it, exactly as it admits `biorouter session` configured with the same model. On a host configured +with a public model nothing changes, and private chats stay out of the browser's reach. + +## SD-13 — `biorouter web` serves no transcripts, and opens no private chat it did not start + +**Ruling (2026-09-11).** The deprecated `biorouter web` command no longer serves +`GET /api/sessions` or `GET /api/sessions/{id}`. The one way into a chat it keeps — a WebSocket +message, which runs a turn in whichever chat it names — is judged before anything touches that +chat. The page is a **public** caller, except in a chat this server started itself through +`GET /`, where it holds the tier of the provider the server was started on. A chat it may not +reach is refused with one sentence, identical for a private chat and for an id that names +nothing. + +**Why.** Both routes predate the privacy tiers (issue #56) and never learned them. The list +returned every user and scheduled chat on the machine with its title and working directory; the +transcript route returned any chat's full conversation, private ones included. The only +credential in front of them was the page's own, and it held nothing back: + +- **Without `--auth-token`** — the default, and all a loopback bind requires — the auth + middleware lets every request through, so anything that can reach the port reads every chat. + A model with a shell does it with `curl`. +- **With `--auth-token`**, the token is a command-line argument. Any process running as the same + user reads it with `ps -axww -o args` (measured on macOS), and on Linux `/proc//cmdline` + is readable by every user unless `/proc` is mounted with `hidepid`. That is + [AR-11](../security/privacy-tiers-execution-plan.md#ar-11--amended-by-dr-17--the-daemons-own-api-secret-is-recoverable)'s + recovery of the daemon's secret, through a channel that is more open than the environment. + +The page read the transcript route for a message count and a tab title, and never read the list. +Gating them would have kept two routes nobody needed, so both were deleted. + +The WebSocket could not be deleted, because it is the chat; it is gated instead. It was the +larger way in, and it was open as well. A message naming a private chat started anywhere else +ran a turn there — Gate B rebinds the one shared agent to the private model that chat's row +names — and streamed the reply, which can quote the whole conversation, back to whoever held +the socket. That is the daemon's `POST /reply` under another name, and `/reply` heads the +daemon's gated list because it dominates every read route. Deleting the transcript route alone +would have closed the smaller way in and left this one. + +**How the page's capability is decided.** Nothing on the socket names the model or the person on +the other end, so the page is a public caller, which is also how the daemon treats a caller that +states no capability. A chat this server started is the exception, reached at the tier of the +provider the server was started on. Without it, a server on a private model would give one reply +per chat: the first reply ratchets the chat to private, and the next message would be refused. +On a public model the exception changes nothing, so a chat this server started that was taken +private somewhere else is refused like any other. + +**Displaced alternatives.** + +- *Gate the two routes: list public chats only, and refuse a private transcript.* Rejected. It + keeps a list nothing reads and a transcript the page never showed, and every route kept is one + more place the reach rule has to be right. +- *Give the page the server's tier for every chat.* Rejected. On a private model, any process + that can reach the port — a public-model chat's shell included — would reach every private + chat on the machine without stating anything. The daemon's residual at least requires the + caller to name a private provider. +- *Refuse every private chat.* Rejected. It breaks the command on the second message of every + chat for exactly the operator who chose a private model. + +**What this is NOT.** It is not authentication. The page's credential is still within any local +process's reach — served to whoever can reach the port without `--auth-token`, read from argv +with it — so a local process can still drive public chats and the chats this server started, as +it could before; issue #47 is unchanged. Nothing that was refused before is permitted now: the +change removes two routes and refuses turns, and grants nothing. + +**Consequence to accept.** A chat is known as started here only for the life of the process. +After a restart it counts as started elsewhere, and a private one must be continued in the +desktop app. The page also stops showing "Session resumed: N messages loaded", because that +count came from the transcript route. Implemented in `crates/biorouter-cli/src/commands/web.rs` +(`turn_reach`, `page_capability` and `refuse_turn_unless_reachable`) and pinned by that module's +tests, three of which drive the real router and WebSocket handler over a socket, against a real +session store. + +### The same page reflected the URL into script context + +**Ruling (2026-09-11).** `GET /session/{name}` no longer writes anything into a `", session_name +``` + +`session_name` is a path segment, so it is whatever the sender typed — behind no credential at +all on the loopback bind that requires none. A `'` ended the string literal and a `` +ended the element. `GET /session/` was served back as: + +```html +… +``` + +On this page that is not defacement. The injected script runs on the server's own origin, reads +`data-ws-token` out of the very document it was injected into, opens `/ws` with it, and sends a +message to an agent that holds `developer__shell`. WebSockets are not subject to the same-origin +policy, so that token is the only thing standing between a drive-by page and the socket — and the +injection is handed it. One link the operator clicks is remote code execution as the operator. + +**Displaced alternatives.** + +- *HTML-escape the value inside the ` + +