From 0dcc5095fbebd0ae53f6253fca33a8115ce6cb30 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 02:30:50 -0700 Subject: [PATCH 01/15] fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base QA on merged main 7c96d796 (2026-09-10) measured four places where a caller holding nothing but the daemon secret - which a public chat's shell recovers with ps eww - was answered by the daemon while the tool path refused it: - H2: every /knowledge/bases/{id}/... route served a private base's pages, graph, history, location and a .brkb export; GET /knowledge/bases listed it. - M1: GET /sessions returned every chat on the machine, private ones titled. - M2: GET /agent/tools?session_id= returned private-extension tool names while add_extension on the same chat refused. - F0: DELETE /sessions/{id} deleted a private chat the read refused, 4 of 4. Each is now the singular read's own gate (session_reach's pure decision), composed rather than re-derived: - Knowledge bases: one route_layer (session_reach::gate_knowledge_base) on a sub-router holding exactly the routes that name a base by {id}, reads and writes alike. An absent or malformed id is answered as a private one. The bases list and /knowledge/active omit what the caller cannot reach, and a selection write cannot move what its caller cannot see (KnowledgeService::set_selection_within). - Chats: session_reach on DELETE, rename, workflow values, the in-place edit arm (it truncates), extensions, usage, /agent/tools, callable_tool_count, /workflows/create and /skills/session; every refusal is GET /sessions/{id}'s byte for byte. GET /sessions, /sessions/sidebar and /schedule/{id}/sessions filter to the rows that gate admits (the sidebar scans so paging stays whole). Conversation ingest checks every named chat. - biorouter serve: its own interface (the served document's cookie) keeps the operator's configured-provider tier on listings and knowledge bases, which were open to it before; the transcript gate never reads it. Nothing refused before is permitted now. Tests show each route failing before and passing after; the wiring census names every new call site. --- crates/biorouter-mcp/src/knowledge/service.rs | 195 ++- crates/biorouter-server/src/auth.rs | 87 ++ crates/biorouter-server/src/commands/agent.rs | 43 + crates/biorouter-server/src/routes/agent.rs | 55 + .../biorouter-server/src/routes/knowledge.rs | 147 +- .../biorouter-server/src/routes/schedule.rs | 22 +- crates/biorouter-server/src/routes/session.rs | 328 ++++- .../src/routes/session_reach.rs | 1273 ++++++++++++++++- crates/biorouter-server/src/routes/skills.rs | 17 + crates/biorouter-server/src/routes/web_ui.rs | 35 +- .../biorouter-server/src/routes/workflow.rs | 58 +- .../tests/knowledge_routes.rs | 653 ++++++++- .../tests/serve_operator_reach.rs | 193 +++ .../biorouter/tests/privacy_guard_wiring.rs | 179 ++- 14 files changed, 3129 insertions(+), 156 deletions(-) create mode 100644 crates/biorouter-server/tests/serve_operator_reach.rs 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 29470f71e..b89b5b015 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, + } +} + fn get_failed_attempts() -> &'static Mutex>> { FAILED_ATTEMPTS.get_or_init(|| Mutex::new(HashMap::new())) } @@ -704,6 +776,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 e9e784051..75f42d6cd 100644 --- a/crates/biorouter-server/src/commands/agent.rs +++ b/crates/biorouter-server/src/commands/agent.rs @@ -70,6 +70,34 @@ async fn read_user_action_digest() -> Option<[u8; 32]> { <[u8; 32]>::try_from(bytes.as_slice()).ok() } +/// 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() -> Result<()> { crate::logging::setup_logging(Some("biorouterd"))?; @@ -173,6 +201,21 @@ pub async fn run() -> 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-9): 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!( diff --git a/crates/biorouter-server/src/routes/agent.rs b/crates/biorouter-server/src/routes/agent.rs index 8dc1604f8..ab588e1f3 100644 --- a/crates/biorouter-server/src/routes/agent.rs +++ b/crates/biorouter-server/src/routes/agent.rs @@ -1114,6 +1114,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") @@ -1122,6 +1126,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); @@ -1248,12 +1280,35 @@ 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: the same refusal, word for \ + word, that `GET /sessions/{session_id}` gives (body = plain \ + text)"), (status = 424, description = "Agent not initialized") ) )] async fn get_callable_tool_count( State(state): State>, Query(query): Query, + headers: axum::http::HeaderMap, +) -> axum::response::Response { + // Issue #56, QA 2026-09-10 — M2's sibling: the same named chat, and the + // same agent minted for it below, so the same gate before either. + 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, StatusCode> { let session_id = query.session_id; let child_initializing = biorouter::agents::subagent_handle::is_child_initializing(&session_id); 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/schedule.rs b/crates/biorouter-server/src/routes/schedule.rs index f9500e6f7..818cbe8e9 100644 --- a/crates/biorouter-server/src/routes/schedule.rs +++ b/crates/biorouter-server/src/routes/schedule.rs @@ -373,7 +373,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 +386,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, 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 09dbb875e..abcc081ca 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -175,10 +175,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. /// @@ -300,6 +303,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 @@ -343,6 +391,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 @@ -488,6 +565,124 @@ pub async fn session_reach( ) } +/// 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-9, 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 { + refuse_unless_reachable( + self.enforced, + TargetTier::from(classification), + 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. /// @@ -570,6 +765,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::*; @@ -1080,6 +1322,9 @@ 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"); for (src, func, gate_call, first_touch, what) in [ ( reply_rs, @@ -1138,6 +1383,85 @@ 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", + ), ] { let handler = body_of(src, func); let gate = handler.find(gate_call).unwrap_or_else(|| { @@ -1164,10 +1488,12 @@ 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, and `get_session_extensions` is on - // the module header's open residual. + // comment here said otherwise until 2026-09-04. `interrupt` requires + // the user's proof instead of reach, so it is a genuinely ungated + // control. `get_session_extensions` was this file's other one until + // QA's 2026-09-10 sweep gated it; `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, @@ -1175,7 +1501,8 @@ mod tests { // over-reads towards the other. for (src, control) in [ (reply_rs, "pub async fn interrupt"), - (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: @@ -1195,6 +1522,250 @@ mod tests { } } + // ─── 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 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)); + } + + /// 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)); + } + + /// ⚠ **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 @@ -1211,6 +1782,39 @@ mod tests { "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)] @@ -2172,39 +2776,652 @@ 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( + // + // ⚠ 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}" + ); + } + } + + // ─── 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(), - serde_json::json!({ "session_id": public.id(), "primary_kb": NO_SUCH_KB }), - None, + "POST", + "/workflows/create", + Some(serde_json::json!({ "session_id": empty.id() })), + &[PROOF], ) .await; - assert_eq!( - status, - StatusCode::BAD_REQUEST, - "the layer refused an unproven caller on a PUBLIC chat: {body}" + 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!( - body.contains(NO_SUCH_KB), - "this 400 did not come from `set_selection`: only it echoes the kb id: {body}" + state.peek_agent(child.chat.id()).await.is_none(), + "a refused caller still materialised an agent for the chat" ); + } - // 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, + /// 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!( - status, - StatusCode::BAD_REQUEST, - "the gate refused a request that names no chat at all: {body}" + rows.len(), + deduped.len(), + "a filtered page repeated a row: {rows:?}" ); - assert!( - body.contains(NO_SUCH_KB), - "this 400 did not come from `set_selection`: only it echoes the kb id: {body}" + 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/{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}" + ); + } + } + + /// 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, + } } } 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 f452c422b..ba509fea8 100644 --- a/crates/biorouter-server/src/routes/web_ui.rs +++ b/crates/biorouter-server/src/routes/web_ui.rs @@ -34,11 +34,21 @@ //! 4. From then on the application presents `X-Secret-Key` exactly as the //! desktop renderer does, and every API route is guarded exactly as before. //! -//! **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-9. //! //! # Why there is no brute-force throttle here //! @@ -186,6 +196,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 @@ -214,7 +237,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 7c1a9f337..5f3228a1a 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(), ) @@ -1752,10 +1789,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(), ) @@ -1766,6 +1811,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] @@ -1825,6 +1880,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. @@ -1833,6 +1890,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(), ) @@ -1881,6 +1939,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(), @@ -1899,6 +1958,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(), ) @@ -1930,6 +1990,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(), @@ -1943,6 +2004,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(), ) @@ -1969,6 +2031,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(), @@ -2385,10 +2448,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(); @@ -2415,6 +2484,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(), ) @@ -2614,7 +2684,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( @@ -2622,6 +2700,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(), ) @@ -2668,12 +2747,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(), ) @@ -2997,12 +3081,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(), ) @@ -3042,12 +3129,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(), ) @@ -3225,8 +3315,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. @@ -3624,3 +3727,545 @@ 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), + ( + "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/serve_operator_reach.rs b/crates/biorouter-server/tests/serve_operator_reach.rs new file mode 100644 index 000000000..e020839e3 --- /dev/null +++ b/crates/biorouter-server/tests/serve_operator_reach.rs @@ -0,0 +1,193 @@ +//! Issue #56, QA 2026-09-10 (SD-9): 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-9), 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-9 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/tests/privacy_guard_wiring.rs b/crates/biorouter/tests/privacy_guard_wiring.rs index ae20fc417..354df44f0 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -318,11 +318,28 @@ const REGISTRY: &[Guard] = &[ // read as refs-only and stand out. Site { file: "crates/biorouter-server/src/routes/agent.rs", - counts: c(4, 4, 0), + 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", + 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", + }, + 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", @@ -339,13 +356,42 @@ const REGISTRY: &[Guard] = &[ session, plus the explicit continuation takeover and group-abandon \ recovery mutation", }, + Site { + file: "crates/biorouter-server/src/routes/schedule.rs", + counts: c(0, 1, 0), + kind: SiteKind::Unrelated, + what: "the MODULE qualifier on `session_reach::http_caller`, which filters \ + `GET /schedule/{id}/sessions` — a listing, gated by `lists_session`, not \ + by this function", + }, 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", @@ -412,10 +458,129 @@ const REGISTRY: &[Guard] = &[ status: Status::WiredThrough("session_reach"), sites: &[Site { file: SESSION_REACH, - counts: c(1, 0, 0), + counts: c(3, 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::lists_session` (a \ + listing is the rows this decision admits, one at a time) and \ + `HttpCaller::reach_knowledge_base` (the same decision with a knowledge \ + base as the target). ONE decision, three 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/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(1, 0, 0), + kind: SiteKind::Guard, + what: "`GET /schedule/{id}/sessions`, a schedule's runs by name and directory", + }, + 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: "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 { From 936f7f8b272e942d42e971abc66af278af594fe1 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 10:15:20 -0700 Subject: [PATCH 02/15] fix(desktop): send the user's proof on every call the reach gate now covers The daemon now answers a request without X-User-Action as a public model on every route that names a chat or a knowledge base (previous commit), so the desktop - the person at the keyboard - says so on each of them: - Knowledge view: knowledgeFetch and the ingest/lint SSE stream attach the proof centrally; listBases, getActive, getGraph, getLocation, getPageBody, previewState, listHistory, restoreState, getKbTier and deleteBase carry it. The listing matters twice: KnowledgeContext prunes its selection against it, so a filtered list would read as deleted bases. - Chats: the session list cache, sidebar, first-run privacy notice, delete, rename, workflow values, in-place edit, extensions, usage, tool count, skills, schedule runs and create-workflow. - History's Export also sends it: the export route was gated in an earlier sweep and exporting a private chat from History had no proof to show. sessionListCache captures include_subagents before the proof's async hop, so an orphaned request still asks for the list it was issued for. Tests that pinned exact call arguments now assert the proof is sent. Docs: privacy-tiers.md records what shipped and what did not change; the execution plan's 'the Knowledge view is the user' scope note is marked superseded and open question 15(b) answered; serve-decisions.md gains SD-9 (the served interface keeps its operator's reach on listings and knowledge bases, and gains no transcript); programmatic-session-access.md's route tables match the tree; session_reach.rs answers its open listing question. OpenAPI spec and TS client regenerated for the new 403 responses. --- CLAUDE.md | 16 +++ .../src/routes/session_reach.rs | 106 +++++++++++++++--- docs/deployment/browser-access.md | 11 +- .../deployment/programmatic-session-access.md | 51 ++++++--- docs/deployment/serve-architecture.md | 7 ++ docs/deployment/serve-decisions.md | 58 ++++++++++ docs/security/privacy-tiers-execution-plan.md | 33 +++++- docs/security/privacy-tiers.md | 37 ++++++ ui/desktop/openapi.json | 43 +++++-- ui/desktop/src/api/types.gen.ts | 59 ++++++++-- .../useSidebarSessions.test.ts | 10 ++ .../BioRouterSidebar/useSidebarSessions.ts | 4 + ui/desktop/src/components/MentionPopover.tsx | 11 +- .../src/components/alerts/useToolCount.ts | 4 + .../BottomMenuExtensionSelection.test.tsx | 9 ++ .../BottomMenuExtensionSelection.tsx | 14 ++- .../components/knowledge/KbTierControl.tsx | 6 +- .../components/knowledge/KnowledgeContext.tsx | 18 ++- .../components/knowledge/KnowledgeView.tsx | 7 +- .../knowledge/hooks/knowledgeRequest.ts | 15 +++ .../components/knowledge/hooks/useHistory.ts | 3 + .../knowledge/hooks/useIngestStream.ts | 6 + .../knowledge/hooks/useKnowledgeBases.ts | 3 +- .../knowledge/hooks/useKnowledgeGraph.ts | 9 +- .../knowledge/hooks/usePagePreview.ts | 6 + .../privacy/FirstRunPrivacyNotice.tsx | 4 + .../privacy/FirstRunPrivacyNoticeGate.tsx | 8 +- .../sessions/SessionListView.test.tsx | 16 ++- .../components/sessions/SessionListView.tsx | 8 ++ .../src/components/skills/useSkillCatalog.ts | 4 + .../components/subagent/useSubagentSession.ts | 8 +- .../CreateWorkflowFromSessionModal.tsx | 85 ++++++++------ ui/desktop/src/hooks/chatStreamStore.test.ts | 4 + ui/desktop/src/hooks/chatStreamStore.tsx | 20 +++- ui/desktop/src/hooks/useCostTracking.ts | 4 + ui/desktop/src/hooks/useWorkflowManager.ts | 4 + ui/desktop/src/schedule.ts | 4 + ui/desktop/src/utils/sessionListCache.test.ts | 23 +++- ui/desktop/src/utils/sessionListCache.ts | 23 +++- ui/desktop/src/utils/sessionNameSync.test.ts | 8 ++ ui/desktop/src/utils/sessionNameSync.ts | 4 + 41 files changed, 657 insertions(+), 116 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 17e8c1799..934e8061a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,6 +274,22 @@ 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`. + - 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-9). + - 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. diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index abcc081ca..ba65df6ec 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -24,15 +24,21 @@ //! inert there; //! * it still reaches every session-addressing route NOT on //! [the gated list](self#the-gated-list). `POST /interrupt` and `POST -//! /agent/cancel` now require user-action proof; `GET +//! /agent/cancel` now require user-action proof. `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` remain open — they name no session id in their +//! path and so enumerate, 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". `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 @@ -55,16 +61,29 @@ //! * 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; +//! * **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-9); //! * **`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,6 +168,18 @@ //! | `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. | +//! | `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` · `GET /agent/callable_tool_count` | QA M2: a private chat's private-connector tool names. Both mint an agent for the chat, so the gate runs first. The empty `session_id` of the settings page names no chat. | +//! | `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. | +//! +//! 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.** The last row reaches the gate through a helper //! rather than by naming it, which is why a scan for the literal `session_reach(` @@ -3318,6 +3349,47 @@ mod bypass_tests { } } + /// 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; + } + /// Every id the sidebar hands this caller, walking `next_offset` to the end. async fn sidebar_ids( state: &Arc, diff --git a/docs/deployment/browser-access.md b/docs/deployment/browser-access.md index 299acf6b2..16192ae90 100644 --- a/docs/deployment/browser-access.md +++ b/docs/deployment/browser-access.md @@ -184,7 +184,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. **Private** chats and knowledge bases appear in History and the Knowledge view only when the provider you configured is private, and a private chat cannot be opened from the browser at all. See [decision SD-9](serve-decisions.md#sd-9--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. | | Model and provider selection | **Not available.** See [The model is fixed before you start](#the-model-is-fixed-before-you-start). | | File and folder pickers | No native dialog. You type a path, and it is a path **on the machine running the daemon**, not on the machine holding the browser. | @@ -223,6 +223,15 @@ interface is served at the root of the daemon's own origin and nowhere else. 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 still cannot be opened from the browser. Open +it in the desktop app. The reasoning is +[decision SD-9](serve-decisions.md#sd-9--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). + ### When the interface cannot be found `serve` looks for the built interface in a fixed order, and names every location it tried when it diff --git a/docs/deployment/programmatic-session-access.md b/docs/deployment/programmatic-session-access.md index 7d976e883..8809b216b 100644 --- a/docs/deployment/programmatic-session-access.md +++ b/docs/deployment/programmatic-session-access.md @@ -175,6 +175,30 @@ 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. | + +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. + +**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 /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. | + +A browser pointed at `biorouter serve` is a special case of this, described in +[decision SD-9](serve-decisions.md#sd-9--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). ## What the header does *not* cover @@ -193,26 +217,25 @@ 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. | +| `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. | +| `GET /active_work` | Every running background job, subagent, detached turn and scheduled run. Each comes with its `sessionId` and a `title`/`detail` that carries the **shell command or task prompt**. This is content rather than metadata, and it is the most significant item on this 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. | The daemon has no principal, so none of this is a *tier* bypass in the strict sense — a caller diff --git a/docs/deployment/serve-architecture.md b/docs/deployment/serve-architecture.md index 28c0cde6e..1795f1300 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-9](serve-decisions.md#sd-9--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 f413d2bef..6d3723803 100644 --- a/docs/deployment/serve-decisions.md +++ b/docs/deployment/serve-decisions.md @@ -227,6 +227,64 @@ can never half-believe a person is reachable. --- +## SD-9 — 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. + A `serve` browser was refused every private transcript before this ruling and still is. So on a + private provider the History list shows private chats that cannot be opened from the browser, + and cannot be deleted or renamed from it either. That is SD-7's limitation, unchanged, and it + keeps deleting a chat from ever being easier than reading it. Letting the transcript gate honour + a served operator 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. +- **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 by +`crates/biorouter-server/tests/serve_operator_reach.rs`, which asserts both halves: the interface +keeps its listing and knowledge-base reach, and gains no transcript. + +--- + ## Related documentation - [Architecture of the serving path](serve-architecture.md) — how the decisions above are built. diff --git a/docs/security/privacy-tiers-execution-plan.md b/docs/security/privacy-tiers-execution-plan.md index 827830c01..bf3650f59 100644 --- a/docs/security/privacy-tiers-execution-plan.md +++ b/docs/security/privacy-tiers-execution-plan.md @@ -1393,10 +1393,13 @@ The cost, stated plainly: it; the privacy barrier does not. The app surfaces the refusal string in its `kb_result` error frame rather than failing silently, but it is a working app that stops answering for a reason the app author did not cause and cannot fix. -- **The Knowledge view itself keeps working.** `GET /knowledge/bases/{id}/page`, `/pages`, `/graph`, +- **The Knowledge view itself keeps working.** ~~`GET /knowledge/bases/{id}/page`, `/pages`, `/graph`, `/history`, `/preview`, `/export` are not gated (Task 10C's second ⚠): the user reading their own - notes is not a model. So the base is not *lost* — it is unreachable to models on a public - capability, and readable by hand. + notes is not a model.~~ **Since 2026-09-11 they are gated on who is asking. The Knowledge view + sends the user-action proof and still reads everything; a caller holding only the daemon secret + is refused a private base** (QA finding H2; see Task 10C's superseded ⚠). So the base is not + *lost*. It is unreachable to models on a public capability, and to anything that merely holds the + secret, and readable by hand. - The repair is the same one every other private surface offers — switch the chat to a private model — and it is discoverable, because the refusal string names it. It is still a real loss of ergonomics for a user whose default model is commercial and whose knowledge base has one private @@ -5155,7 +5158,7 @@ choke points cover everything" (they do not — they cover everything they were | `export_app` → `export_brkb` | **CP4** | Complete for the drafter's **content** door: `knowledge_service_for_export` has exactly one caller. | | A base's **id and name** — `list_platform_catalog`, `validate::check_*` rejection strings, `capability_report` | **CP5** (Task 10D) | **Found in round four, not derived in round three.** CP1–CP4 were derived over content and CP5 was not in the enumeration; both of Task 10C's new-surface detectors are structurally blind to it, because neither pattern names `list_bases`. Task 10D adds a metadata detector. | | The no-target/no-primary error id lists — `kb_id_or_primary` `:323-341`, `resolve_target_kb` `:149-159` | **Task 10C** and **Task 11** | Same class as CP5, same blind spot, two more instances. Both were found by sweeping `session_kb_ids` callers by hand; no detector in this plan would have found either. | -| The 7 `/knowledge/*` GUI **read** routes | **nothing, by decision** | The Knowledge view is the user, not a model (Task 10C's ⚠). [Open question 15](#open-questions) records that the asymmetry is undecided in the UI. | +| The 7 `/knowledge/*` GUI **read** routes, and every other route naming a base by `{id}` | ~~nothing, by decision~~ **the HTTP reach gate, since 2026-09-11** | ~~The Knowledge view is the user, not a model (Task 10C's ⚠).~~ QA's H2 measured that premise false: a public chat's shell recovered the secret and read a private base over HTTP. `routes::session_reach::gate_knowledge_base` now covers every `{id}` route, reads and writes, as one route layer. The bases list and `/knowledge/active` omit what the caller cannot reach. The desktop's Knowledge view sends the user-action proof. | | The `/knowledge/*` **write** routes, the CLI's write commands, `soul.rs`, `reset.rs` | **nothing, by decision** | No model is involved; there is no service-level write choke point to hang a raise on (Task 10B's second exclusion list). | | Existence of a base, from a *guessed* id (`create_base`'s "already exists", `resolve_target_kb:141`) | **nothing, by decision** | DR-7 puts side channels out of scope. [AR-5](#ar-5--the-existence-of-a-private-knowledge-base-is-still-inferable). | | A **future** surface of either kind | **a detector, not a construction** | Task 10C's Step 5 has two content detectors (expect 4 and 4); Task 10D's Step 5 has the metadata one, in **two** sweeps (27 hits / 18 production outside `knowledge/`, 22 / 5 inside it — the second added after a single-sweep version proved structurally unable to see `kb_get_active`), plus the metadata register, which classifies *tools* rather than call sites. All are counted enumerations that fail when they grow. That is a tripwire, not coverage. | @@ -7292,6 +7295,26 @@ out of their own notes with no model involved anywhere. The four macro routes ** because those run a model. If you find yourself adding a check to `get_page_body` or `list_pages`, stop: that is a different product decision and it is [Open question 15](#open-questions). +> ⚠ **SUPERSEDED 2026-09-11. The routes are gated now, and the reason this note gave was the defect.** +> The note assumed that a request carrying the daemon secret came from the user. AR-11 had already +> measured that secret to be recoverable from inside the daemon, and DR-17 left the filesystem +> open. On merged `main` at `7c96d796`, QA drove it end to end (finding H2): a public chat's shell +> ran `ps eww`, took the secret, and `curl`ed `/knowledge/bases/{id}/page` for a private base's page. +> The note also clashed with the tier route beside it, which already treated a caller holding only +> the secret as "not a human" for changing a tier (AR-11/AR-15). +> +> The user is now told apart the way every other private surface tells them apart: by the +> user-action proof the desktop sends, or by the private capability a program states +> (`X-Caller-Provider`). One route layer, `routes::session_reach::gate_knowledge_base`, covers every +> route that names a base by `{id}`, **reads and writes alike**, so a caller that may not read a base +> cannot rewrite, restore or delete it either. `GET /knowledge/bases` and `/knowledge/active` omit +> what the caller cannot reach. The Knowledge view still reads everything, because it sends the +> proof. A `biorouter serve` browser keeps its operator's reach under SD-9. So "a barrier there +> would lock a user out of their own notes" did not come true: the barrier is on the caller who +> proves nothing, and the user proves it on every request. Half (b) of +> [Open question 15](#open-questions) is answered by this. Record: +> [`privacy-tiers.md`](privacy-tiers.md#shipped), the knowledge-base tier entry. + - [ ] **Step 1: Write the failing tests** ```rust @@ -22223,7 +22246,7 @@ independent follow-ups. | **12** | **Does `ensure_privacy_schema` co-landing with BR-71 need a merge-order decision?** Both branches add `parent_session_id`; both would take migration 17. The shape-guarded arm plus the unconditional reconcile makes either order safe **in the database**, but the two diffs conflict textually in `session_manager.rs`. Resolution guidance: take either side — the columns are identical — and keep the **shape-guarded** form. | Task 6, and BR-71 Task 1. | | **13** | **Does `medcp`'s continued reachability need a first-run notice, or is the badge enough?** §13.5 specifies a one-time notice naming any **enabled** extension that is Public and declares clinical-looking credentials. On the operator's machine that names exactly one extension, `medcp`, and nothing else changes. | Task 38's notice copy. Hard-code that expectation into its test fixture. | | **14** | **How does `memory`'s local store get a tier?** AR-3: `compose_instructions` (`memory/mod.rs:277`) inlines local memories in full (`:310-322`) into every session opened in that directory, including one on a public model, and Task 19 ships only a disclosure. The design's §9.3 B3 names the fix — "classify memory entries and filter `retrieve_all` by the session's capability tier at init" — but the on-disk format carries no provenance (`:387-388` writes a `# {tags}` line and bare lines; `:414-418` reads them back keyed by the *tag string*), and `compose_instructions` runs once at `MemoryServer::new` (`:108`) rather than per turn, so a naive capability filter there freezes across a mid-session model swap — the O6 hazard. A real fix needs per-entry provenance **and** a per-turn recompute. | Nothing in this plan. Open it as a follow-up issue at Task 40 Step 6. | -| **15** | **(a) ANSWERED by [DR-18](#dr-18--the-knowledge-base-tier-is-user-controllable-and-a-private-session-creates-a-private-base) — half (b) still open.** ~~**Does a knowledge base need a declassification path,~~ and does the barrier belong on the GUI's own read routes?** Two halves of the same scope question. (a) AR-1: a session can be declassified (Task 29, user-only, graded, audited) and a KB cannot, so a user who ratchets their only base by accident has no in-product exit.~~ **It does, and it has one: [Task 29A](#task-29a-knowledge-base-publicize--privatize--user-only-graded-audited).** Half (b) is untouched and still open. (b) Task 10C gates the four `/knowledge/*` **macro** routes (they run a model) and deliberately leaves the GUI's read routes alone (the Knowledge view is the user, not a model) — a defensible line, but it means the *app* shows a private base that the *agent* in the next tab cannot read, and nobody has decided whether that asymmetry should be visible in the UI. | (a) is now [DR-18](#dr-18--the-knowledge-base-tier-is-user-controllable-and-a-private-session-creates-a-private-base) and [Task 29A](#task-29a-knowledge-base-publicize--privatize--user-only-graded-audited). (b) remains a follow-up — and [Task 29A](#task-29a-knowledge-base-publicize--privatize--user-only-graded-audited) makes the asymmetry *more* visible, not less, because the Knowledge view now shows a tier chip the agent obeys and the read routes do not. | +| **15** | **(a) ANSWERED by [DR-18](#dr-18--the-knowledge-base-tier-is-user-controllable-and-a-private-session-creates-a-private-base); (b) ANSWERED 2026-09-11 (QA H2 — see the last column).** ~~**Does a knowledge base need a declassification path,~~ and does the barrier belong on the GUI's own read routes?** Two halves of the same scope question. (a) AR-1: a session can be declassified (Task 29, user-only, graded, audited) and a KB cannot, so a user who ratchets their only base by accident has no in-product exit.~~ **It does, and it has one: [Task 29A](#task-29a-knowledge-base-publicize--privatize--user-only-graded-audited).** Half (b) is untouched and still open. (b) Task 10C gates the four `/knowledge/*` **macro** routes (they run a model) and deliberately leaves the GUI's read routes alone (the Knowledge view is the user, not a model) — a defensible line, but it means the *app* shows a private base that the *agent* in the next tab cannot read, and nobody has decided whether that asymmetry should be visible in the UI. | (a) is now [DR-18](#dr-18--the-knowledge-base-tier-is-user-controllable-and-a-private-session-creates-a-private-base) and [Task 29A](#task-29a-knowledge-base-publicize--privatize--user-only-graded-audited). ~~(b) remains a follow-up — and [Task 29A](#task-29a-knowledge-base-publicize--privatize--user-only-graded-audited) makes the asymmetry *more* visible, not less, because the Knowledge view now shows a tier chip the agent obeys and the read routes do not.~~ **(b) ANSWERED 2026-09-11: yes, for a caller without the user's proof.** The read routes had no barrier on the premise that the Knowledge view is the user. QA's H2 showed that a caller holding only the recoverable secret was treated as that user. The routes now take the reach gate that `GET /sessions/{id}` takes. The desktop, which sends the proof, sees what it always saw, so the app and the agent in the next tab still differ — as the user and a public model should. See Task 10C's superseded ⚠. | | **17** | ✅ **RESOLVED by [DR-17](#scope-ruling--dr-17-narrows-this-plan-to-the-session-store) — the question has no subject.** There is no Linux read-deny in v1, so there is nothing for Landlock to express and no `bubblewrap` dependency to remove. The analysis is kept because it is the measured reason Landlock cannot do this at all, and a revival would otherwise re-derive it. ~~**Should Linux get a Landlock read-deny by granting the complement?** Landlock has no deny rule, so hiding a subpath means handling read accesses and granting read to every sibling of every ancestor of every deny root. Task 14A declines it in v1 for three measured reasons, the disqualifying one being that anything created in an enumerated ancestor *after* the ruleset is built is unreadable for that command's lifetime — `cd ~ && mkdir out && echo x > out/f && cat out/f` fails.~~ | **Nothing — Task 14A is deferred.** Previously: Task 14A makes `bubblewrap` the only Linux mechanism that can express the read-deny, and the refusal names `apt install bubblewrap` as the fix. A Landlock complement would remove that dependency; it needs a real ergonomics trial on a populated `$HOME` before it is worth the failure mode. | | **18** | ⚠ **WIDENED by [DR-17](#scope-ruling--dr-17-narrows-this-plan-to-the-session-store), not resolved.** DR-14 used to remove two of the three local sources of an app id; with the barrier deferred, **all three are open again** — `GET /apps` needs only the secret, the app tree is an ordinary directory, and `agent_drafter__list_apps` is unfiltered because Task 14E is deferred. So any loopback client that can list apps can drive any app's agent socket with no credential. This is squarely inside DR-17's accepted risk and inside [Task 30A](#task-30a-the-non-private-model-disclosure)'s disclosure. Original text: **Should the per-app agent WebSocket be authenticated by something a shell cannot obtain?** `GET /apps/{id}` and `GET /apps/{id}/agent` are deliberately unauthenticated (`auth.rs:52-78`), and `serve_index` (`apps.rs:168-184`) embeds the socket token in the page it serves, so any loopback client that knows an app id can read the token and drive that app's agent. ⚠ **There are THREE local sources of app ids, not two, and this row said two until this round.** DR-14 removes the first two — `GET /apps` needs the secret, and the app tree is deny root #4 — but the third is `agent_drafter__list_apps` (`agent_drafter/mod.rs:2636` → `ArtifactStore::list`, `store.rs:606`), a tool on a **public** extension that takes no path argument, so neither Layer A nor a filesystem deny can see it. Task 14C withdrew that premise; this row had not caught up. What Task 14E changes is narrower than "removes": a public-capability session's `list_apps` no longer returns a **private** app's id, so what stays reachable is that any loopback client — including a public-capability session — can drive a **public** app's agent socket with no credential at all. | Nothing in this plan; the residual is stated in [AR-6](#ar-6--retired-by-dr-17--on-a-host-that-cannot-express-the-read-deny-a-public-session-loses-the-shell-and-two-costs-come-with-the-sandbox-itself) and pinned by Task 14C's `the_unauthenticated_app_surface_does_not_grow_by_accident`. | | **20** | ⚠ **WIDENED by [DR-17](#scope-ruling--dr-17-narrows-this-plan-to-the-session-store), not resolved.** Layer A used to cover the biggest local route, `POST /agent/call_tool`; with it deferred, that route is covered by **Gate C** for private *extensions* and by nothing for private *paths*. The route list below is unchanged and is now the full extent of what a local caller holding the secret can read. Original text: **Should the daemon's HTTP API authenticate a caller that is on the same machine?** [AR-11](#ar-11--amended-by-dr-17--the-daemons-own-api-secret-is-recoverable): the secret is recoverable from the daemon's own environment (`ps -Ewww -p $PPID` on macOS, `/proc/self/environ` in-process on Linux), so `check_token`'s header comparison stops a remote caller and not a local one. Layer A covers the biggest local route, `POST /agent/call_tool`, because that route dispatches through the same choke point. It does **not** cover the routes that return private content without running a tool: `GET /sessions/{id}/export` and the rest of the transcript family, the `/knowledge/*` read routes, `GET /apps/{id}/export`, and `GET /diagnostics/{id}` — which returns a zip of `session.json`, recent `logs/*.jsonl` and a verbatim `config.yaml`, and is the widest single route in the API. | Nothing in this plan. Task 14C states the residual instead of the old "no way to authenticate" claim, and pins the strip so the *remote* half stays closed. Closing the local half needs a per-caller credential the daemon does not hand to its own children — the same shape as [Open question 18](#open-questions), and probably the same fix. | diff --git a/docs/security/privacy-tiers.md b/docs/security/privacy-tiers.md index 8153afa87..7b8a214ef 100644 --- a/docs/security/privacy-tiers.md +++ b/docs/security/privacy-tiers.md @@ -64,6 +64,43 @@ this section is the ledger. written to it, the ratchet fires at four write choke points, the barrier refuses at the read ones, and a refusal names what it refused rather than returning a silently short answer. The user can publicize or privatize a base themselves, graded and audited. + + ⚠ **"The read ones" were the tool path's until 2026-09-11.** Every `/knowledge/bases/{id}/…` HTTP + route was left ungated. The plan's scope note gave the reason: *"the Knowledge view is the user, + not a model"*. That premise was that holding the daemon secret meant being the user, and QA + measured it false on merged `main` at `7c96d796` (H2). A public chat's own shell recovered the + secret with `ps eww`, then read a private base's page, graph, history and `.brkb` export over + HTTP, while `kb_read_page` refused the same chat. The same run found three siblings. `GET + /sessions` listed every private chat with its title and directory (M1). `GET /agent/tools` named + a private chat's private-connector tools (M2). `DELETE /sessions/{id}` deleted a private chat the + read refused, four times out of four (F0). + + They now share **one gate**, `routes::session_reach`'s pure decision: a private target needs the + caller's stated private capability or the user-action proof. It is applied as follows. + + - **Chats.** Every route that names one chat asks `session_reach`: delete, rename, workflow values, + the in-place edit arm, extensions, usage, `/agent/tools`, `/agent/callable_tool_count`, + `/workflows/create`, `/skills/session`, and each chat `ingest-conversation` names. Each refuses + with `GET /sessions/{id}`'s own words, and answers a chat that does not exist the same way. + - **Chat listings.** `GET /sessions`, `/sessions/sidebar` and `/schedule/{id}/sessions` omit the + rows that gate would refuse. + - **Knowledge bases.** One route layer covers every route that names a base by `{id}`, reads and + writes alike. An absent or malformed id is answered as a private one. + - **Knowledge-base listings.** `GET /knowledge/bases` and `/knowledge/active` omit what the + caller cannot reach, and a selection write cannot move a base its caller cannot see. + + The desktop app sends the proof on each of these calls and sees exactly what it saw before. A + `biorouter serve` browser keeps its operator's reach on listings and knowledge bases and gains + no transcript ([SD-9](../deployment/serve-decisions.md#sd-9--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else)). + Nothing refused before is permitted now. + + ⚠ **What it does not change**, stated so it is not over-read. Privacy remains a safety boundary + for a cooperating agent, not a security boundary. A public chat with a shell can still read the + knowledge base's files and `sessions.db` directly (DR-17 left the filesystem open; see *Did not + ship*), and a caller holding the secret can still state a private provider in `X-Caller-Provider` + (issue #47). What closed is the path through Biorouter's own API. The routes still open are + listed in `routes/session_reach.rs`'s module header and + [Reaching a private chat from a script](../deployment/programmatic-session-access.md#what-the-header-does-not-cover). - **Declassification (§12), graded** — a `turn:*` chat keeps its single click; every other provenance owes both the typed phrase and R18 / DR-20's operating-system authentication, and one predicate decides both so they cannot drift apart. In the desktop app, and as diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 1a66a7468..f59304b65 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -266,6 +266,9 @@ "401": { "description": "Unauthorized - invalid secret key" }, + "403": { + "description": "Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text)" + }, "424": { "description": "Agent not initialized" } @@ -797,6 +800,9 @@ "401": { "description": "Unauthorized - invalid secret key" }, + "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)" + }, "408": { "description": "Extension timed out while loading for settings" }, @@ -1895,7 +1901,7 @@ ], "responses": { "200": { - "description": "The session's knowledge bases and its primary", + "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", "content": { "application/json": { "schema": { @@ -1939,7 +1945,7 @@ "description": "Unknown kb id, a primary outside the resulting set, or conflicting primary-KB fields" }, "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 (body = plain text)" + "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; or `primary_kb` names a knowledge base this caller may not reach, answered exactly as a base that does not exist (body = plain text)" } } } @@ -1952,7 +1958,7 @@ "operationId": "list_bases", "responses": { "200": { - "description": "List of knowledge bases", + "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.", "content": { "application/json": { "schema": { @@ -3780,7 +3786,7 @@ ], "responses": { "200": { - "description": "A list of session display info", + "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`", "content": { "application/json": { "schema": { @@ -3848,7 +3854,7 @@ ], "responses": { "200": { - "description": "List of available sessions retrieved successfully", + "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", "content": { "application/json": { "schema": { @@ -4117,7 +4123,7 @@ ], "responses": { "200": { - "description": "Paginated lightweight session summaries for the sidebar", + "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", "content": { "application/json": { "schema": { @@ -4220,6 +4226,9 @@ "401": { "description": "Unauthorized - Invalid or missing API key" }, + "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)" + }, "404": { "description": "Session not found" }, @@ -4545,6 +4554,9 @@ "401": { "description": "Unauthorized - Invalid or missing API key" }, + "403": { + "description": "Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text)" + }, "404": { "description": "Session not found" }, @@ -4596,6 +4608,9 @@ "401": { "description": "Unauthorized - Invalid or missing API key" }, + "403": { + "description": "Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text)" + }, "404": { "description": "Session not found" }, @@ -4644,6 +4659,9 @@ "401": { "description": "Unauthorized - Invalid or missing API key" }, + "403": { + "description": "Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text)" + }, "404": { "description": "Session not found" }, @@ -4699,6 +4717,9 @@ "401": { "description": "Unauthorized - Invalid or missing API key" }, + "403": { + "description": "Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text)" + }, "404": { "description": "Session not found", "content": { @@ -4999,6 +5020,9 @@ "401": { "description": "Unauthorized - invalid or missing secret key" }, + "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)" + }, "404": { "description": "No such conversation" }, @@ -5257,7 +5281,7 @@ }, "responses": { "200": { - "description": "Workflow created successfully", + "description": "Workflow created successfully. Its `knowledge_bases` names only the bases this caller may open", "content": { "application/json": { "schema": { @@ -5269,6 +5293,9 @@ "400": { "description": "Bad request" }, + "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)" + }, "412": { "description": "Precondition failed - Agent not available" }, @@ -8693,7 +8720,7 @@ } } ], - "description": "One row of `GET /knowledge/bases`: the stored manifest plus the privacy tier\n(issue #56).\n\nThe tier is **flattened alongside** the manifest rather than added to it,\nbecause `manifest.yaml` is the on-disk record and the tier lives in\n`.kb-tiers`. A `tier` field on [`Manifest`] would be persisted by the next\n`manifest::save` and become a second, staler answer to a question the tier\nstore already answers — and it would also appear on `kb_list_bases`, a\nmodel-facing tool whose payload Task 10D's metadata register governs.\n\nThis route is user-facing: the renderer is the only caller, and Task 10C\nalready removes private bases from the model's own listing entirely." + "description": "One row of `GET /knowledge/bases`: the stored manifest plus the privacy tier\n(issue #56).\n\nThe tier is **flattened alongside** the manifest rather than added to it,\nbecause `manifest.yaml` is the on-disk record and the tier lives in\n`.kb-tiers`. A `tier` field on [`Manifest`] would be persisted by the next\n`manifest::save` and become a second, staler answer to a question the tier\nstore already answers — and it would also appear on `kb_list_bases`, a\nmodel-facing tool whose payload Task 10D's metadata register governs.\n\n⚠ **\"The renderer is the only caller\" was this doc's premise, and QA\nmeasured it false on 2026-09-10 (H2):** a public chat's shell recovered the\ndaemon secret and read this list, private bases included. So the rows are\nnow the bases the caller could open — the desktop app, which sends the\nuser's proof, still sees every one, with its tier — and a private base is\nOMITTED for anyone else, as Task 10C already omits it from the model's own\nlisting: a base's id and name are user-authored content." }, "KbTier": { "type": "string", diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 68b657939..bee349467 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1561,8 +1561,13 @@ export type KbFormat = 'okf' | 'biookf'; * 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. */ export type KbListEntry = Manifest & { tier: KbTier; @@ -4381,6 +4386,10 @@ export type GetCallableToolCountErrors = { * Unauthorized - invalid secret key */ 401: unknown; + /** + * Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text) + */ + 403: unknown; /** * Agent not initialized */ @@ -4795,6 +4804,10 @@ export type GetToolsErrors = { * Unauthorized - invalid secret key */ 401: unknown; + /** + * 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) + */ + 403: unknown; /** * Extension timed out while loading for settings */ @@ -5688,7 +5701,7 @@ export type GetActiveErrors = { export type GetActiveResponses = { /** - * The session's knowledge bases and its primary + * 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 */ 200: ActiveKbResponse; }; @@ -5708,7 +5721,7 @@ export type SetActiveErrors = { */ 400: unknown; /** - * 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 (body = plain text) + * 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; or `primary_kb` names a knowledge base this caller may not reach, answered exactly as a base that does not exist (body = plain text) */ 403: unknown; }; @@ -5731,7 +5744,7 @@ export type ListBasesData = { export type ListBasesResponses = { /** - * List of knowledge bases + * 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. */ 200: Array; }; @@ -7118,7 +7131,7 @@ export type SessionsHandlerErrors = { export type SessionsHandlerResponses = { /** - * A list of session display info + * 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` */ 200: Array; }; @@ -7182,7 +7195,7 @@ export type ListSessionsErrors = { export type ListSessionsResponses = { /** - * List of available sessions retrieved successfully + * 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 */ 200: SessionListResponse; }; @@ -7379,7 +7392,7 @@ export type ListSidebarSessionsErrors = { export type ListSidebarSessionsResponses = { /** - * Paginated lightweight session summaries for the sidebar + * 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 */ 200: SidebarSessionListResponse; }; @@ -7403,6 +7416,10 @@ export type DeleteSessionErrors = { * Unauthorized - Invalid or missing API key */ 401: unknown; + /** + * 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) + */ + 403: unknown; /** * Session not found */ @@ -7694,6 +7711,10 @@ export type GetSessionExtensionsErrors = { * Unauthorized - Invalid or missing API key */ 401: unknown; + /** + * Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text) + */ + 403: unknown; /** * Session not found */ @@ -7734,6 +7755,10 @@ export type UpdateSessionNameErrors = { * Unauthorized - Invalid or missing API key */ 401: unknown; + /** + * Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text) + */ + 403: unknown; /** * Session not found */ @@ -7772,6 +7797,10 @@ export type GetSessionUsageErrors = { * Unauthorized - Invalid or missing API key */ 401: unknown; + /** + * Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text) + */ + 403: unknown; /** * Session not found */ @@ -7808,6 +7837,10 @@ export type UpdateSessionUserWorkflowValuesErrors = { * Unauthorized - Invalid or missing API key */ 401: unknown; + /** + * Refused by a privacy boundary: the same refusal, word for word, that `GET /sessions/{session_id}` gives (body = plain text) + */ + 403: unknown; /** * Session not found */ @@ -8012,6 +8045,10 @@ export type SetSessionSkillsErrors = { * Unauthorized - invalid or missing secret key */ 401: unknown; + /** + * 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) + */ + 403: unknown; /** * No such conversation */ @@ -8210,6 +8247,10 @@ export type CreateWorkflowErrors = { * Bad request */ 400: unknown; + /** + * 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) + */ + 403: unknown; /** * Precondition failed - Agent not available */ @@ -8222,7 +8263,7 @@ export type CreateWorkflowErrors = { export type CreateWorkflowResponses = { /** - * Workflow created successfully + * Workflow created successfully. Its `knowledge_bases` names only the bases this caller may open */ 200: CreateWorkflowResponse; }; diff --git a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.test.ts b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.test.ts index 177faad99..90d6187e1 100644 --- a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.test.ts +++ b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.test.ts @@ -14,6 +14,13 @@ vi.mock('../../api', () => ({ listSidebarSessions: mocks.listSidebarSessions, })); +// The proof the desktop sends. Since issue #56's QA sweep (2026-09-10) the +// daemon answers a request without it as a public model — private chats and +// knowledge bases omitted or refused — so each call here must carry it. +vi.mock('../../utils/userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + function makeSummary(index: number): SessionSummary { const timestamp = new Date(Date.parse('2026-07-15T12:00:00.000Z') - index * 60_000).toISOString(); return { @@ -61,6 +68,7 @@ describe('useSidebarSessions', () => { expect(result.current.hasMore).toBe(true); expect(mocks.listSidebarSessions).toHaveBeenNthCalledWith(1, { query: { limit: 10, offset: 0 }, + headers: { 'X-User-Action': 'test-proof' }, throwOnError: true, }); @@ -70,6 +78,7 @@ describe('useSidebarSessions', () => { expect(result.current.hasMore).toBe(false); expect(mocks.listSidebarSessions).toHaveBeenNthCalledWith(2, { query: { limit: 10, offset: 10 }, + headers: { 'X-User-Action': 'test-proof' }, throwOnError: true, }); }); @@ -107,6 +116,7 @@ describe('useSidebarSessions', () => { expect(result.current.sessions).toHaveLength(20); expect(mocks.listSidebarSessions).toHaveBeenNthCalledWith(3, { query: { limit: 10, offset: 0 }, + headers: { 'X-User-Action': 'test-proof' }, throwOnError: true, }); }); diff --git a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.ts b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.ts index 4acef7279..9cfdf5d4f 100644 --- a/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.ts +++ b/ui/desktop/src/components/BioRouterSidebar/useSidebarSessions.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { listSidebarSessions, type SessionSummary } from '../../api'; +import { userActionHeaders } from '../../utils/userAction'; import { subscribeSessionNameChanges } from '../../utils/sessionNameSync'; import { subscribeSessionListChanges } from '../../utils/sessionListCache'; @@ -50,8 +51,11 @@ export default function useSidebarSessions(): SidebarSessionsState { setIsLoading(true); try { + // With the user's proof: without it the daemon pages a view with every + // private chat omitted (issue #56, QA 2026-09-10 M1). const response = await listSidebarSessions({ query: { limit: SIDEBAR_SESSION_PAGE_SIZE, offset }, + headers: await userActionHeaders(), throwOnError: true, }); const page = response.data; diff --git a/ui/desktop/src/components/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx index b49b56b7f..e06972434 100644 --- a/ui/desktop/src/components/MentionPopover.tsx +++ b/ui/desktop/src/components/MentionPopover.tsx @@ -11,6 +11,7 @@ import { createPortal } from 'react-dom'; import { ItemIcon } from './ItemIcon'; import BuiltInBadge from './ui/BuiltInBadge'; import { CommandType, getActive, getSessionExtensions, getSlashCommands, listBases } from '../api'; +import { userActionHeaders } from '../utils/userAction'; import type { CatalogView } from '../api'; import { getInitialWorkingDir } from '../utils/workingDir'; import { IMAGE_EXTENSIONS } from '../utils/imageFormats'; @@ -543,14 +544,20 @@ const MentionPopover = forwardRef< const loadReferenceItems = useCallback( async (includeCommands: boolean) => { + // The user's proof on the three reads below that name a chat or its + // knowledge bases: since issue #56's QA sweep (2026-09-10) a request + // without it is answered as a public model, and would be shown no + // private base and no private chat's extensions. + const headers = await userActionHeaders(); const [commandsResponse, basesResponse, activeResponse, skillsResult, sessionExtensions] = await Promise.all([ includeCommands ? getSlashCommands({ throwOnError: true }) : Promise.resolve({ data: { commands: [] } }), - listBases({ throwOnError: false }), + listBases({ headers, throwOnError: false }), getActive({ query: sessionId ? { session_id: sessionId } : undefined, + headers, throwOnError: false, }), // The daemon's catalog, not a renderer scan: a skill bundled inside @@ -560,7 +567,7 @@ const MentionPopover = forwardRef< () => ({ generation: 0, roots: [], skills: [], bundles: [] }) as CatalogView ), sessionId - ? getSessionExtensions({ path: { session_id: sessionId } }).catch(() => null) + ? getSessionExtensions({ path: { session_id: sessionId }, headers }).catch(() => null) : Promise.resolve(null), ]); const commandItems: DisplayItem[] = (commandsResponse.data?.commands || []) diff --git a/ui/desktop/src/components/alerts/useToolCount.ts b/ui/desktop/src/components/alerts/useToolCount.ts index ed9ee2834..bdb942b93 100644 --- a/ui/desktop/src/components/alerts/useToolCount.ts +++ b/ui/desktop/src/components/alerts/useToolCount.ts @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { getCallableToolCount } from '../../api'; +import { userActionHeaders } from '../../utils/userAction'; import { CATALOG_CHANGED_EVENT } from '../../utils/catalogSubscription'; import { SESSION_TOOLS_CHANGED_EVENT, @@ -35,8 +36,11 @@ export const useToolCount = (sessionId: string, agentReady: boolean = true) => { controller?.abort(); controller = new AbortController(); try { + // With the user's proof: a private chat refuses this read to a caller + // without it (issue #56, QA 2026-09-10 M2). const response = await getCallableToolCount({ query: { session_id: sessionId }, + headers: await userActionHeaders(), signal: controller.signal, }); if (cancelled || requestRevision !== revision) return; diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.test.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.test.tsx index 9c213dbdd..f95f60a40 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.test.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.test.tsx @@ -106,6 +106,13 @@ vi.mock('../../api', () => ({ getSessionExtensions: mocks.getSessionExtensions, })); +// The proof the desktop sends. Since issue #56's QA sweep (2026-09-10) the +// daemon answers a request without it as a public model — private chats and +// knowledge bases omitted or refused — so each call here must carry it. +vi.mock('../../utils/userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + vi.mock('../settings/extensions/agent-api', () => ({ addToAgent: mocks.addToAgent, removeFromAgent: mocks.removeFromAgent, @@ -355,6 +362,7 @@ describe('BottomMenuExtensionSelection', () => { ); expect(mocks.getSessionExtensions).toHaveBeenLastCalledWith({ path: { session_id: 'session-1' }, + headers: { 'X-User-Action': 'test-proof' }, }); await waitFor(() => expect(example).toHaveAttribute('aria-checked', 'true')); expect(screen.getByLabelText('Manage extensions (1 enabled)')).toBeInTheDocument(); @@ -400,6 +408,7 @@ describe('BottomMenuExtensionSelection', () => { ); expect(mocks.getSessionExtensions).toHaveBeenLastCalledWith({ path: { session_id: 'session-1' }, + headers: { 'X-User-Action': 'test-proof' }, }); await waitFor(() => expect(screen.getByLabelText('Manage extensions (1 enabled)')).toBeInTheDocument() diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 43aaa14fa..d964f7527 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -19,6 +19,7 @@ import { isBuiltInExtension, } from '../settings/extensions/subcomponents/ExtensionList'; import { ExtensionConfig, getSessionExtensions } from '../../api'; +import { userActionHeaders } from '../../utils/userAction'; import type { SessionClassification } from '../../api/types.gen'; import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; import { extensionPairingRefused } from '../settings/extensions/extensionPrivacy'; @@ -140,8 +141,11 @@ export const BottomMenuExtensionSelection = ({ } try { + // With the user's proof: a private chat's extensions are refused to a + // caller without it (issue #56, QA 2026-09-10). const response = await getSessionExtensions({ path: { session_id: sessionId }, + headers: await userActionHeaders(), }); if (current && response.data?.extensions) { @@ -214,7 +218,10 @@ export const BottomMenuExtensionSelection = ({ if (sessionToggleChainsRef.current.get(name) !== operation) return; try { - const response = await getSessionExtensions({ path: { session_id: sessionId } }); + const response = await getSessionExtensions({ + path: { session_id: sessionId }, + headers: await userActionHeaders(), + }); if (sessionToggleChainsRef.current.get(name) !== operation) return; if (response.data?.extensions) { setSessionExtensions(response.data.extensions); @@ -425,7 +432,10 @@ export const BottomMenuExtensionSelection = ({ : removeFromAgent(ext.name, sessionId, true) ) ); - const response = await getSessionExtensions({ path: { session_id: sessionId } }); + const response = await getSessionExtensions({ + path: { session_id: sessionId }, + headers: await userActionHeaders(), + }); if (response.data?.extensions) { setSessionExtensions(response.data.extensions); setSessionExtensionsLoaded(true); diff --git a/ui/desktop/src/components/knowledge/KbTierControl.tsx b/ui/desktop/src/components/knowledge/KbTierControl.tsx index f3f6f58f2..e6ea4ef20 100644 --- a/ui/desktop/src/components/knowledge/KbTierControl.tsx +++ b/ui/desktop/src/components/knowledge/KbTierControl.tsx @@ -167,7 +167,11 @@ export function KbTierPanel({ kb }: { kb: { id: string; name: string; tier: KbTi setRadius(null); void (async () => { try { - const res = await getKbTier({ path: { id: kb.id }, throwOnError: true }); + const res = await getKbTier({ + path: { id: kb.id }, + headers: await userActionHeaders(), + throwOnError: true, + }); if (cancelled) return; setRadius({ pageCount: res.data.page_count, diff --git a/ui/desktop/src/components/knowledge/KnowledgeContext.tsx b/ui/desktop/src/components/knowledge/KnowledgeContext.tsx index 34b513075..2ee5554d6 100644 --- a/ui/desktop/src/components/knowledge/KnowledgeContext.tsx +++ b/ui/desktop/src/components/knowledge/KnowledgeContext.tsx @@ -186,6 +186,7 @@ export function KnowledgeProvider({ try { const res = await getActive({ query: sessionId ? { session_id: sessionId } : undefined, + headers: await userActionHeaders(), throwOnError: true, }); if (generation !== selectionGenerationRef.current) return; @@ -323,7 +324,11 @@ export function KnowledgeProvider({ return; } try { - const res = await getActive({ query: undefined, throwOnError: true }); + const res = await getActive({ + query: undefined, + headers: await userActionHeaders(), + throwOnError: true, + }); setDefaultPrimaryKbId(readPrimary(res.data)); } catch (err) { // Keep the last known default: a failed read is not evidence that there @@ -373,7 +378,15 @@ export function KnowledgeProvider({ const refresh = useCallback(async () => { setLoading(true); try { - const res = await listBases({ throwOnError: true }); + // ⚠ With the user's proof, and it is load-bearing twice over. Since + // issue #56's QA sweep (2026-09-10) the daemon OMITS a private base from + // a caller without it — and the two effects below prune the selection + // against this list, so a list missing a base reads as "that base was + // deleted" and would drop it from the primary and the hidden set. The + // daemon also refuses to let such a caller move what it cannot see + // (`set_selection_within`), but the list the user is shown must be the + // whole one. + const res = await listBases({ headers: await userActionHeaders(), throwOnError: true }); setBases(res.data || []); setBasesLoaded(true); setBasesError(null); @@ -455,6 +468,7 @@ export function KnowledgeProvider({ try { const res = await getActive({ query: sessionId ? { session_id: sessionId } : undefined, + headers: await userActionHeaders(), throwOnError: true, }); if (cancelled || generation !== selectionGenerationRef.current) return; diff --git a/ui/desktop/src/components/knowledge/KnowledgeView.tsx b/ui/desktop/src/components/knowledge/KnowledgeView.tsx index 41757c7a9..ef694f668 100644 --- a/ui/desktop/src/components/knowledge/KnowledgeView.tsx +++ b/ui/desktop/src/components/knowledge/KnowledgeView.tsx @@ -14,6 +14,7 @@ import { Trash2, } from '../icons/app-icons'; import { getLocation } from '../../api'; +import { userActionHeaders } from '../../utils/userAction'; import { Button } from '../ui/button'; import { PrivacyBadge } from '../ui/PrivacyBadge'; import { EmptyState } from '../ui/empty-state'; @@ -117,7 +118,11 @@ function KnowledgeViewInner() { async function openKbFolder() { if (!primaryKbId) return; try { - const res = await getLocation({ path: { id: primaryKbId }, throwOnError: true }); + const res = await getLocation({ + path: { id: primaryKbId }, + headers: await userActionHeaders(), + throwOnError: true, + }); const path = res.data?.path; if (path) await window.electron.openDirectoryInExplorer(path); } catch (err) { diff --git a/ui/desktop/src/components/knowledge/hooks/knowledgeRequest.ts b/ui/desktop/src/components/knowledge/hooks/knowledgeRequest.ts index 1d43fbb53..6a342e316 100644 --- a/ui/desktop/src/components/knowledge/hooks/knowledgeRequest.ts +++ b/ui/desktop/src/components/knowledge/hooks/knowledgeRequest.ts @@ -1,4 +1,5 @@ import { client } from '../../../api/client.gen'; +import { userActionHeaders } from '../../../utils/userAction'; type ElectronBridge = { getBiorouterdHostPort?: () => Promise; @@ -42,12 +43,26 @@ export async function buildKnowledgeUrl(path: string): Promise { return `${await getBackendBaseUrl()}${path}`; } +/** + * A request to the daemon's `/knowledge/*` routes, as the Knowledge view makes + * it: with the secret, and with the user-action proof. + * + * Issue #56, QA 2026-09-10 H2: every route that names a knowledge base now + * answers a caller WITHOUT the proof as a public model — a private base is + * refused and omitted from listings — because a public chat's shell could + * recover the secret and read private bases with it. The desktop is the person + * at the keyboard, so it says so on every knowledge request; a request that + * forgot would see private bases vanish, not an error. + */ export async function knowledgeFetch(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers ?? {}); const secret = await getSecretKey(); if (secret) { headers.set('X-Secret-Key', secret); } + for (const [name, value] of Object.entries(await userActionHeaders())) { + headers.set(name, value); + } return fetch(await buildKnowledgeUrl(path), { ...init, diff --git a/ui/desktop/src/components/knowledge/hooks/useHistory.ts b/ui/desktop/src/components/knowledge/hooks/useHistory.ts index 0d569d1f2..b7ad10c0e 100644 --- a/ui/desktop/src/components/knowledge/hooks/useHistory.ts +++ b/ui/desktop/src/components/knowledge/hooks/useHistory.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { listHistory, restoreState } from '../../../api'; +import { userActionHeaders } from '../../../utils/userAction'; import type { HistoryEntry, RestoreResponse } from '../../../api/types.gen'; export interface UseHistoryResult { @@ -26,6 +27,7 @@ export function useHistory(kbId: string | null): UseHistoryResult { const res = await listHistory({ path: { id: kbId }, query: { limit: 200 }, + headers: await userActionHeaders(), throwOnError: true, }); // ListHistoryResponses[200] is typed `unknown` in the generated SDK, @@ -46,6 +48,7 @@ export function useHistory(kbId: string | null): UseHistoryResult { const res = await restoreState({ path: { id: kbId }, body: { commit_sha: commitSha }, + headers: await userActionHeaders(), throwOnError: true, }); const sha = (res.data as RestoreResponse | undefined)?.new_commit_sha ?? ''; diff --git a/ui/desktop/src/components/knowledge/hooks/useIngestStream.ts b/ui/desktop/src/components/knowledge/hooks/useIngestStream.ts index 6b5c1fb93..0b454e0dc 100644 --- a/ui/desktop/src/components/knowledge/hooks/useIngestStream.ts +++ b/ui/desktop/src/components/knowledge/hooks/useIngestStream.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react'; import { buildKnowledgeUrl, getSecretKey } from './knowledgeRequest'; +import { userActionHeaders } from '../../../utils/userAction'; export type SubAgentEvent = | { kind: 'step'; index: number; assistant_text: string } @@ -74,12 +75,17 @@ export function useIngestStream() { // `cfg.headers as Record` is unreliable because // HeadersInit can be a Headers instance or a [string,string][] array. const xSecretKey = await getSecretKey(); + // The user-action proof, as `knowledgeFetch` sends it: a macro names a + // base, and since issue #56's QA sweep (2026-09-10) a request without + // the proof is answered as a public model, which a private base refuses. + const proof = await userActionHeaders(); try { const res = await fetch(url, { method: 'POST', headers: { 'X-Secret-Key': xSecretKey, + ...proof, ...(requestInit.headers ?? {}), }, body: requestInit.body, diff --git a/ui/desktop/src/components/knowledge/hooks/useKnowledgeBases.ts b/ui/desktop/src/components/knowledge/hooks/useKnowledgeBases.ts index b9be11a26..fc2ae969e 100644 --- a/ui/desktop/src/components/knowledge/hooks/useKnowledgeBases.ts +++ b/ui/desktop/src/components/knowledge/hooks/useKnowledgeBases.ts @@ -3,6 +3,7 @@ import { createBase as apiCreate, deleteBase as apiDelete } from '../../../api'; import { useKnowledge } from '../KnowledgeContext'; import type { KbFormat, Manifest } from '../../../api/types.gen'; import { knowledgeFetch } from './knowledgeRequest'; +import { userActionHeaders } from '../../../utils/userAction'; export function useKnowledgeBases() { const { refresh, setPrimaryKbId, primaryKbId } = useKnowledge(); @@ -62,7 +63,7 @@ export function useKnowledgeBases() { const remove = useCallback( async (id: string): Promise => { - await apiDelete({ throwOnError: true, path: { id } }); + await apiDelete({ throwOnError: true, path: { id }, headers: await userActionHeaders() }); if (primaryKbId === id) setPrimaryKbId(null); await refresh(); }, diff --git a/ui/desktop/src/components/knowledge/hooks/useKnowledgeGraph.ts b/ui/desktop/src/components/knowledge/hooks/useKnowledgeGraph.ts index 793cf2746..7bb2e544e 100644 --- a/ui/desktop/src/components/knowledge/hooks/useKnowledgeGraph.ts +++ b/ui/desktop/src/components/knowledge/hooks/useKnowledgeGraph.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { getGraph } from '../../../api'; +import { userActionHeaders } from '../../../utils/userAction'; import type { Graph } from '../../../api/types.gen'; export interface UseKnowledgeGraphResult { @@ -22,7 +23,13 @@ export function useKnowledgeGraph(kbId: string | null): UseKnowledgeGraphResult setLoading(true); setError(null); try { - const res = await getGraph({ path: { id: kbId }, throwOnError: true }); + // With the user's proof: a private base is refused to any caller without + // it (issue #56, QA 2026-09-10 H2), and this view is the user. + const res = await getGraph({ + path: { id: kbId }, + headers: await userActionHeaders(), + throwOnError: true, + }); setGraph(res.data ?? null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); diff --git a/ui/desktop/src/components/knowledge/hooks/usePagePreview.ts b/ui/desktop/src/components/knowledge/hooks/usePagePreview.ts index a214bfbb6..28f17d929 100644 --- a/ui/desktop/src/components/knowledge/hooks/usePagePreview.ts +++ b/ui/desktop/src/components/knowledge/hooks/usePagePreview.ts @@ -1,6 +1,7 @@ // ui/desktop/src/components/knowledge/hooks/usePagePreview.ts import { useEffect, useState } from 'react'; import { getPageBody, previewState } from '../../../api'; +import { userActionHeaders } from '../../../utils/userAction'; export interface UsePagePreviewResult { content: string | null; @@ -28,15 +29,20 @@ export function usePagePreview( setError(null); (async () => { try { + // With the user's proof: a private base's pages are refused to any + // caller without it (issue #56, QA 2026-09-10 H2). + const headers = await userActionHeaders(); const res = previewSha ? await previewState({ path: { id: kbId }, body: { commit_sha: previewSha, path }, + headers, throwOnError: true, }) : await getPageBody({ path: { id: kbId }, query: { path }, + headers, throwOnError: true, }); if (!cancelled) setContent(res.data?.content ?? null); diff --git a/ui/desktop/src/components/privacy/FirstRunPrivacyNotice.tsx b/ui/desktop/src/components/privacy/FirstRunPrivacyNotice.tsx index b3a25ca7c..cc83ed298 100644 --- a/ui/desktop/src/components/privacy/FirstRunPrivacyNotice.tsx +++ b/ui/desktop/src/components/privacy/FirstRunPrivacyNotice.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'; import { Button } from '../ui/button'; import { listSessions, type Session } from '../../api'; +import { userActionHeaders } from '../../utils/userAction'; /** * The numbers the day-one notice quotes, over the population **History actually @@ -187,8 +188,11 @@ export function shouldShowFirstRunNotice(counts: NoticeCounts): boolean { * answer, and touches no shared state. It costs one GET, once per install. */ async function fetchVisibleSessions(): Promise { + // With the user's proof: without it the daemon omits every private chat — + // the chats this notice exists to count (issue #56, QA 2026-09-10 M1). const response = await listSessions({ throwOnError: true, + headers: await userActionHeaders(), query: { include_subagents: false }, }); return response.data.sessions; diff --git a/ui/desktop/src/components/privacy/FirstRunPrivacyNoticeGate.tsx b/ui/desktop/src/components/privacy/FirstRunPrivacyNoticeGate.tsx index f500d0d82..93f6c11d1 100644 --- a/ui/desktop/src/components/privacy/FirstRunPrivacyNoticeGate.tsx +++ b/ui/desktop/src/components/privacy/FirstRunPrivacyNoticeGate.tsx @@ -8,6 +8,7 @@ import { type NoticeCounts, } from './FirstRunPrivacyNotice'; import { listSessions, type Session } from '../../api'; +import { userActionHeaders } from '../../utils/userAction'; /** * Where "this machine has already been told" is recorded. @@ -83,7 +84,12 @@ export function FirstRunPrivacyNoticeGate() { useEffect(() => { if (dismissed) return; let cancelled = false; - listSessions({ throwOnError: true, query: { include_subagents: false } }) + // With the user's proof: without it the daemon omits every private chat, + // and the notice would count none (issue #56, QA 2026-09-10 M1). + userActionHeaders() + .then((headers) => + listSessions({ throwOnError: true, headers, query: { include_subagents: false } }) + ) .then((response) => { if (!cancelled) setCounts(computeNoticeCounts(response.data.sessions as Session[])); }) diff --git a/ui/desktop/src/components/sessions/SessionListView.test.tsx b/ui/desktop/src/components/sessions/SessionListView.test.tsx index 48cc1fc82..3a3beb44c 100644 --- a/ui/desktop/src/components/sessions/SessionListView.test.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.test.tsx @@ -30,6 +30,13 @@ vi.mock('../../toasts', () => ({ toastError: mocks.toastError, })); +// The proof the desktop sends. Since issue #56's QA sweep (2026-09-10) the +// daemon answers a request without it as a public model — private chats and +// knowledge bases omitted or refused — so each call here must carry it. +vi.mock('../../utils/userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + vi.mock('../conversation/SearchView', () => ({ SearchView: ({ children }: { children: ReactNode }) => <>{children}, })); @@ -126,7 +133,12 @@ describe('SessionListView loading and cache', () => { expect(screen.getByText('Cached conversation')).toBeInTheDocument(); expect(screen.queryByRole('status', { name: 'Loading chat history' })).not.toBeInTheDocument(); - expect(mocks.listSessions).toHaveBeenCalledTimes(2); + // The revalidation leaves one async hop after mount — it waits for the + // user's proof, which it must carry — so it is awaited rather than assumed. + await waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(2)); + expect(mocks.listSessions).toHaveBeenLastCalledWith( + expect.objectContaining({ headers: { 'X-User-Action': 'test-proof' } }) + ); await act(async () => { finishRefresh?.({ data: { sessions: [session] } }); @@ -284,6 +296,7 @@ describe('SessionListView row actions', () => { ); expect(mocks.deleteSession).toHaveBeenCalledWith({ path: { session_id: session.id }, + headers: { 'X-User-Action': 'test-proof' }, throwOnError: true, }); }); @@ -511,6 +524,7 @@ describe('SessionListView row actions', () => { expect(mocks.updateSessionName).toHaveBeenCalledWith({ path: { session_id: session.id }, body: { name: 'Updated session name' }, + headers: { 'X-User-Action': 'test-proof' }, throwOnError: true, }); }); diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 083e84ee6..4452c84df 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -47,6 +47,7 @@ import { ExtensionConfig, ExtensionData, } from '../../api'; +import { userActionHeaders } from '../../utils/userAction'; import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; import { getSearchShortcutText } from '../../utils/keyboardShortcuts'; import { ReadableContent } from '../Layout/ReadableContent'; @@ -954,8 +955,11 @@ const SessionListView: React.FC = React.memo(({ onSelectSe setSessionToDelete(null); try { + // With the user's proof: deleting a private chat is refused, exactly as + // reading it is, to a caller without it (issue #56, QA 2026-09-10 F0). await deleteSession({ path: { session_id: sessionToDeleteId }, + headers: await userActionHeaders(), throwOnError: true, }); const removeDeletedSession = (currentSessions: Session[]) => @@ -985,8 +989,12 @@ const SessionListView: React.FC = React.memo(({ onSelectSe const handleExportSession = useCallback(async (session: Session, e: React.MouseEvent) => { e.stopPropagation(); + // With the user's proof, like every read of a chat's transcript: the + // export route has refused a private chat to a caller without it since the + // reach gate's export sweep, and this is the person at the keyboard. const response = await exportSession({ path: { session_id: session.id }, + headers: await userActionHeaders(), throwOnError: true, }); diff --git a/ui/desktop/src/components/skills/useSkillCatalog.ts b/ui/desktop/src/components/skills/useSkillCatalog.ts index e5a5f4d8a..db0e29191 100644 --- a/ui/desktop/src/components/skills/useSkillCatalog.ts +++ b/ui/desktop/src/components/skills/useSkillCatalog.ts @@ -49,6 +49,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CatalogBundle, CatalogSkill, CatalogView, SkillRoot } from '../../api'; import { refreshSkillCatalog, setSessionSkills, skillCatalogHandler } from '../../api'; +import { userActionHeaders } from '../../utils/userAction'; import { CATALOG_CHANGED_EVENT } from '../../utils/catalogSubscription'; import { isContextBundle, isContextSkill } from '../settings/contexts/contexts'; import { @@ -255,12 +256,15 @@ export function useSkillCatalog(sessionId: string | null): SkillCatalogState { try { if (sessionId) { + // With the user's proof: a private chat refuses this write to a + // caller without it (issue #56, QA 2026-09-10). const response = await setSessionSkills({ body: { sessionId, add: enabled ? keys : [], remove: enabled ? [] : keys, }, + headers: await userActionHeaders(), throwOnError: true, }); commit(response.data.catalog); diff --git a/ui/desktop/src/components/subagent/useSubagentSession.ts b/ui/desktop/src/components/subagent/useSubagentSession.ts index 029c3ac64..b28531bae 100644 --- a/ui/desktop/src/components/subagent/useSubagentSession.ts +++ b/ui/desktop/src/components/subagent/useSubagentSession.ts @@ -112,8 +112,12 @@ export function useSubagentSession(sessionId: string): SubagentSessionInfo { (m) => m?.metadata?.provenance?.kind === 'spawn_context' ); const spawnContext = record?.content?.map((c) => ('text' in c ? c.text : '')).join('\n'); - const extensionsResponse = (await getSessionExtensions({ path: { session_id: sessionId } })) - .data; + const extensionsResponse = ( + await getSessionExtensions({ + path: { session_id: sessionId }, + headers: await userActionHeaders(), + }) + ).data; if (cancelled) return; setInfo({ isSubagent: true, diff --git a/ui/desktop/src/components/workflows/CreateWorkflowFromSessionModal.tsx b/ui/desktop/src/components/workflows/CreateWorkflowFromSessionModal.tsx index b17c85653..d042b1dbb 100644 --- a/ui/desktop/src/components/workflows/CreateWorkflowFromSessionModal.tsx +++ b/ui/desktop/src/components/workflows/CreateWorkflowFromSessionModal.tsx @@ -6,6 +6,7 @@ import { Button } from '../ui/button'; import { WorkflowFormFields } from './shared/WorkflowFormFields'; import { WorkflowFormData } from './shared/workflowFormSchema'; import { createWorkflow, getActive, getSessionExtensions, listBases } from '../../api/sdk.gen'; +import { userActionHeaders } from '../../utils/userAction'; import { WorkflowParameter } from './shared/workflowFormSchema'; import { toastError } from '../../toasts'; import { saveWorkflow } from '../../workflow/workflow_management'; @@ -97,37 +98,51 @@ export default function CreateWorkflowFromSessionModal({ setAnalysisStage(stages[currentStageIndex]); }, 800); + // The user's proof, on every request below that names this chat or its + // knowledge bases: since issue #56's QA sweep (2026-09-10) a request + // without it is answered as a public model, and a private chat — the + // chat this modal is opened from — would refuse all four. + const proof = userActionHeaders(); + // Pre-select session extensions immediately — independent of workflow analysis - getSessionExtensions({ path: { session_id: sessionId }, throwOnError: false }).then((res) => { - if (cancelled) return; - if (res.data?.extensions) { - setWorkflowExtensions(res.data.extensions); - } - }); + void proof + .then((headers) => + getSessionExtensions({ path: { session_id: sessionId }, headers, throwOnError: false }) + ) + .then((res) => { + if (cancelled) return; + if (res.data?.extensions) { + setWorkflowExtensions(res.data.extensions); + } + }); - Promise.all([ - listBases({ throwOnError: false }), - getActive({ query: { session_id: sessionId }, throwOnError: false }), - ]).then(([basesRes, activeRes]) => { - if (cancelled) return; - const bases: Manifest[] = basesRes.data ?? []; - const hidden = new Set(activeRes.data?.hidden_kbs ?? []); - const visible = bases.filter((base) => !hidden.has(base.id)).map((base) => base.id); - // The captured default is the session's primary; `active_kb` is the - // deprecated mirror, read so a fresh renderer survives an older daemon. - const primary = activeRes.data?.primary_kb ?? activeRes.data?.active_kb ?? null; - const defaultId = primary && visible.includes(primary) ? primary : (visible[0] ?? null); - - setKnowledgeBaseItems( - bases.map((base) => ({ - id: base.id, - label: base.name, - description: base.id, - })) - ); - setWorkflowKnowledgeBaseIds(visible); - setDefaultKnowledgeBaseId(defaultId); - }); + void proof + .then((headers) => + Promise.all([ + listBases({ headers, throwOnError: false }), + getActive({ query: { session_id: sessionId }, headers, throwOnError: false }), + ]) + ) + .then(([basesRes, activeRes]) => { + if (cancelled) return; + const bases: Manifest[] = basesRes.data ?? []; + const hidden = new Set(activeRes.data?.hidden_kbs ?? []); + const visible = bases.filter((base) => !hidden.has(base.id)).map((base) => base.id); + // The captured default is the session's primary; `active_kb` is the + // deprecated mirror, read so a fresh renderer survives an older daemon. + const primary = activeRes.data?.primary_kb ?? activeRes.data?.active_kb ?? null; + const defaultId = primary && visible.includes(primary) ? primary : (visible[0] ?? null); + + setKnowledgeBaseItems( + bases.map((base) => ({ + id: base.id, + label: base.name, + description: base.id, + })) + ); + setWorkflowKnowledgeBaseIds(visible); + setDefaultKnowledgeBaseId(defaultId); + }); // The daemon's catalog, so a skill bundled inside an installed extension // can be attached to a workflow like any other (#113). @@ -154,10 +169,14 @@ export default function CreateWorkflowFromSessionModal({ }); // Analyze the conversation to generate a suggested workflow - createWorkflow({ - body: { session_id: sessionId }, - throwOnError: true, - }) + proof + .then((headers) => + createWorkflow({ + body: { session_id: sessionId }, + headers, + throwOnError: true, + }) + ) .then((response) => { if (cancelled) return; clearInterval(stageInterval); diff --git a/ui/desktop/src/hooks/chatStreamStore.test.ts b/ui/desktop/src/hooks/chatStreamStore.test.ts index ee26ab45f..4f824c176 100644 --- a/ui/desktop/src/hooks/chatStreamStore.test.ts +++ b/ui/desktop/src/hooks/chatStreamStore.test.ts @@ -942,6 +942,7 @@ describe('ChatStreamRegistry', () => { editType: 'edit', expectedMessageIds: ['u1', 'a1', 'a2'], }, + headers: { 'X-User-Action': 'test-key' }, throwOnError: true, }); }); @@ -992,6 +993,9 @@ describe('ChatStreamRegistry', () => { expect(editMessage).toHaveBeenCalledWith({ path: { session_id: sessionId }, body: { timestamp: 10, editType: 'edit' }, + // The in-place edit asks the read's reach gate since QA's 2026-09-10 + // sweep, so it carries the proof as a branch does. + headers: { 'X-User-Action': 'test-key' }, throwOnError: true, }); const body = vi.mocked(editMessage).mock.calls[0][0].body as Record; diff --git a/ui/desktop/src/hooks/chatStreamStore.tsx b/ui/desktop/src/hooks/chatStreamStore.tsx index 20c084a8e..caf85f520 100644 --- a/ui/desktop/src/hooks/chatStreamStore.tsx +++ b/ui/desktop/src/hooks/chatStreamStore.tsx @@ -332,7 +332,9 @@ async function fetchAllSessions(): Promise<{ id: string; name?: string | null }[ } sessionListInflightAt = now; sessionListInflight = (async () => { - const response = await listSessions({ throwOnError: true }); + // With the user's proof: without it the daemon omits private chats + // (issue #56, QA 2026-09-10 M1). + const response = await listSessions({ throwOnError: true, headers: await userActionHeaders() }); return (response.data?.sessions ?? []) as { id: string; name?: string | null }[]; })(); sessionListInflight.catch(() => { @@ -3653,6 +3655,9 @@ class ChatStreamController { setWorkflowUserParams = async (user_workflow_values: Record): Promise => { if (this.snapshot.session) { + // With the user's proof: this writes into the chat and re-applies its + // workflow, which a private chat refuses to a caller without it (issue + // #56, QA 2026-09-10). await updateSessionUserWorkflowValues({ path: { session_id: this.sessionId, @@ -3660,6 +3665,7 @@ class ChatStreamController { body: { userWorkflowValues: user_workflow_values, }, + headers: await userActionHeaders(), throwOnError: true, }); this.updateSnapshot((prev) => @@ -4177,13 +4183,15 @@ class ChatStreamController { editType, ...(expectedMessageIds ? { expectedMessageIds } : {}), }, + // The proof that the person at the keyboard asked, on both edit types. // Issue #56 DR-19: `diverge` branches this chat into a NEW session that // inherits its provider, so on a private chat it mints a new - // private-capability session and the daemon refuses it without proof the - // request came from the person at the keyboard. `edit` truncates this - // session in place and mints nothing, so it is not gated and does not - // carry the proof. - ...(editType === 'diverge' ? { headers: await userActionHeaders() } : {}), + // private-capability session and the daemon refuses it without the + // proof. `edit` truncates this session in place; it mints nothing, but + // since QA's 2026-09-10 sweep it asks the read's reach gate, because a + // caller that may not read a private chat may not cut its history + // either. + headers: await userActionHeaders(), throwOnError: true, }); diff --git a/ui/desktop/src/hooks/useCostTracking.ts b/ui/desktop/src/hooks/useCostTracking.ts index 2c3f7aa00..a847e6e33 100644 --- a/ui/desktop/src/hooks/useCostTracking.ts +++ b/ui/desktop/src/hooks/useCostTracking.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { fetchModelPricing } from '../utils/pricing'; import { getSessionUsage, ModelUsageRow, Session } from '../api'; +import { userActionHeaders } from '../utils/userAction'; import { billedTokens } from '../utils/usageAccounting'; export interface ModelCostRow { @@ -166,8 +167,11 @@ export const useCostTracking = ({ session }: UseCostTrackingProps) => { return; } try { + // With the user's proof: a private chat's usage is refused to a caller + // without it (issue #56, QA 2026-09-10). const response = await getSessionUsage({ path: { session_id: sessionId }, + headers: await userActionHeaders(), throwOnError: false, }); const rows = response.data?.models ?? []; diff --git a/ui/desktop/src/hooks/useWorkflowManager.ts b/ui/desktop/src/hooks/useWorkflowManager.ts index accf3e5ec..ccc1928f9 100644 --- a/ui/desktop/src/hooks/useWorkflowManager.ts +++ b/ui/desktop/src/hooks/useWorkflowManager.ts @@ -5,6 +5,7 @@ import { Message } from '../api'; import { substituteParameters } from '../utils/providerUtils'; import { updateSessionUserWorkflowValues } from '../api'; +import { userActionHeaders } from '../utils/userAction'; import { useChatContext } from '../contexts/ChatContext'; import { ChatType } from '../types/chat'; import { toastError, toastSuccess } from '../toasts'; @@ -198,6 +199,9 @@ export const useWorkflowManager = (chat: ChatType, workflow?: Workflow | null) = body: { userWorkflowValues: inputValues, }, + // With the user's proof: a private chat refuses this write to a caller + // without it (issue #56, QA 2026-09-10). + headers: await userActionHeaders(), throwOnError: true, }); let resolvedWorkflow = response.data?.workflow; diff --git a/ui/desktop/src/schedule.ts b/ui/desktop/src/schedule.ts index 2d39debb9..a8a797811 100644 --- a/ui/desktop/src/schedule.ts +++ b/ui/desktop/src/schedule.ts @@ -11,6 +11,7 @@ import { inspectRunningJob as apiInspectRunningJob, SessionDisplayInfo, } from './api'; +import { userActionHeaders } from './utils/userAction'; export interface ScheduledJob { id: string; @@ -142,9 +143,12 @@ export async function getScheduleSessions( scheduleId: string, limit: number ): Promise> { + // With the user's proof: a schedule's private runs are omitted from a caller + // without it (issue #56, QA 2026-09-10 M1). const response = await apiGetScheduleSessions({ path: { id: scheduleId }, query: { limit }, + headers: await userActionHeaders(), throwOnError: true, }); diff --git a/ui/desktop/src/utils/sessionListCache.test.ts b/ui/desktop/src/utils/sessionListCache.test.ts index 6e4f19945..63f3c65b8 100644 --- a/ui/desktop/src/utils/sessionListCache.test.ts +++ b/ui/desktop/src/utils/sessionListCache.test.ts @@ -19,6 +19,14 @@ vi.mock('../api', () => ({ updateSessionName: mocks.updateSessionName, })); +// The proof the desktop sends. Since issue #56's QA sweep (2026-09-10) a list +// request without it is shown no private chat, so every request here must carry +// it — and it arrives one async hop after the call, which is why the assertions +// below wait for `listSessions` rather than expecting it synchronously. +vi.mock('./userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + beforeEach(() => { vi.clearAllMocks(); clearSessionListCache(); @@ -36,7 +44,10 @@ describe('sessionListCache', () => { preloadSessionList(); const viewLoad = refreshSessionList(); - expect(mocks.listSessions).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(1)); + expect(mocks.listSessions).toHaveBeenCalledWith( + expect.objectContaining({ headers: { 'X-User-Action': 'test-proof' } }) + ); finishRequest?.({ data: { sessions: [] } }); await viewLoad; expect(getCachedSessionList()).toEqual([]); @@ -103,7 +114,13 @@ describe('sessionListCache', () => { const first = refreshSessionList(); const second = refreshSessionList(true); - expect(mocks.listSessions).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalledTimes(2)); + // Each request asks for the list it was issued for, even the orphan whose + // flag changed during the proof's async hop. + expect(mocks.listSessions.mock.calls.map(([options]) => options.query)).toEqual([ + { include_subagents: false }, + { include_subagents: true }, + ]); finishSecond?.({ data: { sessions: [{ id: 'with-subagents' }] } }); await second; @@ -160,7 +177,7 @@ describe('sessionListCache', () => { notifySessionListChanged(); expect(listener).toHaveBeenCalledTimes(1); - expect(mocks.listSessions).toHaveBeenCalled(); + await vi.waitFor(() => expect(mocks.listSessions).toHaveBeenCalled()); unsub(); }); }); diff --git a/ui/desktop/src/utils/sessionListCache.ts b/ui/desktop/src/utils/sessionListCache.ts index 7ae7b2732..f0a649b0c 100644 --- a/ui/desktop/src/utils/sessionListCache.ts +++ b/ui/desktop/src/utils/sessionListCache.ts @@ -1,4 +1,5 @@ import { listSessions, type Session } from '../api'; +import { userActionHeaders } from './userAction'; import { subscribeSessionNameChanges } from './sessionNameSync'; let cachedSessions: Session[] | null = null; @@ -115,12 +116,22 @@ export async function refreshSessionList(includeSubagents?: boolean): Promise({ - throwOnError: true, - // `cachedIncludeSubagents`, not the parameter: a keyless call must send the - // identity the cache is holding, not `undefined`. - query: { include_subagents: cachedIncludeSubagents }, - }) + // With the user's proof: since issue #56's QA sweep (2026-09-10) a listing + // omits every private chat from a caller without it, as the singular read + // refuses one — and this app is the person at the keyboard. + // `cachedIncludeSubagents`, not the parameter: a keyless call must send the + // identity the cache is holding, not `undefined`. Read NOW, before the + // proof's async hop: a flag change in that gap orphans this request, and + // an orphan must still ask for the list it was issued for. + const issuedFor = cachedIncludeSubagents; + inFlightRequest = userActionHeaders() + .then((headers) => + listSessions({ + throwOnError: true, + headers, + query: { include_subagents: issuedFor }, + }) + ) .then((response) => { // Superseded while in flight: hand the answer back to whoever awaited // this exact call, but publish nothing — the cache and its subscribers diff --git a/ui/desktop/src/utils/sessionNameSync.test.ts b/ui/desktop/src/utils/sessionNameSync.test.ts index 8d50b1157..dcf41db1c 100644 --- a/ui/desktop/src/utils/sessionNameSync.test.ts +++ b/ui/desktop/src/utils/sessionNameSync.test.ts @@ -6,6 +6,13 @@ vi.mock('../api', () => ({ updateSessionName: vi.fn(async () => ({ data: {} })), })); +// The proof the desktop sends. Since issue #56's QA sweep (2026-09-10) the +// daemon answers a request without it as a public model — private chats and +// knowledge bases omitted or refused — so each call here must carry it. +vi.mock('./userAction', () => ({ + userActionHeaders: async () => ({ 'X-User-Action': 'test-proof' }), +})); + import { updateSessionName } from '../api'; import { announceSessionName, @@ -127,6 +134,7 @@ describe('renameSession', () => { expect(updateSessionName).toHaveBeenCalledWith({ path: { session_id: 's1' }, body: { name: 'Q1 Plans' }, + headers: { 'X-User-Action': 'test-proof' }, throwOnError: true, }); }); diff --git a/ui/desktop/src/utils/sessionNameSync.ts b/ui/desktop/src/utils/sessionNameSync.ts index 874d43eee..2456f52ab 100644 --- a/ui/desktop/src/utils/sessionNameSync.ts +++ b/ui/desktop/src/utils/sessionNameSync.ts @@ -28,6 +28,7 @@ import type { Message, Session } from '../api'; import { updateSessionName } from '../api'; +import { userActionHeaders } from './userAction'; export const DEFAULT_SESSION_NAME = 'New chat'; @@ -144,9 +145,12 @@ export async function renameSession( const trimmed = newName.trim(); if (!trimmed) throw new Error('Chat name cannot be empty'); + // With the user's proof: renaming a private chat is refused, exactly as + // reading it is, to a caller without it (issue #56, QA 2026-09-10). await updateSessionName({ path: { session_id: sessionId }, body: { name: trimmed }, + headers: await userActionHeaders(), throwOnError: true, }); From 5a9f3fb8d57789e7db104099c9504ca27e0a93f1 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 10:31:51 -0700 Subject: [PATCH 03/15] test(privacy): run the knowledge-base sweep and the serve standing where CI looks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs cargo test --workspace --lib --bins, so the integration binaries that held the H2 route sweep (tests/knowledge_routes.rs) and the served-operator standing (tests/serve_operator_reach.rs) were not what kept either door shut. Both now have a copy in routes::session_reach's lib tests, driven through routes::configure — the tree the daemon serves. The serve decision record becomes SD-10 (SD-9 is claimed by PR #229), and its transcript bullet is reworded so it holds whether or not the interface states a capability: the cookie never reaches a transcript, and a stated capability is judged at those routes exactly as any caller's is. --- CLAUDE.md | 2 +- crates/biorouter-server/src/commands/agent.rs | 2 +- .../src/routes/session_reach.rs | 385 +++++++++++++++++- crates/biorouter-server/src/routes/web_ui.rs | 2 +- .../tests/serve_operator_reach.rs | 6 +- docs/deployment/browser-access.md | 8 +- .../deployment/programmatic-session-access.md | 2 +- docs/deployment/serve-architecture.md | 2 +- docs/deployment/serve-decisions.md | 25 +- docs/security/privacy-tiers-execution-plan.md | 2 +- docs/security/privacy-tiers.md | 2 +- 11 files changed, 412 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 934e8061a..c89030a97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,7 +288,7 @@ what did not" section first**; the rest of that document is the design, not the 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-9). + (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 diff --git a/crates/biorouter-server/src/commands/agent.rs b/crates/biorouter-server/src/commands/agent.rs index 75f42d6cd..001dc06ba 100644 --- a/crates/biorouter-server/src/commands/agent.rs +++ b/crates/biorouter-server/src/commands/agent.rs @@ -201,7 +201,7 @@ pub async fn run() -> 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-9): the interface this daemon serves is + // 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 diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index ba65df6ec..bc2f4035a 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -83,7 +83,7 @@ //! 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-9); +//! `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 @@ -615,7 +615,7 @@ pub async fn session_reach( /// 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-9, not taken here. +/// 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. @@ -3390,6 +3390,387 @@ mod bypass_tests { 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", + 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(); + crate::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!( + crate::auth::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 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); + + 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" + ); + } + /// Every id the sidebar hands this caller, walking `next_offset` to the end. async fn sidebar_ids( state: &Arc, diff --git a/crates/biorouter-server/src/routes/web_ui.rs b/crates/biorouter-server/src/routes/web_ui.rs index ba509fea8..3fd9f5a5d 100644 --- a/crates/biorouter-server/src/routes/web_ui.rs +++ b/crates/biorouter-server/src/routes/web_ui.rs @@ -48,7 +48,7 @@ //! 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-9. +//! appears. See `docs/deployment/serve-decisions.md` SD-10. //! //! # Why there is no brute-force throttle here //! diff --git a/crates/biorouter-server/tests/serve_operator_reach.rs b/crates/biorouter-server/tests/serve_operator_reach.rs index e020839e3..35c496b8c 100644 --- a/crates/biorouter-server/tests/serve_operator_reach.rs +++ b/crates/biorouter-server/tests/serve_operator_reach.rs @@ -1,4 +1,4 @@ -//! Issue #56, QA 2026-09-10 (SD-9): a `biorouter serve` daemon's own web +//! 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. @@ -118,7 +118,7 @@ async fn the_served_interface_keeps_the_operators_reach_on_knowledge_bases() { /// 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-9), not taken. +/// 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(); @@ -127,7 +127,7 @@ async fn the_served_interface_keeps_its_history_list_and_gains_nothing_else() { let chat = manager .create_session( std::path::PathBuf::from("/tmp/sd9_served_operator"), - "SD-9 private (test fixture)".to_string(), + "SD-10 private (test fixture)".to_string(), SessionType::User, ) .await diff --git a/docs/deployment/browser-access.md b/docs/deployment/browser-access.md index 16192ae90..7d1a03da8 100644 --- a/docs/deployment/browser-access.md +++ b/docs/deployment/browser-access.md @@ -184,7 +184,7 @@ differs: | Area | In a browser | |---|---| -| Chat, sessions, history, extensions, skills, knowledge bases, workflows | Work as they do in the desktop application, for everything public. **Private** chats and knowledge bases appear in History and the Knowledge view only when the provider you configured is private, and a private chat cannot be opened from the browser at all. See [decision SD-9](serve-decisions.md#sd-9--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). | +| Chat, sessions, history, extensions, skills, knowledge bases, workflows | Work as they do in the desktop application, for everything public. **Private** chats and knowledge bases appear in History and the Knowledge view only when the provider you configured is private. Being listed does not by itself make a private chat openable from the browser. 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. | | Model and provider selection | **Not available.** See [The model is fixed before you start](#the-model-is-fixed-before-you-start). | | File and folder pickers | No native dialog. You type a path, and it is a path **on the machine running the daemon**, not on the machine holding the browser. | @@ -228,9 +228,9 @@ Private chats and knowledge bases are shown in the browser only when the provide 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 still cannot be opened from the browser. Open -it in the desktop app. The reasoning is -[decision SD-9](serve-decisions.md#sd-9--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). +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 diff --git a/docs/deployment/programmatic-session-access.md b/docs/deployment/programmatic-session-access.md index 8809b216b..cace2f3e2 100644 --- a/docs/deployment/programmatic-session-access.md +++ b/docs/deployment/programmatic-session-access.md @@ -198,7 +198,7 @@ the caller could not open: | `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. | A browser pointed at `biorouter serve` is a special case of this, described in -[decision SD-9](serve-decisions.md#sd-9--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). +[decision SD-10](serve-decisions.md#sd-10--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). ## What the header does *not* cover diff --git a/docs/deployment/serve-architecture.md b/docs/deployment/serve-architecture.md index 1795f1300..dca065e68 100644 --- a/docs/deployment/serve-architecture.md +++ b/docs/deployment/serve-architecture.md @@ -135,7 +135,7 @@ already passed `check_token` and also carries the cookie came from the document 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-9](serve-decisions.md#sd-9--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else). +[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 diff --git a/docs/deployment/serve-decisions.md b/docs/deployment/serve-decisions.md index 6d3723803..292d802c5 100644 --- a/docs/deployment/serve-decisions.md +++ b/docs/deployment/serve-decisions.md @@ -227,7 +227,7 @@ can never half-believe a person is reachable. --- -## SD-9 — The served interface keeps its operator's reach on listings and knowledge bases, and gains nothing else +## 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 @@ -255,12 +255,14 @@ the operator's reach, which reopens H2 on every `serve` daemon. - **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. - A `serve` browser was refused every private transcript before this ruling and still is. So on a - private provider the History list shows private chats that cannot be opened from the browser, - and cannot be deleted or renamed from it either. That is SD-7's limitation, unchanged, and it - keeps deleting a chat from ever being easier than reading it. Letting the transcript gate honour - a served operator would be the first time a gate widened. It is an **open decision**, recorded - here and not taken. + 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 @@ -279,9 +281,12 @@ different tiers, show different subsets of one shared history and knowledge stor 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 by -`crates/biorouter-server/tests/serve_operator_reach.rs`, which asserts both halves: the interface -keeps its listing and knowledge-base reach, and gains no transcript. +`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. --- diff --git a/docs/security/privacy-tiers-execution-plan.md b/docs/security/privacy-tiers-execution-plan.md index bf3650f59..54e750e1f 100644 --- a/docs/security/privacy-tiers-execution-plan.md +++ b/docs/security/privacy-tiers-execution-plan.md @@ -7309,7 +7309,7 @@ stop: that is a different product decision and it is [Open question 15](#open-qu > route that names a base by `{id}`, **reads and writes alike**, so a caller that may not read a base > cannot rewrite, restore or delete it either. `GET /knowledge/bases` and `/knowledge/active` omit > what the caller cannot reach. The Knowledge view still reads everything, because it sends the -> proof. A `biorouter serve` browser keeps its operator's reach under SD-9. So "a barrier there +> proof. A `biorouter serve` browser keeps its operator's reach under SD-10. So "a barrier there > would lock a user out of their own notes" did not come true: the barrier is on the caller who > proves nothing, and the user proves it on every request. Half (b) of > [Open question 15](#open-questions) is answered by this. Record: diff --git a/docs/security/privacy-tiers.md b/docs/security/privacy-tiers.md index 7b8a214ef..4799fda3b 100644 --- a/docs/security/privacy-tiers.md +++ b/docs/security/privacy-tiers.md @@ -91,7 +91,7 @@ this section is the ledger. The desktop app sends the proof on each of these calls and sees exactly what it saw before. A `biorouter serve` browser keeps its operator's reach on listings and knowledge bases and gains - no transcript ([SD-9](../deployment/serve-decisions.md#sd-9--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else)). + no transcript ([SD-10](../deployment/serve-decisions.md#sd-10--the-served-interface-keeps-its-operators-reach-on-listings-and-knowledge-bases-and-gains-nothing-else)). Nothing refused before is permitted now. ⚠ **What it does not change**, stated so it is not over-read. Privacy remains a safety boundary From ccc3e7985904d978229b66c25af9eb24da2fdc0a Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 10:33:27 -0700 Subject: [PATCH 04/15] test(privacy): the knowledge-base sweep covers the credibility override too --- crates/biorouter-server/src/routes/session_reach.rs | 5 +++++ crates/biorouter-server/tests/knowledge_routes.rs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index bc2f4035a..28d2f2080 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -3522,6 +3522,11 @@ mod bypass_tests { format!("{base}/merge"), Some(serde_json::json!({ "source_kb_id": other })), ), + ( + "PUT", + format!("{base}/sources/s1/credibility"), + Some(serde_json::json!({})), + ), ( "PUT", base.clone(), diff --git a/crates/biorouter-server/tests/knowledge_routes.rs b/crates/biorouter-server/tests/knowledge_routes.rs index 5f3228a1a..6b8692318 100644 --- a/crates/biorouter-server/tests/knowledge_routes.rs +++ b/crates/biorouter-server/tests/knowledge_routes.rs @@ -3855,6 +3855,11 @@ mod h2_http_barrier { 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"), From 01115e7a328c21ac6d4b877167859ad6d182cd7b Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 10:52:51 -0700 Subject: [PATCH 05/15] test(privacy): reach the serve standing through the library path the bin also links routes::session_reach is compiled into the biorouterd bin as well as the library, and the bin has no auth module of its own, so the new served-operator test named crate::auth and failed to build there (clippy caught it; cargo test --lib alone did not). It now goes through biorouter_server::auth, which is the static http_caller reads in either binary. --- crates/biorouter-server/src/routes/session_reach.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index 28d2f2080..83fdaec76 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -3693,12 +3693,18 @@ mod bypass_tests { #[serial] async fn a_served_interface_keeps_its_listing_reach_and_gains_no_transcript() { install_test_user_action_key(); - crate::auth::install_served_operator(SERVED_TOKEN.to_string(), ProviderTier::Private); + // `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!( - crate::auth::served_operator_capability(&probe), + served_operator_capability(&probe), ProviderTier::Private, "a different serve operator was installed into this binary first; this test's \ premise does not hold" From 4eabd13f2546fc806aed9d44ee8e8def13d8e0e9 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 14:24:02 -0700 Subject: [PATCH 06/15] feat(active-work): read one registry entry by id The /active_work cancel route has to resolve an id to the chat that owns the work before it fires anything (issue #56). `get` gives it the one entry, built by the same snapshot `list` uses, so the two cannot disagree about an entry. Documents `ActiveWorkItem::session_id` as load-bearing: GET /active_work now shows a row only to a caller that could open its chat, and a row with no chat only to a caller that could open a private one. --- crates/biorouter-mcp/src/active_work.rs | 74 +++++++++++++++++++++---- 1 file changed, 62 insertions(+), 12 deletions(-) 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(); From 57510c9c1226347ba0959010a98d4e7a8b2d9a63 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 14:24:02 -0700 Subject: [PATCH 07/15] fix(developer): a shell command's active-work row names the chat that ran it Background jobs and foreground commands registered with no session id. The /active_work reach filter (next commit) answers such a row as a private chat's, so it would have hidden every shell command, a public chat's included, from any caller not shown private chats. The developer server now reads the dispatching chat from the `biorouter-session-id` key that Biorouter's MCP client stamps on every tool call's `_meta`. The client composes that key itself; the model supplies arguments, never `_meta`. The chat id is threaded into `BackgroundJobs::spawn` and `ForegroundWorkGuard::register`. Both new tests drive `shell()` with a stamped context, and both failed before this change (`left: None`). --- .../biorouter-mcp/src/developer/background.rs | 24 ++- .../src/developer/rmcp_developer.rs | 164 +++++++++++++++++- crates/biorouter-mcp/src/developer/shell.rs | 8 +- 3 files changed, 182 insertions(+), 14 deletions(-) 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 1348dc65a..2584b9d99 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 @@ -1511,7 +1538,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!( @@ -1537,7 +1564,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 @@ -1762,6 +1789,7 @@ impl DeveloperServer { &self, command: &str, working_dir: Option, + session_id: Option, peer: &rmcp::service::Peer, cancellation_token: CancellationToken, ) -> Result<(String, Option), ErrorData> { @@ -1819,7 +1847,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(), @@ -5841,6 +5870,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, ), } From 01f4a283ca98c8839a3caf36cbcf3e942b109ab4 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 14:32:43 -0700 Subject: [PATCH 08/15] fix(privacy): /active_work lists and stops only the work of chats the caller could open GET /active_work returned every running job, subagent, detached turn and scheduled run to a caller holding only the daemon secret. Each row carries its chat's id and the shell command or task prompt. POST /active_work/{id}/cancel stopped any of them. The residual table in programmatic-session-access.md recorded both as open. - The list resolves the caller once (`http_caller`) and keeps a row only if `HttpCaller::lists_work` admits it. That is `lists_session`'s answer for the row's chat, resolved metadata-only. Refused rows are omitted, never redacted, and a caller shown every row skips the store reads. - The cancel resolves its id to the owning chat (the registry entry, or the running schedule) and asks `work_reach` before anything stops. For a chat, `work_reach` is `session_reach` by the same call, so it refuses with GET /sessions/{id}'s exact status and words. - A row that names no chat is `Unreadable`, so both routes answer it as a private chat's row. So are an unreadable chat, a handle that names nothing and a schedule that is not running. - SD-10: the list honours a serve browser's operator standing, because it is a listing. The cancel does not, like `session_reach`. `lists_session` now delegates to a private `admits`, so the listing decision keeps one spelling. The census gains rows for `lists_work` and `work_reach`, and updated counts for `session_reach`, `refuse_unless_reachable`, `http_caller` and `target_tier`. Tests are lib tests, so CI runs them. The two HTTP regressions in `bypass_tests` failed before this change: the private chat's row was listed, and its cancel answered 200 "Requested cancel of 'sub-5'" instead of the read's 403. Also added: - the fast path is checked sound at every corner; - `lists_work` is checked against a real temp store; - `work_reach` is byte-equal to `session_reach`; - the route's filter is checked over every row kind, a scheduled run included; - a served interface is listed the work and cannot stop it; - two ordering rows pin the gate ahead of both effects. --- .../src/routes/active_work.rs | 220 ++++++- .../src/routes/session_reach.rs | 620 +++++++++++++++++- .../biorouter/tests/privacy_guard_wiring.rs | 78 ++- 3 files changed, 883 insertions(+), 35 deletions(-) diff --git a/crates/biorouter-server/src/routes/active_work.rs b/crates/biorouter-server/src/routes/active_work.rs index b97103e75..39a248f08 100644 --- a/crates/biorouter-server/src/routes/active_work.rs +++ b/crates/biorouter-server/src/routes/active_work.rs @@ -7,12 +7,39 @@ //! 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 not closed by this file.** `GET /schedule/list` +//! and `GET /schedule/{id}/inspect` still name a running schedule's chat, and +//! `POST /schedule/{id}/kill` still stops it, for any holder of the daemon +//! secret; see the residual table in +//! `docs/deployment/programmatic-session-access.md`. use std::sync::Arc; use axum::{ extract::{Path, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; @@ -128,19 +155,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 +211,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() @@ -172,7 +262,8 @@ async fn cancel_active_work( 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 +274,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 +374,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/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index 83fdaec76..d5e32d188 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -30,11 +30,16 @@ //! `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` remain open — they name no session id in their -//! path and so enumerate, 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". `GET /sessions/running` (ids +//! /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 not closed: +//! `GET /schedule/list` and `GET /schedule/{id}/inspect` still name a running +//! schedule's chat, and `POST /schedule/{id}/kill` still stops it, for any +//! holder of the secret. `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` @@ -73,7 +78,10 @@ //! 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; +//! 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 @@ -176,6 +184,7 @@ //! | `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. | //! //! 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 @@ -188,7 +197,8 @@ //! without the capability header and proceeds with it. A future sweep that greps //! for the call must follow `authorize_agent_control` too, or it will "discover" //! four holes that are not there and, worse, trust the same grep when it reports -//! a real one. +//! a real one. The same holds for `POST /active_work/{id}/cancel`, which reaches +//! the gate through [`work_reach`]; its ordering rows below name `work_reach(`. //! //! # Why `X-User-Action` and not a new mechanism, for the proof half //! @@ -534,10 +544,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) @@ -596,6 +607,48 @@ 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 @@ -659,13 +712,49 @@ impl HttpCaller { /// directory, both content (§11.4), which is the rule `workspace_list` /// already applies to a model. pub fn lists_session(&self, classification: SessionClassification) -> bool { - refuse_unless_reachable( - self.enforced, - TargetTier::from(classification), - self.capability(), - self.proof, - ) - .is_ok() + self.admits(TargetTier::from(classification)) + } + + /// May this caller be shown a row of RUNNING WORK — `GET /active_work` — + /// that belongs to the chat `owner`? + /// + /// [`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 @@ -1356,6 +1445,7 @@ mod tests { 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"); for (src, func, gate_call, first_touch, what) in [ ( reply_rs, @@ -1493,6 +1583,21 @@ mod tests { ".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(", + "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", + ), ] { let handler = body_of(src, func); let gate = handler.find(gate_call).unwrap_or_else(|| { @@ -1630,6 +1735,195 @@ mod tests { assert!(public_operator.lists_session(SessionClassification::Public)); } + // ─── Running work (`GET /active_work`, `POST /active_work/{id}/cancel`) ─── + + /// 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); + } + 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) + } + + /// `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" + ); + } + } + } + } + } + } + + /// 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 @@ -3349,6 +3643,261 @@ mod bypass_tests { } } + /// 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 @@ -3713,6 +4262,11 @@ mod bypass_tests { 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", @@ -3730,6 +4284,15 @@ mod bypass_tests { 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}"); @@ -3780,6 +4343,27 @@ mod bypass_tests { .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. diff --git a/crates/biorouter/tests/privacy_guard_wiring.rs b/crates/biorouter/tests/privacy_guard_wiring.rs index 354df44f0..2dbce8d4d 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -316,6 +316,15 @@ 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(6, 6, 0), @@ -427,10 +436,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", }, ], }, @@ -458,14 +469,17 @@ const REGISTRY: &[Guard] = &[ status: Status::WiredThrough("session_reach"), sites: &[Site { file: SESSION_REACH, - counts: c(3, 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; and since QA's 2026-09-10 sweep `HttpCaller::lists_session` (a \ - listing is the rows this decision admits, one at a time) and \ - `HttpCaller::reach_knowledge_base` (the same decision with a knowledge \ - base as the target). ONE decision, three subjects: a second spelling of it \ - is what this census exists to stop", + 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 { @@ -476,6 +490,14 @@ const REGISTRY: &[Guard] = &[ 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), @@ -536,6 +558,40 @@ const REGISTRY: &[Guard] = &[ }, ], }, + 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", + }], + }, + 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", + }], + }, Guard { ident: "reach_knowledge_base", defined_in: SESSION_REACH, @@ -590,10 +646,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 From edbff6a9947b12cbc3e6c809a018ca41cde4507c Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 14:32:44 -0700 Subject: [PATCH 09/15] chore(api): regenerate the OpenAPI spec and client for the /active_work gate `just generate-openapi` + `npm run generate-api`. The list route's description now says what it withholds, and the cancel route documents its 403. No other drift. --- ui/desktop/openapi.json | 5 ++++- ui/desktop/src/api/types.gen.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index f59304b65..0577a53f5 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -99,7 +99,7 @@ "operationId": "list_active_work", "responses": { "200": { - "description": "Current background jobs, subagents, and in-flight scheduled runs", + "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`", "content": { "application/json": { "schema": { @@ -139,6 +139,9 @@ } } }, + "403": { + "description": "The work belongs to a chat this caller could not open — a private chat, one that cannot be read, or none at all — 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 an id that names nothing, so a refusal says nothing about the work. Nothing was stopped" + }, "404": { "description": "No such active-work item" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index bee349467..f1c7bc913 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -4256,7 +4256,7 @@ export type ListActiveWorkData = { export type ListActiveWorkResponses = { /** - * Current background jobs, subagents, and in-flight scheduled runs + * 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` */ 200: ActiveWorkResponse; }; @@ -4276,6 +4276,10 @@ export type CancelActiveWorkData = { }; export type CancelActiveWorkErrors = { + /** + * The work belongs to a chat this caller could not open — a private chat, one that cannot be read, or none at all — 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 an id that names nothing, so a refusal says nothing about the work. Nothing was stopped + */ + 403: unknown; /** * No such active-work item */ From 4207c02b002ff99841077656c90d222505ba0a6a Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 14:32:44 -0700 Subject: [PATCH 10/15] test(desktop): a call to the active-work routes must carry the person's proof Nothing in the renderer calls `listActiveWork` or `cancelActiveWork` yet (measured; the panel is deferred), so there was no call site to add `userActionHeaders()` to. A call without the proof is not an error. The list comes back 200 with the private chats' work missing. So this source guard fails the day a call is added without the proof. Its positive controls show it firing. A walk-size check stops it passing vacuously, and so does a real file that names `/active_work` only in comments. --- ui/desktop/src/activeWorkUserProof.test.ts | 142 +++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 ui/desktop/src/activeWorkUserProof.test.ts diff --git a/ui/desktop/src/activeWorkUserProof.test.ts b/ui/desktop/src/activeWorkUserProof.test.ts new file mode 100644 index 000000000..e138cd233 --- /dev/null +++ b/ui/desktop/src/activeWorkUserProof.test.ts @@ -0,0 +1,142 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Every renderer call to the active-work routes carries the person's proof. + * + * Issue #56: `GET /active_work` now shows a row only to a caller that could open + * the chat the row belongs to, and `POST /active_work/{id}/cancel` refuses the + * rest with the chat read's own 403. The desktop is the person at the keyboard, + * but the daemon only believes that when the request carries + * `userActionHeaders()`. + * + * ⚠ **A missing proof is not an error here, which is why this is a test and not + * a code review note.** The list comes back 200 with the private chats' work + * silently left out, so a panel built on it would tell the user nothing is + * running while their private chat's job still is. `CLAUDE.md` records the same + * trap for the chat and knowledge-base listings. + * + * Measured 2026-09-11: nothing in the renderer calls either route yet (the panel + * is deferred). So this guard fails the day one is added without the proof, not + * today. Its positive controls below show that it can fail. + */ +const SRC = __dirname; + +/** The generated client's functions for the two routes (`src/api/sdk.gen.ts`). */ +const GATED_CALLS = ['listActiveWork', 'cancelActiveWork'] as const; + +/** `api/` is generated; `bin/` and `web/` are build outputs. */ +const OUT_OF_SCOPE = ['api/', 'bin/', 'web/']; + +function productionSources(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const rel = relative(SRC, path).replace(/\\/g, '/'); + if (statSync(path).isDirectory()) { + if ( + entry !== 'node_modules' && + !OUT_OF_SCOPE.some((prefix) => `${rel}/`.startsWith(prefix)) + ) { + productionSources(path, out); + } + } else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) { + out.push(path); + } + } + return out; +} + +/** The source with comments and import declarations blanked, so neither reads as a call. */ +function codeOf(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/(^|[^:])\/\/.*$/gm, '$1') + .replace(/^\s*import\s[^;]*?from\s*['"][^'"]+['"];?/gm, ' '); +} + +/** The argument text of the call whose `(` is at `open`, by bracket matching. */ +function argumentsAt(code: string, open: number): string { + let depth = 0; + for (let i = open; i < code.length; i += 1) { + if (code[i] === '(') depth += 1; + if (code[i] === ')') { + depth -= 1; + if (depth === 0) return code.slice(open + 1, i); + } + } + return code.slice(open + 1); +} + +/** + * Every place `source` reaches an active-work route without the proof: a call + * to a gated function whose arguments do not call `userActionHeaders()`, the + * function passed as a value (its headers cannot be seen, so it cannot be + * trusted to send them), or a hand-built request to the path. + */ +function unprovenActiveWorkCalls(source: string): string[] { + const code = codeOf(source); + const findings: string[] = []; + for (const name of GATED_CALLS) { + for (const match of code.matchAll(new RegExp(`\\b${name}\\b\\s*(\\()?`, 'g'))) { + const open = (match.index ?? 0) + match[0].length - 1; + if (match[1] === undefined) { + findings.push(`${name} used as a value`); + } else if (!/\buserActionHeaders\s*\(/.test(argumentsAt(code, open))) { + findings.push(`${name}(…) without userActionHeaders()`); + } + } + } + if (/['"`][^'"`]*\/active_work\b/.test(code)) { + findings.push('a hand-built request to /active_work'); + } + return findings; +} + +describe("the active-work routes are called with the person's proof", () => { + it('is a guard that can fail (positive controls)', () => { + expect(unprovenActiveWorkCalls('await listActiveWork({ throwOnError: true });')).toEqual([ + 'listActiveWork(…) without userActionHeaders()', + ]); + expect(unprovenActiveWorkCalls('cancelActiveWork({ path: { id } });')).toEqual([ + 'cancelActiveWork(…) without userActionHeaders()', + ]); + expect(unprovenActiveWorkCalls('const load = listActiveWork;')).toEqual([ + 'listActiveWork used as a value', + ]); + expect(unprovenActiveWorkCalls('await fetch(`${base}/active_work`, { headers });')).toEqual([ + 'a hand-built request to /active_work', + ]); + }); + + it('accepts the shape the rest of the renderer uses, and ignores imports and comments', () => { + const proven = [ + "import { cancelActiveWork, listActiveWork } from '../api';", + '// listActiveWork() without a proof, in prose, is not a call', + 'const list = await listActiveWork({ throwOnError: true, headers: await userActionHeaders() });', + 'await cancelActiveWork({', + ' path: { id: item.id },', + ' headers: await userActionHeaders(),', + '});', + ].join('\n'); + expect(unprovenActiveWorkCalls(proven)).toEqual([]); + }); + + it('holds for every production source in the renderer', () => { + const sources = productionSources(SRC); + // A walk that read nothing reports the same empty list as a clean tree. + // `chatStreamStore.tsx` names `/active_work` in its comments, so it is also + // the real-world check that prose is not read as a request. + expect(sources.length).toBeGreaterThan(200); + const chatStreamStore = sources.find((path) => path.endsWith('chatStreamStore.tsx')); + expect(chatStreamStore).toBeDefined(); + expect(readFileSync(chatStreamStore!, 'utf8')).toContain('/active_work'); + + const findings = sources.flatMap((path) => + unprovenActiveWorkCalls(readFileSync(path, 'utf8')).map( + (finding) => `${relative(SRC, path)}: ${finding}` + ) + ); + expect(findings).toEqual([]); + }); +}); From 0722c11c131de52cd835bbc986fb3a571602e02a Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 14:32:44 -0700 Subject: [PATCH 11/15] docs(privacy): /active_work is gated; record the no-chat decision and the schedule residual programmatic-session-access.md: both /active_work rows move out of the known-residual table. The cancel joins the gated routes and the list joins the listings. The page now records three things: - work that names no chat is treated as a private chat's; - a serve browser keeps its operator's reach on the list and not on the cancel; - the scheduled half is still open through /schedule/list, /schedule/{id}/inspect and /schedule/{id}/kill. The last of these is added to the residual table, with the chat ids /schedule/list exposes. privacy-tiers.md: a "What shipped" entry, and open question 10 answered. The execution plan: question 10 answered. developer.md: the row names the chat that ran the command. CLAUDE.md: the rule, beside #237's. --- CLAUDE.md | 4 ++++ .../deployment/programmatic-session-access.md | 22 +++++++++++++++---- docs/extensions/built-in/developer.md | 2 +- docs/security/privacy-tiers-execution-plan.md | 2 +- docs/security/privacy-tiers.md | 16 ++++++++++++++ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c89030a97..041e744bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,6 +282,10 @@ what did not" section first**; the rest of that document is the design, not 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 diff --git a/docs/deployment/programmatic-session-access.md b/docs/deployment/programmatic-session-access.md index cace2f3e2..fff48772b 100644 --- a/docs/deployment/programmatic-session-access.md +++ b/docs/deployment/programmatic-session-access.md @@ -183,10 +183,11 @@ one of them resolves the target's tier **before** it touches the session, so a r | `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. | 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. +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: @@ -196,9 +197,23 @@ the caller could not open: | `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 @@ -234,9 +249,8 @@ reader should not infer from this page that the surface is complete: | `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. | -| `GET /active_work` | Every running background job, subagent, detached turn and scheduled run. Each comes with its `sessionId` and a `title`/`detail` that carries the **shell command or task prompt**. This is content rather than metadata, and it is the most significant item on this 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 /schedule/{id}/inspect`, `POST /schedule/{id}/run_now`, `POST /schedule/create` | Inspects or launches scheduled work that may run in a private session. | +| `GET /schedule/list`, `GET /schedule/{id}/inspect`, `POST /schedule/{id}/kill` | Every schedule, with the chat that created it and each running one's `current_session_id`, and a way to stop it. This is the scheduled half of what `/active_work` now filters, still open through the schedule routes: a caller that `/active_work` refuses a private chat's scheduled run can find its chat here and stop it here. | +| `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/extensions/built-in/developer.md b/docs/extensions/built-in/developer.md index cab2d22a7..326f11a5e 100644 --- a/docs/extensions/built-in/developer.md +++ b/docs/extensions/built-in/developer.md @@ -128,7 +128,7 @@ The `shell` tool runs a command one of two ways, and the difference matters more The budget exists because a foreground command that turns out to be far more expensive than it looked — a `find` over a whole home directory, a query with no index — blocks the turn for minutes with nothing to show for it. Two things make that visible while it happens: - The tool card in chat reports the elapsed time every 15 seconds ("shell: still running after 45s — …"), so a silent command is distinguishable from a stuck agent. -- The command is listed in the active-work view (`GET /active_work`) for as long as it runs, alongside background jobs, subagents and scheduled runs, and `POST /active_work/{id}/cancel` stops it on its own without ending the turn. +- The command is listed in the active-work view (`GET /active_work`) for as long as it runs, alongside background jobs, subagents and scheduled runs, and `POST /active_work/{id}/cancel` stops it on its own without ending the turn. The row names the chat that ran the command, as a background job's row does. The view shows it only to a caller that could open that chat, and only such a caller can stop it; see [Reaching a private chat from a script](../../deployment/programmatic-session-access.md). Raise, lower or disable the budget with [`BIOROUTER_SHELL_FOREGROUND_TIMEOUT_SECS`](../../configuration/environment-variables.md#foreground-shell-budget) (seconds; `0` disables it). diff --git a/docs/security/privacy-tiers-execution-plan.md b/docs/security/privacy-tiers-execution-plan.md index 54e750e1f..50950c6d3 100644 --- a/docs/security/privacy-tiers-execution-plan.md +++ b/docs/security/privacy-tiers-execution-plan.md @@ -22224,7 +22224,7 @@ costs recorded in [Accepted risks](#accepted-risks) (AR-1, AR-2 and AR-5). | **7** | **Should the compiled-in private baseline be a signed registry snapshot?** Signing would let a *downgrade* be trusted offline. Today the union rule means an extension can only ever gain a private badge without a fresh fetch — safe, but a genuine reclassification-to-public needs connectivity. | Task 37 implements the union rule and Task 34 gates the const against `registry.json`. Signing is a follow-up. | | **8** | **Who is "who" in the declassification record?** The app is single-user, so the local OS username is recorded. On a shared lab machine that is right; in a multi-account setup it is not, and there is no user identity in the product to record instead. | Task 29 records the OS user + machine in `classification_audit.actor`, with `actor_kind = 'user'` — a value no other code path can construct. | | **9** | **Skills (R12) carry no classification, which leaves three gaps.** (a) A skill authored while a private chat was open can embed pasted private text and is then readable by every session and publishable to the marketplace. (b) A skill can instruct the model to call `ucsfomopagent` — harmless in effect because Gate C refuses at dispatch, but the steering is unblocked and produces confusing refusals. (c) BR-71 Task 15 lets one session add skills to another. | v1 mitigation is a line in the skill-creation UI (Task 28's copy pass). Closing (a) needs skills to carry a classification, which contradicts R12. | -| **10** | **`ActiveWorkItem.title` is cross-session content and predates all of this** — derived from a subagent's task prompt and surfaced process-wide with a session id. The visibility rule is applied to it, but it is exposed only via `GET /active_work` for the GUI (the model-facing `subagent_status` is session-scoped), so it may deserve its own fix rather than riding this one. | Task 21 provides `appears_in_list`; wiring `/active_work` to it is a follow-up. | +| **10** | **`ActiveWorkItem.title` is cross-session content and predates all of this** — derived from a subagent's task prompt and surfaced process-wide with a session id. The visibility rule is applied to it, but it is exposed only via `GET /active_work` for the GUI (the model-facing `subagent_status` is session-scoped), so it may deserve its own fix rather than riding this one. | Task 21 provides `appears_in_list`; wiring `/active_work` to it is a follow-up. ✅ **ANSWERED 2026-09-11, by its own fix and a different instrument.** The route has no model caller, so there is no `CallCapability` for `appears_in_list` to read. It asks the HTTP reach gate (`routes::session_reach`) instead, which states the same rule for a caller's stated capability or the user's proof. `GET /active_work` omits a row whose chat its caller could not open (`HttpCaller::lists_work`). `POST /active_work/{id}/cancel`, which stopped any row, resolves its id to the owning chat and asks `work_reach`. A row that names no chat is treated as a private chat's. | | **11** | **`POST /agent/call_tool` remains inspector-free.** This design is correct either way because the barrier is in the extension manager, but the route is a standing hazard for every *future* inspector-based control, including BR-71's. | Task 14 fixes its error mapping so a refusal reaches the caller as text rather than a bare 500, and Task 20's gate exercises it explicitly. The route itself is unchanged. | Nine more this plan surfaced. Twelve and thirteen need a ruling before the phase that touches them; diff --git a/docs/security/privacy-tiers.md b/docs/security/privacy-tiers.md index 4799fda3b..5ea5e7b9e 100644 --- a/docs/security/privacy-tiers.md +++ b/docs/security/privacy-tiers.md @@ -88,6 +88,14 @@ this section is the ledger. writes alike. An absent or malformed id is answered as a private one. - **Knowledge-base listings.** `GET /knowledge/bases` and `/knowledge/active` omit what the caller cannot reach, and a selection write cannot move a base its caller cannot see. + - **Running work.** `GET /active_work` omits every row whose chat the caller could not open. A + row is a background job's or a foreground command's shell text, or a subagent's task prompt, + which is content rather than metadata. `POST /active_work/{id}/cancel` resolves its id to the + chat that owns the work and asks that chat's read gate before it stops anything, refusing in + the read's own words. A row that names no chat is answered as a private chat's row, because + its command came from some chat and nothing says whose. The shell now records the chat that + ran each command, which leaves that arm to work that genuinely has no chat. Open question 10 + below was this. The desktop app sends the proof on each of these calls and sees exactly what it saw before. A `biorouter serve` browser keeps its operator's reach on listings and knowledge bases and gains @@ -2989,6 +2997,14 @@ prediction stands for whatever the next narrowest reading of it turns out to be. applied to it, but it is exposed only via `GET /active_work` for the GUI (the model-facing `workspace_read_conversation` / `workspace_watch` are session-scoped), so it may deserve its own fix rather than riding this one. + ✅ **Answered 2026-09-11, with its own fix.** It was wider than the title: `detail` carries + every running shell command verbatim, and `POST /active_work/{id}/cancel` stopped any of them. + Both routes now ask the HTTP reach gate about the chat that owns each row. The listing omits a + row its caller could not open, and the cancel refuses in the chat read's own words. A row that + names no chat is treated as a private chat's. The instrument is `routes::session_reach` rather + than `appears_in_list`, because an HTTP caller has no `CallCapability`; its capability is the + one it states, or the user's proof. The rule it applies is the same one. See + [Reaching a private chat from a script](../deployment/programmatic-session-access.md). 11. **`POST /agent/call_tool` remains inspector-free.** This design is correct either way because the barrier is in the extension manager, but the route is a standing hazard for every *future* inspector-based control, including BR-71's. From c4056bd2598489742b5cc9a6962e37d11c465bf6 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 19:21:18 -0700 Subject: [PATCH 12/15] fix(privacy): stopping a scheduled run is gated wherever it is asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /schedule/{id}/kill` called `Scheduler::kill_running_job` with no gate of any kind, so any holder of the daemon secret stopped any chat's scheduled run. On its own that was a documented residual. What made it worse than that is the route beside it: `POST /active_work/{id}/cancel` gates the SAME kill, on the SAME scheduler, by the SAME schedule id. A caller refused there re-issued the request one URL over and stopped the run anyway — so the gate was bypassable by a one-word change of URL, and a gate that is trivially routed around is worse than none, because it reads as protection. The kill route now resolves the run to the chat it is in and asks the chat READ's own gate (`work_reach`) before anything is stopped: the same status and the same bytes as the cancel route, and the same answer for a schedule that is not running and one that does not exist, so a refusal says nothing about which it was. The stop is also now checked against the run it was authorized against. `kill_running_job_in_session` holds the scheduler's `jobs` lock across the check AND the cancel, where the two used to be separate acquisitions. A schedule id is stable across runs while `current_session_id` is not, so a public run could end and a private run of the same schedule begin between the gate's read and the kill, and the kill would land on the run nobody authorized. Both routes now pass the chat they admitted, and both refuse rather than stop a run that changed underneath. There is deliberately no `.await` between the lock and `token.cancel()`. Fail-before evidence: the new row in `every_gated_route_resolves_the_tier_before_it_touches_the_session` panicked with "pub async fn kill_running_job( does not consult its session-reach gate (`work_reach(`)" against the unfixed handler. Census: the `work_reach` guard gains a second site (schedule.rs), and the `session_reach` module-qualifier row for schedule.rs goes 1 -> 2 refs. The `cancel_active_work` ordering row's action needle follows the rename to `kill_running_job_in_session(`. --- .../src/routes/active_work.rs | 7 +- .../biorouter-server/src/routes/schedule.rs | 42 ++++++++-- .../src/routes/session_reach.rs | 18 ++++- crates/biorouter/src/agents/schedule_tool.rs | 9 +++ crates/biorouter/src/scheduler.rs | 80 ++++++++++++++++--- crates/biorouter/src/scheduler_trait.rs | 13 +++ crates/biorouter/tests/agent.rs | 8 ++ .../biorouter/tests/privacy_guard_wiring.rs | 45 ++++++++--- 8 files changed, 191 insertions(+), 31 deletions(-) diff --git a/crates/biorouter-server/src/routes/active_work.rs b/crates/biorouter-server/src/routes/active_work.rs index 39a248f08..63a79500c 100644 --- a/crates/biorouter-server/src/routes/active_work.rs +++ b/crates/biorouter-server/src/routes/active_work.rs @@ -256,7 +256,12 @@ async fn cancel_active_work( 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, diff --git a/crates/biorouter-server/src/routes/schedule.rs b/crates/biorouter-server/src/routes/schedule.rs index 818cbe8e9..58e45a55a 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, }; @@ -549,18 +550,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), diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index d5e32d188..9aa0553a4 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -1446,6 +1446,7 @@ mod tests { 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, @@ -1588,7 +1589,7 @@ mod tests { active_work_rs, "async fn cancel_active_work(", "work_reach(", - "kill_running_job(", + "kill_running_job_in_session(", "the scheduler's kill of the run", ), ( @@ -1598,6 +1599,21 @@ mod tests { "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", + ), ] { let handler = body_of(src, func); let gate = handler.find(gate_call).unwrap_or_else(|| { diff --git a/crates/biorouter/src/agents/schedule_tool.rs b/crates/biorouter/src/agents/schedule_tool.rs index 34b9a73c8..27f620467 100644 --- a/crates/biorouter/src/agents/schedule_tool.rs +++ b/crates/biorouter/src/agents/schedule_tool.rs @@ -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/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/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 f806f05a5..22312d143 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -367,11 +367,15 @@ const REGISTRY: &[Guard] = &[ }, Site { file: "crates/biorouter-server/src/routes/schedule.rs", - counts: c(0, 1, 0), + counts: c(0, 2, 0), kind: SiteKind::Unrelated, - what: "the MODULE qualifier on `session_reach::http_caller`, which filters \ - `GET /schedule/{id}/sessions` — a listing, gated by `lists_session`, not \ - by this function", + what: "the MODULE qualifier twice, on neither 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`, which gates `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", }, Site { file: "crates/biorouter-server/src/routes/session.rs", @@ -582,15 +586,30 @@ const REGISTRY: &[Guard] = &[ 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", - }], + 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(1, 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", + }, + ], }, Guard { ident: "reach_knowledge_base", From 93aa328b2bc4e1aa50cb14843d26406446be190c Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 19:23:05 -0700 Subject: [PATCH 13/15] fix(privacy): inspecting a schedule names its chat only to a caller that could open it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /schedule/{id}/inspect` answered any holder of the daemon secret with the `session_id` of the chat a schedule is running in, and the run's start time. That is exactly the work-to-chat association `GET /active_work` omits and `GET /schedule/list` redacts — handed over whole, one route away, by a handler that did not even accept a `HeaderMap`. It now resolves the run once, gates on the chat with `work_reach`, and builds the answer from the value it already resolved. Asking the scheduler a second time after the gate would be a second read of a fact that can change between them, which is the shape of defect the sibling kill route just fixed. The refusal is the chat read's own, so an unadmitted caller cannot tell a private chat's running schedule from a schedule that is idle or absent: all three resolve to no reachable chat and answer identically. An admitted caller still gets the truth, including the 404. Fail-before evidence: the new ordering row panicked with "pub async fn inspect_running_job( does not consult its session-reach gate (`work_reach(`)" against the unfixed handler. Census: `work_reach` in schedule.rs goes 1 -> 2 calls, and the module-qualifier row for that file 2 -> 3 refs. Both are the existing rows extended, not new ones. --- .../biorouter-server/src/routes/schedule.rs | 27 ++++++++++++++++--- .../src/routes/session_reach.rs | 7 +++++ .../biorouter/tests/privacy_guard_wiring.rs | 21 +++++++++------ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/biorouter-server/src/routes/schedule.rs b/crates/biorouter-server/src/routes/schedule.rs index 58e45a55a..e46a89aac 100644 --- a/crates/biorouter-server/src/routes/schedule.rs +++ b/crates/biorouter-server/src/routes/schedule.rs @@ -634,10 +634,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); @@ -657,8 +674,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_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index 9aa0553a4..b3ec0a9ba 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -1614,6 +1614,13 @@ mod tests { "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(|| { diff --git a/crates/biorouter/tests/privacy_guard_wiring.rs b/crates/biorouter/tests/privacy_guard_wiring.rs index 22312d143..1748a62aa 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -367,15 +367,17 @@ const REGISTRY: &[Guard] = &[ }, Site { file: "crates/biorouter-server/src/routes/schedule.rs", - counts: c(0, 2, 0), + counts: c(0, 3, 0), kind: SiteKind::Unrelated, - what: "the MODULE qualifier twice, on neither occasion this function. Once on \ + what: "the MODULE qualifier three 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`, which gates `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", + `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", }, Site { file: "crates/biorouter-server/src/routes/session.rs", @@ -598,7 +600,7 @@ const REGISTRY: &[Guard] = &[ }, Site { file: "crates/biorouter-server/src/routes/schedule.rs", - counts: c(1, 0, 0), + 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 \ @@ -607,7 +609,10 @@ const REGISTRY: &[Guard] = &[ 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", + 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", }, ], }, From 1a0b4c14fc775d06837439b47ecc4aa6a99b4d2d Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 19:40:43 -0700 Subject: [PATCH 14/15] fix(privacy): a schedule listing names only the chats the caller could open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /schedule/list` returned every schedule on the machine with the chat that created it (`creator_session_id`) and, for a running one, the chat the run is in (`current_session_id`) — to any holder of the daemon secret, from a handler that did not accept a `HeaderMap`. That is the same work-to-chat association `GET /active_work` omits, and it made this route the easy way around it. ⚠ This one REDACTS rather than omitting, and the difference is the subject. 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. 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 fixture list in the new test carries an idle schedule naming no chat precisely to pin that. The two fields are asked about separately: a schedule created from a public chat can be running in a private one, and the reverse. No response shape change, so no client regeneration is forced: both fields are already `Option`, redaction yields the `null` an idle schedule has always produced, and the generated TS is already `?: string | null`. Measured what the client does with a missing one before changing it — `ScheduleDetailView.tsx:504` guards with `running && current_session_id &&`, so it simply omits the line; `creator_session_id` has no renderer reader at all. `lists_work` is reused rather than joined by a `may_name_chat` beside it: "may this caller be told this chat exists" must have one spelling, and a second is the drift the census exists to catch. Its doc now names both subjects. Fail-before evidence, both halves, each measured by breaking exactly one thing: - the decision, with the `current_session_id` arm neutered: "a private chat was named in the schedule listing (proof: false)"; - the wiring, with the handler's call removed: "`GET /schedule/list` does not redact the chats it names". An earlier HTTP-level version of this test also captured the raw leak (`"current_session_id":"20260912_1"` for a private chat) before the fix; it was replaced because seeding a schedule needs `add_scheduled_job`, which registers on the process-global tokio-cron-scheduler while each `#[tokio::test]` brings its own runtime — it passes alone and fails with `CantAdd` in the suite. The chats, tiers and caller in the replacement are real; only the rows are hand-built, and a body scan holds the route's wiring. Census: `lists_work` gains a schedule.rs site (2 calls), `http_caller` there goes 1 -> 2, and the module-qualifier row 3 -> 5 refs. Existing rows extended, never duplicated. --- .../biorouter-server/src/routes/schedule.rs | 53 ++++++- .../src/routes/session_reach.rs | 143 +++++++++++++++++- .../biorouter/tests/privacy_guard_wiring.rs | 41 +++-- 3 files changed, 223 insertions(+), 14 deletions(-) diff --git a/crates/biorouter-server/src/routes/schedule.rs b/crates/biorouter-server/src/routes/schedule.rs index e46a89aac..bff097c1b 100644 --- a/crates/biorouter-server/src/routes/schedule.rs +++ b/crates/biorouter-server/src/routes/schedule.rs @@ -191,14 +191,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}", diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index b3ec0a9ba..f61ee1d25 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -715,8 +715,16 @@ impl HttpCaller { self.admits(TargetTier::from(classification)) } - /// May this caller be shown a row of RUNNING WORK — `GET /active_work` — - /// that belongs to the chat `owner`? + /// 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 @@ -1672,6 +1680,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("), ] { assert!( !body_of(src, control).contains("session_reach("), @@ -3623,6 +3635,133 @@ mod bypass_tests { } } + /// `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")] diff --git a/crates/biorouter/tests/privacy_guard_wiring.rs b/crates/biorouter/tests/privacy_guard_wiring.rs index 1748a62aa..31ac0b0bc 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -367,9 +367,9 @@ const REGISTRY: &[Guard] = &[ }, Site { file: "crates/biorouter-server/src/routes/schedule.rs", - counts: c(0, 3, 0), + counts: c(0, 5, 0), kind: SiteKind::Unrelated, - what: "the MODULE qualifier three times, on no occasion this function. Once on \ + 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 \ @@ -377,7 +377,9 @@ const REGISTRY: &[Guard] = &[ /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", + 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", @@ -514,9 +516,11 @@ const REGISTRY: &[Guard] = &[ }, Site { file: "crates/biorouter-server/src/routes/schedule.rs", - counts: c(1, 0, 0), + counts: c(2, 0, 0), kind: SiteKind::Guard, - what: "`GET /schedule/{id}/sessions`, a schedule's runs by name and directory", + 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", @@ -572,14 +576,29 @@ const REGISTRY: &[Guard] = &[ 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 — \ + 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", From 1e2512382b57cc7d2a4dac4e44e61d4145ef71c6 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Fri, 11 Sep 2026 20:01:20 -0700 Subject: [PATCH 15/15] docs(privacy): the schedule residual is closed; regenerate the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three documents and two module headers still described `GET /schedule/list`, `GET /schedule/{id}/inspect` and `POST /schedule/{id}/kill` as an open hole. They are now gated, so saying otherwise is not a harmless stale line — a residual table that lists a closed hole teaches the next reader to distrust the table, and one that omits an open one is worse. - `programmatic-session-access.md`: the three routes leave "Ungated, and a known residual". `kill` and `inspect` join the gated table, with the reason `kill` had to be gated (it reaches the same `Scheduler` kill as `/active_work/{id}/cancel` by the schedule id, so an ungated twin made that gate bypassable by a one-word change of URL). `GET /schedule/list` joins the listing table with its own note: the one listing that redacts FIELDS instead of dropping rows. - `routes/active_work.rs` and `routes/session_reach.rs` headers say the same, and say where the gate lives, so neither file claims a hole its neighbour closed. ⚠ `POST /schedule/{id}/run_now` and `POST /schedule/create` stay in the residual table. They launch scheduled work that may run in a private session, which is a different question from naming or stopping an existing run, and nothing here closes them. OpenAPI: `just generate-openapi` (+ `npm run generate-api`). `kill` gains 403 / 404 / 400 and an `id` param it never documented; `inspect` gains 403; `list`'s 200 now says which fields a row may lose and that rows are never dropped. The 400 wording follows `classify_kill_error`, which maps the run-changed refusal to BAD_REQUEST — the annotation said 409 for one draft and was corrected to what the code actually returns. `ScheduledJob`'s two fields were already `Option`, so redaction changes no schema; the +24 lines in `types.gen.ts` are the new responses. --- .../src/routes/active_work.rs | 15 ++++++--- .../biorouter-server/src/routes/schedule.rs | 31 ++++++++++++++++++- .../src/routes/session_reach.rs | 11 ++++--- .../deployment/programmatic-session-access.md | 4 ++- ui/desktop/openapi.json | 15 ++++++++- ui/desktop/src/api/index.ts | 2 +- ui/desktop/src/api/sdk.gen.ts | 4 +-- ui/desktop/src/api/types.gen.ts | 24 +++++++++++++- 8 files changed, 90 insertions(+), 16 deletions(-) diff --git a/crates/biorouter-server/src/routes/active_work.rs b/crates/biorouter-server/src/routes/active_work.rs index 63a79500c..bac2448bf 100644 --- a/crates/biorouter-server/src/routes/active_work.rs +++ b/crates/biorouter-server/src/routes/active_work.rs @@ -28,11 +28,16 @@ //! 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 not closed by this file.** `GET /schedule/list` -//! and `GET /schedule/{id}/inspect` still name a running schedule's chat, and -//! `POST /schedule/{id}/kill` still stops it, for any holder of the daemon -//! secret; see the residual table in -//! `docs/deployment/programmatic-session-access.md`. +//! ⚠ **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; diff --git a/crates/biorouter-server/src/routes/schedule.rs b/crates/biorouter-server/src/routes/schedule.rs index bff097c1b..f9f322e84 100644 --- a/crates/biorouter-server/src/routes/schedule.rs +++ b/crates/biorouter-server/src/routes/schedule.rs @@ -183,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" @@ -592,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" )] @@ -676,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") ), diff --git a/crates/biorouter-server/src/routes/session_reach.rs b/crates/biorouter-server/src/routes/session_reach.rs index f61ee1d25..aee26cd20 100644 --- a/crates/biorouter-server/src/routes/session_reach.rs +++ b/crates/biorouter-server/src/routes/session_reach.rs @@ -36,10 +36,13 @@ //! 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 not closed: -//! `GET /schedule/list` and `GET /schedule/{id}/inspect` still name a running -//! schedule's chat, and `POST /schedule/{id}/kill` still stops it, for any -//! holder of the secret. `GET /sessions/running` (ids +//! 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` diff --git a/docs/deployment/programmatic-session-access.md b/docs/deployment/programmatic-session-access.md index fff48772b..c3c47588b 100644 --- a/docs/deployment/programmatic-session-access.md +++ b/docs/deployment/programmatic-session-access.md @@ -184,6 +184,8 @@ one of them resolves the target's tier **before** it touches the session, so a r | `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. | 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 @@ -194,6 +196,7 @@ 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. | @@ -249,7 +252,6 @@ reader should not infer from this page that the surface is complete: | `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. | -| `GET /schedule/list`, `GET /schedule/{id}/inspect`, `POST /schedule/{id}/kill` | Every schedule, with the chat that created it and each running one's `current_session_id`, and a way to stop it. This is the scheduled half of what `/active_work` now filters, still open through the schedule routes: a caller that `/active_work` refuses a private chat's scheduled run can find its chat here and stop it here. | | `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 diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4bc80e613..5cac90588 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3566,7 +3566,7 @@ "operationId": "list_schedules", "responses": { "200": { - "description": "A list of scheduled jobs", + "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", "content": { "application/json": { "schema": { @@ -3659,6 +3659,9 @@ } } }, + "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" + }, "404": { "description": "Scheduled job not found" }, @@ -3678,6 +3681,7 @@ { "name": "id", "in": "path", + "description": "ID of the schedule whose run should be stopped", "required": true, "schema": { "type": "string" @@ -3687,6 +3691,15 @@ "responses": { "200": { "description": "Running job killed successfully" + }, + "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" + }, + "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" + }, + "404": { + "description": "No such schedule" } } } diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index fcf6d1847..656cf190d 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { abandonContinuationLease, ackPrivacyDisclosure, addExtension, addRawSource, agentAddExtension, agentCrossAffiliationGrant, agentRemoveExtension, backupConfig, callTool, cancelActiveWork, cancelTurn, catalogChanges, catalogRevision, checkModel, checkProvider, codingAgentsStatus, confirmToolAction, createBase, createCustomProvider, createSchedule, createWorkflow, declassifySession, decodeWorkflow, deleteBase, deleteSchedule, deleteSession, deleteWorkflow, detectProvider, diagnostics, divergeSession, editMessage, encodeWorkflow, exportBrkb, exportSession, getActive, getBase, getCallableToolCount, getCustomProvider, getDetectableProviders, getExtensions, getGraph, getKbTier, getLocation, getPageBody, getPricing, getPrivacyDisclosure, getProviderModels, getSession, getSessionActivity, getSessionExtensions, getSessionInsights, getSessionUsage, getSlashCommands, getTools, getTunnelStatus, getUsageReport, getUsageSummary, importBrkb, importSession, ingest, ingestConversation, initConfig, inspectRunningJob, installSkillPackage, interrupt, killRunningJob, lint, listActiveWork, listBases, listHistory, listPages, listSchedules, listSessions, listSidebarSessions, listWorkflows, llamacppDelete, llamacppEnsure, llamacppStatus, llamacppStop, llamacppWarmup, memoryDeleteCategory, memoryDeleteEntry, memoryInventory, mergeBases, observeSessionEvents, type Options, overrideCredibility, parseWorkflow, pauseSchedule, previewReset, previewSkillPackage, previewState, providers, queryKb, readAllConfig, readConfig, readPage, readResource, reclassify, recoverConfig, recoverContinuation, refreshSkillCatalog, removeConfig, removeCustomProvider, removeExtension, removeSkillPackage, reply, resetAppData, restartAgent, restoreState, resumeAgent, runningSessions, runNowHandler, saveWorkflow, scanWorkflow, scheduleWorkflow, sessionChanges, sessionsHandler, setActive, setConfigProvider, setDefaultModel, setKbTier, setSessionSkills, setWorkflowSlashCommand, skillCatalogHandler, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, submitSecrets, systemInfo, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserWorkflowValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig, workflowToYaml, writePage } from './sdk.gen'; -export type { AbandonContinuationLeaseData, AbandonContinuationLeaseError, AbandonContinuationLeaseErrors, AbandonContinuationLeaseRequest, AbandonContinuationLeaseResponse, AbandonContinuationLeaseResponse2, AbandonContinuationLeaseResponses, AckPrivacyDisclosureData, AckPrivacyDisclosureErrors, AckPrivacyDisclosureResponses, ActionRequired, ActionRequiredData, ActiveKbResponse, ActiveTurnRef, ActiveWorkItemDto, ActiveWorkResponse, ActivityWindow, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AddRawSourceData, AddRawSourceErrors, AddRawSourceResponse, AddRawSourceResponses, AffiliationInstitution, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentAvailability, AgentCrossAffiliationGrantData, AgentCrossAffiliationGrantErrors, AgentCrossAffiliationGrantResponse, AgentCrossAffiliationGrantResponses, AgentInitializationError, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Ambiguity, Annotations, Author, AuthorRequest, AuthState, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallableToolCountQuery, CallableToolCountResponse, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelActiveWorkData, CancelActiveWorkErrors, CancelActiveWorkResponse, CancelActiveWorkResponse2, CancelActiveWorkResponses, CancelTurnConflict, CancelTurnConflictResponse, CancelTurnData, CancelTurnError, CancelTurnErrors, CancelTurnRequest, CancelTurnResponse, CancelTurnResponse2, CancelTurnResponses, CarriedPage, CatalogBundle, CatalogChanged, CatalogChangeReason, CatalogChangesData, CatalogChangesErrors, CatalogChangesResponse, CatalogChangesResponses, CatalogDelta, CatalogEntryChange, CatalogExtensionChange, CatalogRevisionData, CatalogRevisionErrors, CatalogRevisionResponse, CatalogRevisionResponses, CatalogSkill, CatalogSkillChange, CatalogView, ChangeKind, ChatRequest, CheckModelBody, CheckModelData, CheckModelError, CheckModelErrors, CheckModelResponse, CheckModelResponse2, CheckModelResponses, CheckProviderData, CheckProviderRequest, ClientOptions, CodingAgentKind, CodingAgentsStatusData, CodingAgentsStatusResponse, CodingAgentsStatusResponses, CodingAgentStatusResponse, CommandType, CommitResponse, ConfigKey, ConfigKeyQuery, ConfigRecoveryReport, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContinuationLeaseErrorResponse, Conversation, CreateBaseBody, CreateBaseData, CreateBaseErrors, CreateBaseResponse, CreateBaseResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleError, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CreateWorkflowData, CreateWorkflowErrors, CreateWorkflowRequest, CreateWorkflowResponse, CreateWorkflowResponse2, CreateWorkflowResponses, Credibility, CredibilityResponse, CredibilityTier, CrossAffiliationGrantRequest, CrossAffiliationGrantResponse, DailyActivity, DeclarativeProviderConfig, DeclassifySessionData, DeclassifySessionErrors, DeclassifySessionRequest, DeclassifySessionResponse, DeclassifySessionResponse2, DeclassifySessionResponses, DecodeWorkflowData, DecodeWorkflowErrors, DecodeWorkflowRequest, DecodeWorkflowResponse, DecodeWorkflowResponse2, DecodeWorkflowResponses, DeleteBaseData, DeleteBaseErrors, DeleteBaseResponse, DeleteBaseResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DeleteWorkflowData, DeleteWorkflowErrors, DeleteWorkflowRequest, DeleteWorkflowResponse, DeleteWorkflowResponses, DetectableProvider, DetectableProvidersResponse, DetectProviderData, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, Diagnostic, Diagnostics, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DivergeSessionData, DivergeSessionErrors, DivergeSessionRequest, DivergeSessionResponse, DivergeSessionResponse2, DivergeSessionResponses, EditMessageData, EditMessageErrors, EditMessageRequest, EditMessageResponse, EditMessageResponse2, EditMessageResponses, EditType, EmbeddedResource, EncodeWorkflowData, EncodeWorkflowErrors, EncodeWorkflowRequest, EncodeWorkflowResponse, EncodeWorkflowResponse2, EncodeWorkflowResponses, Envs, ErrorResponse, Evidence, ExportBrkbData, ExportBrkbErrors, ExportBrkbResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FrontendToolRequest, GetActiveData, GetActiveErrors, GetActiveResponse, GetActiveResponses, GetBaseData, GetBaseErrors, GetBaseResponse, GetBaseResponses, GetCallableToolCountData, GetCallableToolCountErrors, GetCallableToolCountResponse, GetCallableToolCountResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDetectableProvidersData, GetDetectableProvidersResponse, GetDetectableProvidersResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetGraphData, GetGraphErrors, GetGraphResponse, GetGraphResponses, GetKbTierData, GetKbTierErrors, GetKbTierResponse, GetKbTierResponses, GetLocationData, GetLocationErrors, GetLocationResponse, GetLocationResponses, GetPageBodyData, GetPageBodyErrors, GetPageBodyResponse, GetPageBodyResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetPrivacyDisclosureData, GetPrivacyDisclosureResponse, GetPrivacyDisclosureResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionActivityData, GetSessionActivityErrors, GetSessionActivityResponse, GetSessionActivityResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSessionUsageData, GetSessionUsageErrors, GetSessionUsageResponse, GetSessionUsageResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GetUsageReportData, GetUsageReportErrors, GetUsageReportResponse, GetUsageReportResponses, GetUsageSummaryData, GetUsageSummaryErrors, GetUsageSummaryResponse, GetUsageSummaryResponses, Graph, GraphEdge, GraphNode, HistoryEntry, HistoryQuery, Icon, ImageContent, ImportBrkbData, ImportBrkbErrors, ImportBrkbResponses, ImportChoice, ImportKind, ImportPreview, ImportRequest, ImportResult, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, IngestBody, IngestConversationBody, IngestConversationData, IngestConversationErrors, IngestConversationResponses, IngestData, IngestErrors, IngestResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, InstalledPackage, InstallSkillPackageData, InstallSkillPackageErrors, InstallSkillPackageResponse, InstallSkillPackageResponses, InterruptAccepted, InterruptData, InterruptErrors, InterruptRequest, InterruptResponse, InterruptResponses, JsonObject, KbFormat, KbListEntry, KbTier, KbTierResponse, KillJobResponse, KillRunningJobData, KillRunningJobResponses, LintBody, LintData, LintErrors, LintReport, LintResponses, LintResult, ListActiveWorkData, ListActiveWorkResponse, ListActiveWorkResponses, ListBasesData, ListBasesResponse, ListBasesResponses, ListHistoryData, ListHistoryResponses, ListPagesData, ListPagesQuery, ListPagesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, ListSidebarSessionsData, ListSidebarSessionsErrors, ListSidebarSessionsResponse, ListSidebarSessionsResponses, ListWorkflowResponse, ListWorkflowsData, ListWorkflowsErrors, ListWorkflowsResponse, ListWorkflowsResponses, LlamacppDeleteData, LlamacppDeleteErrors, LlamaCppDeleteRequest, LlamacppDeleteResponse, LlamaCppDeleteResponse, LlamacppDeleteResponses, LlamacppEnsureData, LlamacppEnsureErrors, LlamaCppEnsureRequest, LlamacppEnsureResponse, LlamacppEnsureResponses, LlamaCppModel, LlamacppStatusData, LlamacppStatusResponse, LlamaCppStatusResponse, LlamacppStatusResponses, LlamacppStopData, LlamacppStopResponse, LlamacppStopResponses, LlamaCppSuitability, LlamaCppSystemInfo, LlamacppWarmupData, LlamacppWarmupErrors, LlamaCppWarmupRequest, LlamacppWarmupResponse, LlamaCppWarmupResponse, LlamacppWarmupResponses, LoadedProvider, LocationResponse, Manifest, MemoryCategoryInventory, MemoryDeleteCategoryData, MemoryDeleteCategoryErrors, MemoryDeleteCategoryRequest, MemoryDeleteCategoryResponse, MemoryDeleteCategoryResponse2, MemoryDeleteCategoryResponses, MemoryDeleteEntryData, MemoryDeleteEntryErrors, MemoryDeleteEntryRequest, MemoryDeleteEntryResponse, MemoryDeleteEntryResponse2, MemoryDeleteEntryResponses, MemoryEntry, MemoryInventoryData, MemoryInventoryErrors, MemoryInventoryResponse, MemoryInventoryResponse2, MemoryInventoryResponses, MemoryScope, MemoryStoreInventory, MergeBasesData, MergeBasesErrors, MergeBasesResponse, MergeBasesResponses, MergeBody, MergeReport, Message, MessageContent, MessageEvent, MessageMetadata, MessageProvenance, ModelCacheStatus, ModelConfig, ModelInfo, ModelRef, ModelUsageRow, ObserveSessionEventsData, ObserveSessionEventsErrors, ObserveSessionEventsResponse, ObserveSessionEventsResponses, OverrideCredibilityData, OverrideCredibilityErrors, OverrideCredibilityResponse, OverrideCredibilityResponses, PackageSummary, PageContent, PageKind, PageRef, ParseWorkflowData, ParseWorkflowError, ParseWorkflowErrors, ParseWorkflowRequest, ParseWorkflowResponse, ParseWorkflowResponse2, ParseWorkflowResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, PendingContinuationOwnership, PendingContinuationRef, PermissionLevel, PersistedMessage, PlannedSkill, PreviewBody, PreviewResetData, PreviewResetError, PreviewResetErrors, PreviewResetResponse, PreviewResetResponses, PreviewResponse, PreviewSkillPackageData, PreviewSkillPackageErrors, PreviewSkillPackageResponse, PreviewSkillPackageResponses, PreviewStateData, PreviewStateErrors, PreviewStateResponse, PreviewStateResponses, PricingData, PricingQuery, PricingResponse, PrincipalType, PrivacyBarrierBody, PrivacyDisclosureResponse, ProvenanceKind, ProviderAffiliation, ProviderAffiliationKind, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTier, ProviderType, QuantitativeValue, QueryBody, QueryKbData, QueryKbErrors, QueryKbResponses, RawAudioContent, RawDedup, RawEmbeddedResource, RawImageContent, RawResource, RawSourceResponse, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadPageData, ReadPageErrors, ReadPageQuery, ReadPageResponse, ReadPageResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, ReasoningEffort, ReclassifyData, ReclassifyErrors, ReclassifyResponse, ReclassifyResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RecoverContinuationAction, RecoverContinuationData, RecoverContinuationError, RecoverContinuationErrors, RecoverContinuationRequest, RecoverContinuationResponse, RecoverContinuationResponse2, RecoverContinuationResponses, RedactedThinkingContent, RefreshSkillCatalogData, RefreshSkillCatalogErrors, RefreshSkillCatalogResponse, RefreshSkillCatalogResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, RemovePackageRequest, RemoveSkillPackageData, RemoveSkillPackageErrors, RemoveSkillPackageResponse, RemoveSkillPackageResponses, Rename, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetAppDataData, ResetAppDataError, ResetAppDataErrors, ResetAppDataResponse, ResetAppDataResponses, ResetCategory, ResetCounts, ResetErrorResponse, ResetPreviewResponse, ResetRequest, ResetResponse, ResourceContents, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, RestoreBody, RestoreResponse, RestoreStateData, RestoreStateErrors, RestoreStateResponse, RestoreStateResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunningSessionsData, RunningSessionsErrors, RunningSessionsResponse, RunningSessionsResponse2, RunningSessionsResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SaveWorkflowData, SaveWorkflowError, SaveWorkflowErrors, SaveWorkflowRequest, SaveWorkflowResponse, SaveWorkflowResponse2, SaveWorkflowResponses, ScanWorkflowData, ScanWorkflowRequest, ScanWorkflowResponse, ScanWorkflowResponse2, ScanWorkflowResponses, ScheduledJob, ScheduleWorkflowData, ScheduleWorkflowErrors, ScheduleWorkflowRequest, ScheduleWorkflowResponses, SecretDestination, SecretKeyRequest, Session, SessionChangesData, SessionChangesErrors, SessionChangesResponse, SessionChangesResponses, SessionClassification, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionMetaChanged, SessionMetaDelta, SessionModelUsageResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionSkillsRequest, SessionSkillsResponse, SessionsQuery, SessionState, SessionSummary, SessionType, SetActiveBody, SetActiveData, SetActiveErrors, SetActiveResponse, SetActiveResponses, SetConfigProviderData, SetConfigProviderErrors, SetConfigProviderResponses, SetDefaultModelBody, SetDefaultModelData, SetDefaultModelErrors, SetDefaultModelResponse, SetDefaultModelResponses, SetKbTierBody, SetKbTierData, SetKbTierErrors, SetKbTierResponse, SetKbTierResponses, SetProviderRequest, SetSessionSkillsData, SetSessionSkillsErrors, SetSessionSkillsResponse, SetSessionSkillsResponses, SetSlashCommandRequest, Settings, SetupResponse, SetWorkflowSlashCommandData, SetWorkflowSlashCommandErrors, SetWorkflowSlashCommandResponses, Severity, SidebarSessionListResponse, SidecarState, SidecarStatus, SkillCatalogHandlerData, SkillCatalogHandlerErrors, SkillCatalogHandlerResponse, SkillCatalogHandlerResponses, SkillRoot, SkillSource, SkillSourceKind, SkillState, SlashCommand, SlashCommandsResponse, SourceMeta, SourceProvenance, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubmitSecretsData, SubmitSecretsErrors, SubmitSecretsRequest, SubmitSecretsResponses, SubWorkflow, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolInfo, ToolPermission, ToolPreview, ToolPreviewLine, ToolPreviewLineKind, ToolRequest, ToolResponse, ToolRisk, TunnelInfo, TunnelState, TurnErrorScope, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderError, UpdateAgentProviderErrors, UpdateAgentProviderResponse, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserWorkflowValuesData, UpdateSessionUserWorkflowValuesError, UpdateSessionUserWorkflowValuesErrors, UpdateSessionUserWorkflowValuesRequest, UpdateSessionUserWorkflowValuesResponse, UpdateSessionUserWorkflowValuesResponse2, UpdateSessionUserWorkflowValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, UsageGroup, UsageReportResponse, UsageReportRow, UsageSummary, UsageSummaryResponse, UsageTotals, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, Workflow, WorkflowKnowledgeBases, WorkflowManifest, WorkflowParameter, WorkflowParameterInputType, WorkflowParameterRequirement, WorkflowToYamlData, WorkflowToYamlError, WorkflowToYamlErrors, WorkflowToYamlRequest, WorkflowToYamlResponse, WorkflowToYamlResponse2, WorkflowToYamlResponses, WritePageBody, WritePageData, WritePageErrors, WritePageResponse, WritePageResponses } from './types.gen'; +export type { AbandonContinuationLeaseData, AbandonContinuationLeaseError, AbandonContinuationLeaseErrors, AbandonContinuationLeaseRequest, AbandonContinuationLeaseResponse, AbandonContinuationLeaseResponse2, AbandonContinuationLeaseResponses, AckPrivacyDisclosureData, AckPrivacyDisclosureErrors, AckPrivacyDisclosureResponses, ActionRequired, ActionRequiredData, ActiveKbResponse, ActiveTurnRef, ActiveWorkItemDto, ActiveWorkResponse, ActivityWindow, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AddRawSourceData, AddRawSourceErrors, AddRawSourceResponse, AddRawSourceResponses, AffiliationInstitution, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentAvailability, AgentCrossAffiliationGrantData, AgentCrossAffiliationGrantErrors, AgentCrossAffiliationGrantResponse, AgentCrossAffiliationGrantResponses, AgentInitializationError, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Ambiguity, Annotations, Author, AuthorRequest, AuthState, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallableToolCountQuery, CallableToolCountResponse, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelActiveWorkData, CancelActiveWorkErrors, CancelActiveWorkResponse, CancelActiveWorkResponse2, CancelActiveWorkResponses, CancelTurnConflict, CancelTurnConflictResponse, CancelTurnData, CancelTurnError, CancelTurnErrors, CancelTurnRequest, CancelTurnResponse, CancelTurnResponse2, CancelTurnResponses, CarriedPage, CatalogBundle, CatalogChanged, CatalogChangeReason, CatalogChangesData, CatalogChangesErrors, CatalogChangesResponse, CatalogChangesResponses, CatalogDelta, CatalogEntryChange, CatalogExtensionChange, CatalogRevisionData, CatalogRevisionErrors, CatalogRevisionResponse, CatalogRevisionResponses, CatalogSkill, CatalogSkillChange, CatalogView, ChangeKind, ChatRequest, CheckModelBody, CheckModelData, CheckModelError, CheckModelErrors, CheckModelResponse, CheckModelResponse2, CheckModelResponses, CheckProviderData, CheckProviderRequest, ClientOptions, CodingAgentKind, CodingAgentsStatusData, CodingAgentsStatusResponse, CodingAgentsStatusResponses, CodingAgentStatusResponse, CommandType, CommitResponse, ConfigKey, ConfigKeyQuery, ConfigRecoveryReport, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContinuationLeaseErrorResponse, Conversation, CreateBaseBody, CreateBaseData, CreateBaseErrors, CreateBaseResponse, CreateBaseResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleError, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CreateWorkflowData, CreateWorkflowErrors, CreateWorkflowRequest, CreateWorkflowResponse, CreateWorkflowResponse2, CreateWorkflowResponses, Credibility, CredibilityResponse, CredibilityTier, CrossAffiliationGrantRequest, CrossAffiliationGrantResponse, DailyActivity, DeclarativeProviderConfig, DeclassifySessionData, DeclassifySessionErrors, DeclassifySessionRequest, DeclassifySessionResponse, DeclassifySessionResponse2, DeclassifySessionResponses, DecodeWorkflowData, DecodeWorkflowErrors, DecodeWorkflowRequest, DecodeWorkflowResponse, DecodeWorkflowResponse2, DecodeWorkflowResponses, DeleteBaseData, DeleteBaseErrors, DeleteBaseResponse, DeleteBaseResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DeleteWorkflowData, DeleteWorkflowErrors, DeleteWorkflowRequest, DeleteWorkflowResponse, DeleteWorkflowResponses, DetectableProvider, DetectableProvidersResponse, DetectProviderData, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, Diagnostic, Diagnostics, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DivergeSessionData, DivergeSessionErrors, DivergeSessionRequest, DivergeSessionResponse, DivergeSessionResponse2, DivergeSessionResponses, EditMessageData, EditMessageErrors, EditMessageRequest, EditMessageResponse, EditMessageResponse2, EditMessageResponses, EditType, EmbeddedResource, EncodeWorkflowData, EncodeWorkflowErrors, EncodeWorkflowRequest, EncodeWorkflowResponse, EncodeWorkflowResponse2, EncodeWorkflowResponses, Envs, ErrorResponse, Evidence, ExportBrkbData, ExportBrkbErrors, ExportBrkbResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FrontendToolRequest, GetActiveData, GetActiveErrors, GetActiveResponse, GetActiveResponses, GetBaseData, GetBaseErrors, GetBaseResponse, GetBaseResponses, GetCallableToolCountData, GetCallableToolCountErrors, GetCallableToolCountResponse, GetCallableToolCountResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDetectableProvidersData, GetDetectableProvidersResponse, GetDetectableProvidersResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetGraphData, GetGraphErrors, GetGraphResponse, GetGraphResponses, GetKbTierData, GetKbTierErrors, GetKbTierResponse, GetKbTierResponses, GetLocationData, GetLocationErrors, GetLocationResponse, GetLocationResponses, GetPageBodyData, GetPageBodyErrors, GetPageBodyResponse, GetPageBodyResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetPrivacyDisclosureData, GetPrivacyDisclosureResponse, GetPrivacyDisclosureResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionActivityData, GetSessionActivityErrors, GetSessionActivityResponse, GetSessionActivityResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSessionUsageData, GetSessionUsageErrors, GetSessionUsageResponse, GetSessionUsageResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GetUsageReportData, GetUsageReportErrors, GetUsageReportResponse, GetUsageReportResponses, GetUsageSummaryData, GetUsageSummaryErrors, GetUsageSummaryResponse, GetUsageSummaryResponses, Graph, GraphEdge, GraphNode, HistoryEntry, HistoryQuery, Icon, ImageContent, ImportBrkbData, ImportBrkbErrors, ImportBrkbResponses, ImportChoice, ImportKind, ImportPreview, ImportRequest, ImportResult, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, IngestBody, IngestConversationBody, IngestConversationData, IngestConversationErrors, IngestConversationResponses, IngestData, IngestErrors, IngestResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, InstalledPackage, InstallSkillPackageData, InstallSkillPackageErrors, InstallSkillPackageResponse, InstallSkillPackageResponses, InterruptAccepted, InterruptData, InterruptErrors, InterruptRequest, InterruptResponse, InterruptResponses, JsonObject, KbFormat, KbListEntry, KbTier, KbTierResponse, KillJobResponse, KillRunningJobData, KillRunningJobErrors, KillRunningJobResponses, LintBody, LintData, LintErrors, LintReport, LintResponses, LintResult, ListActiveWorkData, ListActiveWorkResponse, ListActiveWorkResponses, ListBasesData, ListBasesResponse, ListBasesResponses, ListHistoryData, ListHistoryResponses, ListPagesData, ListPagesQuery, ListPagesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, ListSidebarSessionsData, ListSidebarSessionsErrors, ListSidebarSessionsResponse, ListSidebarSessionsResponses, ListWorkflowResponse, ListWorkflowsData, ListWorkflowsErrors, ListWorkflowsResponse, ListWorkflowsResponses, LlamacppDeleteData, LlamacppDeleteErrors, LlamaCppDeleteRequest, LlamacppDeleteResponse, LlamaCppDeleteResponse, LlamacppDeleteResponses, LlamacppEnsureData, LlamacppEnsureErrors, LlamaCppEnsureRequest, LlamacppEnsureResponse, LlamacppEnsureResponses, LlamaCppModel, LlamacppStatusData, LlamacppStatusResponse, LlamaCppStatusResponse, LlamacppStatusResponses, LlamacppStopData, LlamacppStopResponse, LlamacppStopResponses, LlamaCppSuitability, LlamaCppSystemInfo, LlamacppWarmupData, LlamacppWarmupErrors, LlamaCppWarmupRequest, LlamacppWarmupResponse, LlamaCppWarmupResponse, LlamacppWarmupResponses, LoadedProvider, LocationResponse, Manifest, MemoryCategoryInventory, MemoryDeleteCategoryData, MemoryDeleteCategoryErrors, MemoryDeleteCategoryRequest, MemoryDeleteCategoryResponse, MemoryDeleteCategoryResponse2, MemoryDeleteCategoryResponses, MemoryDeleteEntryData, MemoryDeleteEntryErrors, MemoryDeleteEntryRequest, MemoryDeleteEntryResponse, MemoryDeleteEntryResponse2, MemoryDeleteEntryResponses, MemoryEntry, MemoryInventoryData, MemoryInventoryErrors, MemoryInventoryResponse, MemoryInventoryResponse2, MemoryInventoryResponses, MemoryScope, MemoryStoreInventory, MergeBasesData, MergeBasesErrors, MergeBasesResponse, MergeBasesResponses, MergeBody, MergeReport, Message, MessageContent, MessageEvent, MessageMetadata, MessageProvenance, ModelCacheStatus, ModelConfig, ModelInfo, ModelRef, ModelUsageRow, ObserveSessionEventsData, ObserveSessionEventsErrors, ObserveSessionEventsResponse, ObserveSessionEventsResponses, OverrideCredibilityData, OverrideCredibilityErrors, OverrideCredibilityResponse, OverrideCredibilityResponses, PackageSummary, PageContent, PageKind, PageRef, ParseWorkflowData, ParseWorkflowError, ParseWorkflowErrors, ParseWorkflowRequest, ParseWorkflowResponse, ParseWorkflowResponse2, ParseWorkflowResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, PendingContinuationOwnership, PendingContinuationRef, PermissionLevel, PersistedMessage, PlannedSkill, PreviewBody, PreviewResetData, PreviewResetError, PreviewResetErrors, PreviewResetResponse, PreviewResetResponses, PreviewResponse, PreviewSkillPackageData, PreviewSkillPackageErrors, PreviewSkillPackageResponse, PreviewSkillPackageResponses, PreviewStateData, PreviewStateErrors, PreviewStateResponse, PreviewStateResponses, PricingData, PricingQuery, PricingResponse, PrincipalType, PrivacyBarrierBody, PrivacyDisclosureResponse, ProvenanceKind, ProviderAffiliation, ProviderAffiliationKind, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTier, ProviderType, QuantitativeValue, QueryBody, QueryKbData, QueryKbErrors, QueryKbResponses, RawAudioContent, RawDedup, RawEmbeddedResource, RawImageContent, RawResource, RawSourceResponse, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadPageData, ReadPageErrors, ReadPageQuery, ReadPageResponse, ReadPageResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, ReasoningEffort, ReclassifyData, ReclassifyErrors, ReclassifyResponse, ReclassifyResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RecoverContinuationAction, RecoverContinuationData, RecoverContinuationError, RecoverContinuationErrors, RecoverContinuationRequest, RecoverContinuationResponse, RecoverContinuationResponse2, RecoverContinuationResponses, RedactedThinkingContent, RefreshSkillCatalogData, RefreshSkillCatalogErrors, RefreshSkillCatalogResponse, RefreshSkillCatalogResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, RemovePackageRequest, RemoveSkillPackageData, RemoveSkillPackageErrors, RemoveSkillPackageResponse, RemoveSkillPackageResponses, Rename, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetAppDataData, ResetAppDataError, ResetAppDataErrors, ResetAppDataResponse, ResetAppDataResponses, ResetCategory, ResetCounts, ResetErrorResponse, ResetPreviewResponse, ResetRequest, ResetResponse, ResourceContents, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, RestoreBody, RestoreResponse, RestoreStateData, RestoreStateErrors, RestoreStateResponse, RestoreStateResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunningSessionsData, RunningSessionsErrors, RunningSessionsResponse, RunningSessionsResponse2, RunningSessionsResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SaveWorkflowData, SaveWorkflowError, SaveWorkflowErrors, SaveWorkflowRequest, SaveWorkflowResponse, SaveWorkflowResponse2, SaveWorkflowResponses, ScanWorkflowData, ScanWorkflowRequest, ScanWorkflowResponse, ScanWorkflowResponse2, ScanWorkflowResponses, ScheduledJob, ScheduleWorkflowData, ScheduleWorkflowErrors, ScheduleWorkflowRequest, ScheduleWorkflowResponses, SecretDestination, SecretKeyRequest, Session, SessionChangesData, SessionChangesErrors, SessionChangesResponse, SessionChangesResponses, SessionClassification, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionMetaChanged, SessionMetaDelta, SessionModelUsageResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionSkillsRequest, SessionSkillsResponse, SessionsQuery, SessionState, SessionSummary, SessionType, SetActiveBody, SetActiveData, SetActiveErrors, SetActiveResponse, SetActiveResponses, SetConfigProviderData, SetConfigProviderErrors, SetConfigProviderResponses, SetDefaultModelBody, SetDefaultModelData, SetDefaultModelErrors, SetDefaultModelResponse, SetDefaultModelResponses, SetKbTierBody, SetKbTierData, SetKbTierErrors, SetKbTierResponse, SetKbTierResponses, SetProviderRequest, SetSessionSkillsData, SetSessionSkillsErrors, SetSessionSkillsResponse, SetSessionSkillsResponses, SetSlashCommandRequest, Settings, SetupResponse, SetWorkflowSlashCommandData, SetWorkflowSlashCommandErrors, SetWorkflowSlashCommandResponses, Severity, SidebarSessionListResponse, SidecarState, SidecarStatus, SkillCatalogHandlerData, SkillCatalogHandlerErrors, SkillCatalogHandlerResponse, SkillCatalogHandlerResponses, SkillRoot, SkillSource, SkillSourceKind, SkillState, SlashCommand, SlashCommandsResponse, SourceMeta, SourceProvenance, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubmitSecretsData, SubmitSecretsErrors, SubmitSecretsRequest, SubmitSecretsResponses, SubWorkflow, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolInfo, ToolPermission, ToolPreview, ToolPreviewLine, ToolPreviewLineKind, ToolRequest, ToolResponse, ToolRisk, TunnelInfo, TunnelState, TurnErrorScope, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderError, UpdateAgentProviderErrors, UpdateAgentProviderResponse, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserWorkflowValuesData, UpdateSessionUserWorkflowValuesError, UpdateSessionUserWorkflowValuesErrors, UpdateSessionUserWorkflowValuesRequest, UpdateSessionUserWorkflowValuesResponse, UpdateSessionUserWorkflowValuesResponse2, UpdateSessionUserWorkflowValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, UsageGroup, UsageReportResponse, UsageReportRow, UsageSummary, UsageSummaryResponse, UsageTotals, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, Workflow, WorkflowKnowledgeBases, WorkflowManifest, WorkflowParameter, WorkflowParameterInputType, WorkflowParameterRequirement, WorkflowToYamlData, WorkflowToYamlError, WorkflowToYamlErrors, WorkflowToYamlRequest, WorkflowToYamlResponse, WorkflowToYamlResponse2, WorkflowToYamlResponses, WritePageBody, WritePageData, WritePageErrors, WritePageResponse, WritePageResponses } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index ec6768ea8..1efc21da0 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AbandonContinuationLeaseData, AbandonContinuationLeaseErrors, AbandonContinuationLeaseResponses, AckPrivacyDisclosureData, AckPrivacyDisclosureErrors, AckPrivacyDisclosureResponses, AddExtensionData, AddExtensionErrors, AddExtensionResponses, AddRawSourceData, AddRawSourceErrors, AddRawSourceResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentCrossAffiliationGrantData, AgentCrossAffiliationGrantErrors, AgentCrossAffiliationGrantResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CancelActiveWorkData, CancelActiveWorkErrors, CancelActiveWorkResponses, CancelTurnData, CancelTurnErrors, CancelTurnResponses, CatalogChangesData, CatalogChangesErrors, CatalogChangesResponses, CatalogRevisionData, CatalogRevisionErrors, CatalogRevisionResponses, CheckModelData, CheckModelErrors, CheckModelResponses, CheckProviderData, CodingAgentsStatusData, CodingAgentsStatusResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateBaseData, CreateBaseErrors, CreateBaseResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, CreateWorkflowData, CreateWorkflowErrors, CreateWorkflowResponses, DeclassifySessionData, DeclassifySessionErrors, DeclassifySessionResponses, DecodeWorkflowData, DecodeWorkflowErrors, DecodeWorkflowResponses, DeleteBaseData, DeleteBaseErrors, DeleteBaseResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DeleteWorkflowData, DeleteWorkflowErrors, DeleteWorkflowResponses, DetectProviderData, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DivergeSessionData, DivergeSessionErrors, DivergeSessionResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeWorkflowData, EncodeWorkflowErrors, EncodeWorkflowResponses, ExportBrkbData, ExportBrkbErrors, ExportBrkbResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetActiveData, GetActiveErrors, GetActiveResponses, GetBaseData, GetBaseErrors, GetBaseResponses, GetCallableToolCountData, GetCallableToolCountErrors, GetCallableToolCountResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDetectableProvidersData, GetDetectableProvidersResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetGraphData, GetGraphErrors, GetGraphResponses, GetKbTierData, GetKbTierErrors, GetKbTierResponses, GetLocationData, GetLocationErrors, GetLocationResponses, GetPageBodyData, GetPageBodyErrors, GetPageBodyResponses, GetPricingData, GetPricingResponses, GetPrivacyDisclosureData, GetPrivacyDisclosureResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionActivityData, GetSessionActivityErrors, GetSessionActivityResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSessionUsageData, GetSessionUsageErrors, GetSessionUsageResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, GetUsageReportData, GetUsageReportErrors, GetUsageReportResponses, GetUsageSummaryData, GetUsageSummaryErrors, GetUsageSummaryResponses, ImportBrkbData, ImportBrkbErrors, ImportBrkbResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, IngestConversationData, IngestConversationErrors, IngestConversationResponses, IngestData, IngestErrors, IngestResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, InstallSkillPackageData, InstallSkillPackageErrors, InstallSkillPackageResponses, InterruptData, InterruptErrors, InterruptResponses, KillRunningJobData, KillRunningJobResponses, LintData, LintErrors, LintResponses, ListActiveWorkData, ListActiveWorkResponses, ListBasesData, ListBasesResponses, ListHistoryData, ListHistoryResponses, ListPagesData, ListPagesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ListSidebarSessionsData, ListSidebarSessionsErrors, ListSidebarSessionsResponses, ListWorkflowsData, ListWorkflowsErrors, ListWorkflowsResponses, LlamacppDeleteData, LlamacppDeleteErrors, LlamacppDeleteResponses, LlamacppEnsureData, LlamacppEnsureErrors, LlamacppEnsureResponses, LlamacppStatusData, LlamacppStatusResponses, LlamacppStopData, LlamacppStopResponses, LlamacppWarmupData, LlamacppWarmupErrors, LlamacppWarmupResponses, MemoryDeleteCategoryData, MemoryDeleteCategoryErrors, MemoryDeleteCategoryResponses, MemoryDeleteEntryData, MemoryDeleteEntryErrors, MemoryDeleteEntryResponses, MemoryInventoryData, MemoryInventoryErrors, MemoryInventoryResponses, MergeBasesData, MergeBasesErrors, MergeBasesResponses, ObserveSessionEventsData, ObserveSessionEventsErrors, ObserveSessionEventsResponses, OverrideCredibilityData, OverrideCredibilityErrors, OverrideCredibilityResponses, ParseWorkflowData, ParseWorkflowErrors, ParseWorkflowResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, PreviewResetData, PreviewResetErrors, PreviewResetResponses, PreviewSkillPackageData, PreviewSkillPackageErrors, PreviewSkillPackageResponses, PreviewStateData, PreviewStateErrors, PreviewStateResponses, ProvidersData, ProvidersResponses, QueryKbData, QueryKbErrors, QueryKbResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadPageData, ReadPageErrors, ReadPageResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, ReclassifyData, ReclassifyErrors, ReclassifyResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RecoverContinuationData, RecoverContinuationErrors, RecoverContinuationResponses, RefreshSkillCatalogData, RefreshSkillCatalogErrors, RefreshSkillCatalogResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, RemoveSkillPackageData, RemoveSkillPackageErrors, RemoveSkillPackageResponses, ReplyData, ReplyErrors, ReplyResponses, ResetAppDataData, ResetAppDataErrors, ResetAppDataResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, RestoreStateData, RestoreStateErrors, RestoreStateResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunningSessionsData, RunningSessionsErrors, RunningSessionsResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveWorkflowData, SaveWorkflowErrors, SaveWorkflowResponses, ScanWorkflowData, ScanWorkflowResponses, ScheduleWorkflowData, ScheduleWorkflowErrors, ScheduleWorkflowResponses, SessionChangesData, SessionChangesErrors, SessionChangesResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetActiveData, SetActiveErrors, SetActiveResponses, SetConfigProviderData, SetConfigProviderErrors, SetConfigProviderResponses, SetDefaultModelData, SetDefaultModelErrors, SetDefaultModelResponses, SetKbTierData, SetKbTierErrors, SetKbTierResponses, SetSessionSkillsData, SetSessionSkillsErrors, SetSessionSkillsResponses, SetWorkflowSlashCommandData, SetWorkflowSlashCommandErrors, SetWorkflowSlashCommandResponses, SkillCatalogHandlerData, SkillCatalogHandlerErrors, SkillCatalogHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SubmitSecretsData, SubmitSecretsErrors, SubmitSecretsResponses, SystemInfoData, SystemInfoResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserWorkflowValuesData, UpdateSessionUserWorkflowValuesErrors, UpdateSessionUserWorkflowValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses, WorkflowToYamlData, WorkflowToYamlErrors, WorkflowToYamlResponses, WritePageData, WritePageErrors, WritePageResponses } from './types.gen'; +import type { AbandonContinuationLeaseData, AbandonContinuationLeaseErrors, AbandonContinuationLeaseResponses, AckPrivacyDisclosureData, AckPrivacyDisclosureErrors, AckPrivacyDisclosureResponses, AddExtensionData, AddExtensionErrors, AddExtensionResponses, AddRawSourceData, AddRawSourceErrors, AddRawSourceResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentCrossAffiliationGrantData, AgentCrossAffiliationGrantErrors, AgentCrossAffiliationGrantResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CancelActiveWorkData, CancelActiveWorkErrors, CancelActiveWorkResponses, CancelTurnData, CancelTurnErrors, CancelTurnResponses, CatalogChangesData, CatalogChangesErrors, CatalogChangesResponses, CatalogRevisionData, CatalogRevisionErrors, CatalogRevisionResponses, CheckModelData, CheckModelErrors, CheckModelResponses, CheckProviderData, CodingAgentsStatusData, CodingAgentsStatusResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateBaseData, CreateBaseErrors, CreateBaseResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, CreateWorkflowData, CreateWorkflowErrors, CreateWorkflowResponses, DeclassifySessionData, DeclassifySessionErrors, DeclassifySessionResponses, DecodeWorkflowData, DecodeWorkflowErrors, DecodeWorkflowResponses, DeleteBaseData, DeleteBaseErrors, DeleteBaseResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DeleteWorkflowData, DeleteWorkflowErrors, DeleteWorkflowResponses, DetectProviderData, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DivergeSessionData, DivergeSessionErrors, DivergeSessionResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeWorkflowData, EncodeWorkflowErrors, EncodeWorkflowResponses, ExportBrkbData, ExportBrkbErrors, ExportBrkbResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetActiveData, GetActiveErrors, GetActiveResponses, GetBaseData, GetBaseErrors, GetBaseResponses, GetCallableToolCountData, GetCallableToolCountErrors, GetCallableToolCountResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDetectableProvidersData, GetDetectableProvidersResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetGraphData, GetGraphErrors, GetGraphResponses, GetKbTierData, GetKbTierErrors, GetKbTierResponses, GetLocationData, GetLocationErrors, GetLocationResponses, GetPageBodyData, GetPageBodyErrors, GetPageBodyResponses, GetPricingData, GetPricingResponses, GetPrivacyDisclosureData, GetPrivacyDisclosureResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionActivityData, GetSessionActivityErrors, GetSessionActivityResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSessionUsageData, GetSessionUsageErrors, GetSessionUsageResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, GetUsageReportData, GetUsageReportErrors, GetUsageReportResponses, GetUsageSummaryData, GetUsageSummaryErrors, GetUsageSummaryResponses, ImportBrkbData, ImportBrkbErrors, ImportBrkbResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, IngestConversationData, IngestConversationErrors, IngestConversationResponses, IngestData, IngestErrors, IngestResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, InstallSkillPackageData, InstallSkillPackageErrors, InstallSkillPackageResponses, InterruptData, InterruptErrors, InterruptResponses, KillRunningJobData, KillRunningJobErrors, KillRunningJobResponses, LintData, LintErrors, LintResponses, ListActiveWorkData, ListActiveWorkResponses, ListBasesData, ListBasesResponses, ListHistoryData, ListHistoryResponses, ListPagesData, ListPagesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ListSidebarSessionsData, ListSidebarSessionsErrors, ListSidebarSessionsResponses, ListWorkflowsData, ListWorkflowsErrors, ListWorkflowsResponses, LlamacppDeleteData, LlamacppDeleteErrors, LlamacppDeleteResponses, LlamacppEnsureData, LlamacppEnsureErrors, LlamacppEnsureResponses, LlamacppStatusData, LlamacppStatusResponses, LlamacppStopData, LlamacppStopResponses, LlamacppWarmupData, LlamacppWarmupErrors, LlamacppWarmupResponses, MemoryDeleteCategoryData, MemoryDeleteCategoryErrors, MemoryDeleteCategoryResponses, MemoryDeleteEntryData, MemoryDeleteEntryErrors, MemoryDeleteEntryResponses, MemoryInventoryData, MemoryInventoryErrors, MemoryInventoryResponses, MergeBasesData, MergeBasesErrors, MergeBasesResponses, ObserveSessionEventsData, ObserveSessionEventsErrors, ObserveSessionEventsResponses, OverrideCredibilityData, OverrideCredibilityErrors, OverrideCredibilityResponses, ParseWorkflowData, ParseWorkflowErrors, ParseWorkflowResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, PreviewResetData, PreviewResetErrors, PreviewResetResponses, PreviewSkillPackageData, PreviewSkillPackageErrors, PreviewSkillPackageResponses, PreviewStateData, PreviewStateErrors, PreviewStateResponses, ProvidersData, ProvidersResponses, QueryKbData, QueryKbErrors, QueryKbResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadPageData, ReadPageErrors, ReadPageResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, ReclassifyData, ReclassifyErrors, ReclassifyResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RecoverContinuationData, RecoverContinuationErrors, RecoverContinuationResponses, RefreshSkillCatalogData, RefreshSkillCatalogErrors, RefreshSkillCatalogResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, RemoveSkillPackageData, RemoveSkillPackageErrors, RemoveSkillPackageResponses, ReplyData, ReplyErrors, ReplyResponses, ResetAppDataData, ResetAppDataErrors, ResetAppDataResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, RestoreStateData, RestoreStateErrors, RestoreStateResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunningSessionsData, RunningSessionsErrors, RunningSessionsResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveWorkflowData, SaveWorkflowErrors, SaveWorkflowResponses, ScanWorkflowData, ScanWorkflowResponses, ScheduleWorkflowData, ScheduleWorkflowErrors, ScheduleWorkflowResponses, SessionChangesData, SessionChangesErrors, SessionChangesResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetActiveData, SetActiveErrors, SetActiveResponses, SetConfigProviderData, SetConfigProviderErrors, SetConfigProviderResponses, SetDefaultModelData, SetDefaultModelErrors, SetDefaultModelResponses, SetKbTierData, SetKbTierErrors, SetKbTierResponses, SetSessionSkillsData, SetSessionSkillsErrors, SetSessionSkillsResponses, SetWorkflowSlashCommandData, SetWorkflowSlashCommandErrors, SetWorkflowSlashCommandResponses, SkillCatalogHandlerData, SkillCatalogHandlerErrors, SkillCatalogHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SubmitSecretsData, SubmitSecretsErrors, SubmitSecretsResponses, SystemInfoData, SystemInfoResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserWorkflowValuesData, UpdateSessionUserWorkflowValuesErrors, UpdateSessionUserWorkflowValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses, WorkflowToYamlData, WorkflowToYamlErrors, WorkflowToYamlResponses, WritePageData, WritePageErrors, WritePageResponses } from './types.gen'; export type Options = Options2 & { /** @@ -781,7 +781,7 @@ export const updateSchedule = (options: Op export const inspectRunningJob = (options: Options) => (options.client ?? client).get({ url: '/schedule/{id}/inspect', ...options }); -export const killRunningJob = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/kill', ...options }); +export const killRunningJob = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/kill', ...options }); export const pauseSchedule = (options: Options) => (options.client ?? client).post({ url: '/schedule/{id}/pause', ...options }); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 7b7cfe468..32a66a2a9 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -6960,7 +6960,7 @@ export type ListSchedulesErrors = { export type ListSchedulesResponses = { /** - * A list of scheduled jobs + * 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 */ 200: ListSchedulesResponse; }; @@ -7016,6 +7016,10 @@ export type InspectRunningJobData = { }; export type InspectRunningJobErrors = { + /** + * 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 + */ + 403: unknown; /** * Scheduled job not found */ @@ -7038,12 +7042,30 @@ export type InspectRunningJobResponse = InspectRunningJobResponses[keyof Inspect export type KillRunningJobData = { body?: never; path: { + /** + * ID of the schedule whose run should be stopped + */ id: string; }; query?: never; url: '/schedule/{id}/kill'; }; +export type KillRunningJobErrors = { + /** + * 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 + */ + 400: unknown; + /** + * 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 + */ + 403: unknown; + /** + * No such schedule + */ + 404: unknown; +}; + export type KillRunningJobResponses = { /** * Running job killed successfully