diff --git a/CLAUDE.md b/CLAUDE.md index d00981305..2bad215d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1187,7 +1187,7 @@ Test the gate where it is: the unit tests in `agents/agent.rs` prints a URL. The daemon serves the SPA **on its own origin**, so nothing is proxied. This replaced a standalone `biorouter-headless` binary and its Linux tarball, both deleted 2026-08-23; release assets went 11 → 10. Design and reasoning: -[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (SD-1..SD-9), +[`docs/deployment/serve-decisions.md`](docs/deployment/serve-decisions.md) (the `SD-n` records), [`serve-architecture.md`](docs/deployment/serve-architecture.md), [`browser-access.md`](docs/deployment/browser-access.md). @@ -1200,6 +1200,22 @@ replaced a standalone `biorouter-headless` binary and its Linux tarball, both de **agent**, so every surface that writes a capability key asks `isBrowserSurface()` (`ui/desktop/src/utils/surface.ts`) and explains *before* the user can reach the 409. Do not "fix" browser mode by weakening the refusal. +- **A new chat starts on the configured model without a proof, and nothing else does** (SD-12). + Until it, a `serve` daemon with a private provider configured refused EVERY `/agent/start` + (the 2026-09-10 QA's F1): the new-chat bind asked for a proof a keyless daemon cannot check. + Three pieces, each load-bearing — measured by removing it: on a keyless daemon + `new_chat_bind_needs_user` (`routes/agent.rs`) lets the configured default bind, while a keyed + daemon still refuses a proof-less private first bind; `raise_baseline` makes a keyless + daemon's `/agent/update_provider` measure every move onto a private model from Public, or the + exemption would carry sideways to a private model nobody configured; and the browser states the + host's model as `X-Caller-Provider` (`userActionHeaders()` on `isBrowserSurface()`), without + which a chat's first reply ratcheted it private and its next request 403'd. Tests: + `cargo test -p biorouter-server --test new_chat_no_user_key` (its own binary: the digest is a + process-global `OnceLock`). ⚠ **Still unreachable in a browser, and out of SD-12's scope:** + `/agent/cancel` and `/interrupt` require the proof unconditionally, so Stop and mid-turn + steering cannot work on a keyless daemon. ⚠ `privacy_ar15_is_retired.rs`'s closure scan took + the FIRST `TierRaiseNeedsUser` in `routes/agent.rs`, which from `eb594ded` was the new-chat gate + and not AR-15's — it now starts at `update_agent_provider`. - **A control that can never work here says so, before it is touched** (SD-8). The same `Stdio::null()` that closes SD-1 means NO approval carrying `requires_user_proof` can ever be granted on a `serve` daemon — for anyone, always. So `confirm_tool_action` answers a diff --git a/crates/biorouter-server/src/commands/agent.rs b/crates/biorouter-server/src/commands/agent.rs index c2fa7dfbe..69f57565d 100644 --- a/crates/biorouter-server/src/commands/agent.rs +++ b/crates/biorouter-server/src/commands/agent.rs @@ -212,8 +212,9 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { let user_action_digest = read_user_action_digest().await; if user_action_digest.is_none() { tracing::warn!( - "no user-action key on stdin: this daemon will refuse every request that raises a \ - session's privacy capability, including one made by the person at the keyboard" + "no user-action key on stdin: this daemon will refuse every request that raises an \ + existing chat's privacy capability, including one made by the person at the \ + keyboard; a new chat still starts on the configured provider (SD-12)" ); } // A tool whose approval can never be granted must not be offered. `serve` diff --git a/crates/biorouter-server/src/routes/agent.rs b/crates/biorouter-server/src/routes/agent.rs index e9f761dc9..98316e018 100644 --- a/crates/biorouter-server/src/routes/agent.rs +++ b/crates/biorouter-server/src/routes/agent.rs @@ -341,6 +341,59 @@ fn configured_new_session_provider() -> Result, Er } } +/// SD-12 (`docs/deployment/serve-decisions.md`): does binding the operator's +/// configured default provider to a brand-new chat need a person's proof? +/// +/// A new chat has no capability of its own yet, so a private default reads as a +/// raise from Public — the reading `update_agent_provider` gives any first bind. +/// What differs is who chose the model. `/agent/start` names no provider; it +/// binds `BIOROUTER_PROVIDER`, a key only a proven person may write over HTTP +/// (DR-16, open question 24) or the operator wrote out of band with `biorouter +/// configure`. The proof is therefore asked for only where it can be given: +/// +/// * `Proven` — the desktop renderer, which sends `X-User-Action` on every start +/// (`ui/desktop/src/sessions.ts`). Binds. +/// * `Unproven` — a daemon that holds a key, and a caller that did not present +/// it: a script, or the model holding the daemon secret AR-11 found +/// recoverable. Refused, as before. The person proves themselves here at no +/// cost, and without the refusal a model could mint a private-capability chat +/// with an extension set of its own choosing. +/// * `NoKeyInstalled` — `biorouter serve` (SD-7) or a hand-run `biorouterd`. +/// Nobody on this daemon can prove anything, so a refusal here refuses the +/// person too, on every new chat, always — the 2026-09-10 QA's F1. Binds. +/// +/// Only the configured default is exempt, and only at creation: `/agent/start` +/// cannot name any other provider, and on a keyless daemon +/// `update_agent_provider` refuses every private bind ([`raise_baseline`]), so a +/// new chat there never reaches a private model the operator did not choose. +fn new_chat_bind_needs_user(enforced: bool, tier: ProviderTier, proof: UserActionProof) -> bool { + enforced + && raise_needs_user_action(ProviderTier::Public, tier) + && match proof { + UserActionProof::Proven | UserActionProof::NoKeyInstalled => false, + UserActionProof::Unproven => true, + } +} + +/// The capability `update_agent_provider` measures a raise from — SD-12's other +/// half. +/// +/// On a daemon that holds a user-action key it is the chat's live capability, +/// as it always was. On one that holds none, no chat's private capability came +/// from anything a person proved over HTTP: the only private binding such a +/// daemon hands out through its routes is [`new_chat_bind_needs_user`]'s +/// creation-time bind to the configured default. There is no private floor for +/// a request to build on, so a bind to ANY private provider is measured from +/// Public, and refused, since no proof can arrive. Without this, SD-12 would let +/// a new chat on a private default be moved sideways, `Private -> Private`, to a +/// private model nobody configured. +fn raise_baseline(current: ProviderTier, proof: UserActionProof) -> ProviderTier { + match proof { + UserActionProof::NoKeyInstalled => ProviderTier::Public, + UserActionProof::Proven | UserActionProof::Unproven => current, + } +} + async fn bind_new_session_provider( state: &AppState, session: &Session, @@ -355,10 +408,12 @@ async fn bind_new_session_provider( message: format!("Failed to configure the selected provider for the new chat: {error}"), status: StatusCode::BAD_REQUEST, })?; - if biorouter::privacy::privacy_tiers_enabled() - && raise_needs_user_action(ProviderTier::Public, provider.tier()) - && !is_user_action(headers) - { + // DR-15's master opt-out, read inside the gate as every #56 surface does. + if new_chat_bind_needs_user( + biorouter::privacy::privacy_tiers_enabled(), + provider.tier(), + user_action_proof(headers), + ) { return Err(ErrorResponse { message: PrivacyRefusal::TierRaiseNeedsUser { requested: provider_name, @@ -619,7 +674,7 @@ pub struct RestartAgentResponse { (status = 200, description = "Agent started successfully", body = Session), (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized - invalid secret key"), - (status = 409, description = "The selected private provider requires user-action proof", body = ErrorResponse), + (status = 409, description = "The configured provider is private and this daemon holds a user-action key, but the request carried no proof it came from the user (SD-12). A daemon with no user-action key binds its configured provider without one.", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ) )] @@ -1297,7 +1352,9 @@ async fn get_callable_tool_count( a public model cannot be bound to a private chat \ (body = PrivacyBarrierBody). DR-16: the bind raises this \ chat's capability to Private and the request carried no \ - proof it came from the user (body = plain text)", + proof it came from the user; on a daemon with no \ + user-action key, any bind to a private model (SD-12) \ + (body = plain text)", body = PrivacyBarrierBody), (status = 424, description = "Agent not initialized"), (status = 500, description = "Internal server error") @@ -1370,6 +1427,10 @@ async fn update_agent_provider( // DR-16 rejected. Sideways and downward binds are untouched for every // caller, which is what keeps Gate A's path, the CLI, // `restore_provider_from_session` and every apps-runtime bind working. + // The one exception is this route on a daemon with no user-action key, + // where a move onto a private model is measured from Public however the + // chat is bound today — `raise_baseline`, SD-12. The predicate itself is + // unchanged, and none of the in-process binds above passes through here. // // An unbound session reads as Public — `Agent::provider` errors when nothing // is bound (and when Gate B' refuses what is), and the conservative reading @@ -1380,11 +1441,13 @@ async fn update_agent_provider( .await .map(|p| p.tier()) .unwrap_or(ProviderTier::Public); + // SD-12's other half — see `raise_baseline`. + let baseline = raise_baseline(current, user_action_proof(&headers)); // DR-15's master opt-out, read INSIDE the gate. A direct read, not a // `CallCapability`: a provider raise over HTTP is not a tool call and has no // admitted capability to inherit. if biorouter::privacy::privacy_tiers_enabled() - && raise_needs_user_action(current, new_provider.tier()) + && raise_needs_user_action(baseline, new_provider.tier()) && !is_user_action(&headers) { return Err(( @@ -3121,6 +3184,67 @@ mod new_session_provider_binding_tests { .await .unwrap(); } + + /// SD-12, every proof verdict against both tiers. The keyless arm cannot be + /// reached through a route in this binary — the installed digest is a + /// process-global `OnceLock` and the test above installs one — so the route + /// half lives in `tests/new_chat_no_user_key.rs`, a binary that never does. + #[test] + fn only_a_daemon_that_can_check_a_proof_asks_a_new_chat_for_one() { + use UserActionProof::{NoKeyInstalled, Proven, Unproven}; + assert!(!new_chat_bind_needs_user( + true, + ProviderTier::Private, + Proven + )); + assert!(new_chat_bind_needs_user( + true, + ProviderTier::Private, + Unproven + )); + assert!( + !new_chat_bind_needs_user(true, ProviderTier::Private, NoKeyInstalled), + "a keyless daemon refusing its own configured default refuses every person, always" + ); + for proof in [Proven, Unproven, NoKeyInstalled] { + // A public default raises nothing, for anyone. + assert!(!new_chat_bind_needs_user(true, ProviderTier::Public, proof)); + // DR-15's master opt-out turns the gate off, not the question. + assert!(!new_chat_bind_needs_user( + false, + ProviderTier::Private, + proof + )); + } + } + + /// SD-12's other half: a keyless daemon measures every move onto a private + /// model from Public, so its exemption for the configured default cannot be + /// carried sideways to a private model nobody configured. + #[test] + fn a_keyless_daemon_has_no_private_floor_for_a_switch_to_build_on() { + use UserActionProof::{NoKeyInstalled, Proven, Unproven}; + for current in [ProviderTier::Private, ProviderTier::Public] { + assert_eq!( + raise_baseline(current, NoKeyInstalled), + ProviderTier::Public + ); + // A daemon that can check a proof keeps measuring from the live binding. + assert_eq!(raise_baseline(current, Proven), current); + assert_eq!(raise_baseline(current, Unproven), current); + } + // The composition `update_agent_provider` asks. Sideways onto a private + // model is a raise only where no proof can be checked. + let sideways = |proof| { + raise_needs_user_action( + raise_baseline(ProviderTier::Private, proof), + ProviderTier::Private, + ) + }; + assert!(sideways(NoKeyInstalled)); + assert!(!sideways(Unproven)); + assert!(!sideways(Proven)); + } } #[cfg(test)] diff --git a/crates/biorouter-server/tests/new_chat_no_user_key.rs b/crates/biorouter-server/tests/new_chat_no_user_key.rs new file mode 100644 index 000000000..7e995f819 --- /dev/null +++ b/crates/biorouter-server/tests/new_chat_no_user_key.rs @@ -0,0 +1,380 @@ +//! SD-12: on a daemon that holds no proof-of-user key — `biorouter serve` (SD-7) +//! or a hand-run `biorouterd` — a new chat starts on the operator's configured +//! provider, private or not, and nothing on the HTTP surface can move it onto a +//! private model the operator did not configure. +//! +//! The 2026-09-10 QA run (finding F1) measured the first half failing: with +//! `versa_azure` configured, every `POST /agent/start` on a `serve` daemon was +//! refused 409, because the new-chat gate asked for a proof this daemon can +//! never check. The browser showed nothing at all. +//! +//! ⚠ **Its own test binary on purpose**, for the reason `approval_no_user_key.rs` +//! gives: the installed digest is a process-global `OnceLock`, the lib's tests +//! install one, and inside that binary the keyless state is unreachable once the +//! first of them wins. Nothing here installs a digest — which is exactly how +//! `biorouter serve` starts its daemon. + +// Redirects this binary's Biorouter data/config/state dirs at a throwaway root +// before `main`, so nothing here can open the developer's real `sessions.db` +// or write the developer's real `config.yaml`. +#[path = "../src/test_sandbox.rs"] +mod test_sandbox; + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{HeaderMap, Request, StatusCode}; +use axum::Router; +use biorouter::config::{with_config_overrides, Config}; +use biorouter::conversation::message::Message; +use biorouter::privacy::refusal::USER_ACTION_REFUSAL_MARKER; +use biorouter::privacy::SessionClassification; +use biorouter::providers::versa_azure::VERSA_AZURE_DEPLOYMENT; +use biorouter_server::auth::{user_action_proof, UserActionProof}; +use biorouter_server::state::AppState; +use serde_json::{json, Value}; +use serial_test::serial; +use tower::ServiceExt; +use wiremock::matchers::{body_string_contains, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// The posture the QA run used: `biorouter configure` chose Versa. The key is a +/// placeholder — these tests construct the provider and never send it a request. +fn versa_is_the_configured_default() -> HashMap { + HashMap::from([ + ("BIOROUTER_PROVIDER".to_string(), "versa_azure".to_string()), + ( + "BIOROUTER_MODEL".to_string(), + VERSA_AZURE_DEPLOYMENT.to_string(), + ), + ( + "VERSA_AZURE_API_KEY".to_string(), + "placeholder-never-sent".to_string(), + ), + ]) +} + +/// Every test here stands on this: the daemon under test holds no key. +fn assert_the_daemon_is_keyless() { + assert_eq!( + user_action_proof(&HeaderMap::new()), + UserActionProof::NoKeyInstalled, + "something in this binary installed a user-action digest, so these tests would be \ + measuring a desktop daemon rather than a `biorouter serve` one" + ); + assert!( + biorouter::privacy::privacy_tiers_enabled(), + "privacy tiers are off, so no gate below would fire either way" + ); +} + +async fn post_json(app: Router, uri: &str, body: Value) -> (StatusCode, String) { + let response = app + .oneshot( + Request::builder() + .uri(uri) + .method("POST") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = tokio::time::timeout( + Duration::from_secs(120), + axum::body::to_bytes(response.into_body(), usize::MAX), + ) + .await + .expect("the response body did not finish within two minutes") + .unwrap(); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +fn start_request(working_dir: &std::path::Path) -> Value { + // No extensions: the chat under test needs a model and nothing else, and + // the machine default set is not this test's subject. + json!({ "working_dir": working_dir, "extension_overrides": [] }) +} + +async fn discard(state: &Arc, session_id: &str) { + // The tests run serially, so every cached agent is this test's. + state.clear_cached_agents().await; + let _ = state.session_manager().delete_session(session_id).await; +} + +/// F1, the half the QA run measured: the configured private provider is the +/// person's choice, made at the terminal, so binding it to a brand-new chat is +/// not a switch and needs no proof — on the one kind of daemon where no proof +/// can exist. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_starts_a_new_chat_on_its_configured_private_model() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + + let (status, body) = with_config_overrides( + versa_is_the_configured_default(), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "a keyless daemon refused to start a chat on its own configured model: {body}" + ); + let id = serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .expect("the started session carries an id") + .to_string(); + + let row = state + .session_manager() + .get_session(&id, false) + .await + .unwrap(); + assert_eq!(row.provider_name.as_deref(), Some("versa_azure")); + assert_eq!( + row.model_config.map(|config| config.model_name).as_deref(), + Some(VERSA_AZURE_DEPLOYMENT) + ); + // O5: the ratchet fires on the first turn, never on the bind. A chat that + // has touched nothing is not yet private — it is private-CAPABLE. + assert_eq!(row.privacy_tier, SessionClassification::Public); + + let agent = state.get_agent_for_route(id.clone()).await.unwrap(); + assert_eq!( + agent + .provider() + .await + .expect("the first turn must not fail with `Provider not set`") + .get_name(), + "versa_azure" + ); + + discard(&state, &id).await; +} + +/// The complement the exemption must not leak into: a request that asks a new +/// chat for a DIFFERENT private model than the one the operator configured is +/// still refused. `/agent/start` cannot name one, so the request that can is +/// `/agent/update_provider` on the chat it just made — `Private -> Private`, +/// which the raise predicate alone would call sideways and wave through. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_will_not_move_a_new_chat_to_a_private_model_nobody_configured() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + + let (status, body) = with_config_overrides( + versa_is_the_configured_default(), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let id = serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // A loopback Ollama is Private (`self_hosted_tier`), and constructing one + // opens no connection, so port 1 is never dialled. + let (status, body) = with_config_overrides( + HashMap::from([("OLLAMA_HOST".to_string(), "http://127.0.0.1:1".to_string())]), + post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/update_provider", + json!({ "session_id": id, "provider": "ollama", "model": "stub-model" }), + ), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "a keyless daemon moved a new chat onto a private model the operator never configured: \ + {body}" + ); + assert!( + body.contains(USER_ACTION_REFUSAL_MARKER), + "refused, but not by the tier gate: {body}" + ); + + let row = state + .session_manager() + .get_session(&id, false) + .await + .unwrap(); + assert_eq!( + row.provider_name.as_deref(), + Some("versa_azure"), + "the refused switch rewrote the row anyway" + ); + let agent = state.get_agent_for_route(id.clone()).await.unwrap(); + assert_eq!(agent.provider().await.unwrap().get_name(), "versa_azure"); + + discard(&state, &id).await; +} + +/// SD-1 is untouched by SD-12: the configured default itself still cannot be +/// changed from a keyless daemon, which is what makes it the operator's choice. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn a_keyless_daemon_still_refuses_to_change_the_configured_default() { + assert_the_daemon_is_keyless(); + let state = AppState::new().await.unwrap(); + let (status, body) = post_json( + biorouter_server::routes::config_management::routes(state), + "/config/set_provider", + json!({ "provider": "versa_azure", "model": VERSA_AZURE_DEPLOYMENT }), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); +} + +/// "privacy_tier ratchets on the first turn as usual": the exemption changes who +/// may bind the configured model, and nothing about what a turn on it does. +/// +/// The configured default here is an Ollama endpoint on loopback, because a +/// Versa module re-pointed at a stub server is no longer Private +/// (`ucsf_gateway_tier` reads the resolved host) and a test must not send +/// traffic to the real gateway. ⚠ No model runs: the endpoint is a `wiremock` +/// stub that returns one canned completion. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn the_first_turn_on_a_keyless_default_chat_ratchets_it_as_usual() { + assert_the_daemon_is_keyless(); + + let stub = MockServer::start().await; + let chunk = |delta: Value, finish: Value| { + json!({ + "id": "stub", "object": "chat.completion.chunk", "model": "stub-model", + "choices": [{ "index": 0, "delta": delta, "finish_reason": finish }] + }) + }; + let sse = format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + chunk( + json!({ "role": "assistant", "content": "ready" }), + Value::Null + ), + chunk(json!({ "content": "" }), json!("stop")), + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("\"stream\":true")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse), + ) + .with_priority(1) + .mount(&stub) + .await; + // Anything that asks without streaming — the chat's auto-title — gets a + // plain completion rather than an event stream it cannot parse. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "stub", "object": "chat.completion", "model": "stub-model", + "choices": [{ "index": 0, "finish_reason": "stop", + "message": { "role": "assistant", "content": "Stub title" } }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + }))) + .mount(&stub) + .await; + + // Written to this binary's sandboxed config.yaml rather than scoped to a + // task: the turn runs on a spawned task, which a task-local override would + // not reach. + let config = Config::global(); + config.set_param("BIOROUTER_PROVIDER", "ollama").unwrap(); + config.set_param("BIOROUTER_MODEL", "stub-model").unwrap(); + config.set_param("OLLAMA_HOST", stub.uri()).unwrap(); + + let state = AppState::new().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + let (status, body) = post_json( + biorouter_server::routes::agent::routes(Arc::clone(&state)), + "/agent/start", + start_request(dir.path()), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let id = serde_json::from_str::(&body).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let before = state + .session_manager() + .get_session(&id, false) + .await + .unwrap(); + assert_eq!(before.provider_name.as_deref(), Some("ollama")); + assert_eq!(before.privacy_tier, SessionClassification::Public); + + let message = Message::user().with_text("Reply with the single word ready."); + let (status, stream) = post_json( + biorouter_server::routes::reply::routes(Arc::clone(&state)), + "/reply", + json!({ "user_message": message, "session_id": id }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{stream}"); + assert!( + stream.contains("\"Finish\""), + "the turn did not finish: {stream}" + ); + assert!( + stream.contains("ready"), + "the turn did not run on the stub: {stream}" + ); + + let after = state + .session_manager() + .get_session(&id, false) + .await + .unwrap(); + assert_eq!( + after.privacy_tier, + SessionClassification::Private, + "a turn on the configured private model did not ratchet the chat" + ); + assert_eq!(after.privacy_reason.as_deref(), Some("turn:ollama")); + + // The chat's NEXT request, now that it is private. A keyless daemon reaches a + // private chat only for a caller whose stated capability covers it, so a + // browser tab that states nothing loses the chat it just started; the host's + // provider, which is what a tab on this host states (SD-12), keeps it. + let reach = |caller: Option<&str>| { + let mut request = Request::builder().uri(format!("/sessions/{id}")); + if let Some(provider) = caller { + request = request.header("X-Caller-Provider", provider); + } + let app = biorouter_server::routes::session::routes(Arc::clone(&state)); + async move { + app.oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + .status() + } + }; + assert_eq!(reach(None).await, StatusCode::FORBIDDEN); + assert_eq!(reach(Some("ollama")).await, StatusCode::OK); + + discard(&state, &id).await; + for key in ["BIOROUTER_PROVIDER", "BIOROUTER_MODEL", "OLLAMA_HOST"] { + let _ = config.delete(key); + } +} diff --git a/crates/biorouter-server/tests/privacy_ar15_is_retired.rs b/crates/biorouter-server/tests/privacy_ar15_is_retired.rs index 6862181b4..076db76d9 100644 --- a/crates/biorouter-server/tests/privacy_ar15_is_retired.rs +++ b/crates/biorouter-server/tests/privacy_ar15_is_retired.rs @@ -345,9 +345,24 @@ fn the_documented_closure_is_the_one_the_code_performs() { // The docs now assert a specific gate. If the gate goes, the docs are // wrong in the *dangerous* direction — claiming a hole is closed when it is // open — and no other test in this tree ties the two together. - let refusal = AGENT_ROUTE - .find("PrivacyRefusal::TierRaiseNeedsUser") - .expect("routes/agent.rs no longer refuses an unproven tier raise at all"); + // + // ⚠ The scan starts at `update_agent_provider`, the handler AR-15 is about. + // It used to take the FIRST refusal in the file, which stopped being this + // handler's when `eb594ded` put the new-chat gate above it: from then on the + // scan read that gate, and deleting the proof from this one left it green. + // The new-chat gate is SD-12's, a different rule with its own tests in + // `routes/agent.rs`. + let handler = AGENT_ROUTE + .find("async fn update_agent_provider") + .expect("update_agent_provider moved; AR-15's gate is the one inside it"); + let body = cut_from(AGENT_ROUTE, handler); + // Bounded by the next route, so a refusal that left this handler cannot be + // found in the one after it. + let body = cut_to(body, body.find("\n#[utoipa::path(").unwrap_or(body.len())); + let refusal = handler + + body + .find("PrivacyRefusal::TierRaiseNeedsUser") + .expect("update_agent_provider no longer refuses an unproven tier raise at all"); let guard = cut_to(AGENT_ROUTE, refusal) .rfind(" if ") .expect("the tier-raise refusal is not inside an `if`"); diff --git a/docs/deployment/browser-access.md b/docs/deployment/browser-access.md index 44526d471..7a6f5f89c 100644 --- a/docs/deployment/browser-access.md +++ b/docs/deployment/browser-access.md @@ -22,7 +22,9 @@ first and then [Headless Linux deployment](headless-linux.md). ## Quickstart Choose the provider and model **before** you start serving — a browser session cannot change them -(see [The model is fixed before you start](#the-model-is-fixed-before-you-start)): +(see [The model is fixed before you start](#the-model-is-fixed-before-you-start)). Either kind +works: a commercial model, or a private one your institution hosts or that runs on the machine, +which is the choice to make for patient data. ```bash biorouter configure @@ -193,6 +195,21 @@ provider is chosen once, at the terminal, and the tier that choice implies holds session in that daemon. A run started against an institutional model is private for its whole life; one started against a commercial model is public for its whole life. Neither can drift. +What that means for a chat you start in the browser: + +- **It starts on the configured model**, private or not, with nothing asked of you — choosing it + at the terminal was the decision. +- **A private model makes it private with its first reply.** The tab you are in keeps working with + it: the browser tells the daemon which model it runs under, and a chat is open to anything + running under a model at least as private as the chat. +- **Nothing in the browser can move it onto a different private model.** The configured one is the + only private model a chat started here ever reaches. +- **A private chat started in the desktop application opens here only when the host's model is + private too.** On a host configured with a commercial model it stays out of reach, with a card + saying so; open it in the desktop application instead. + +The reasoning is recorded as [decision SD-12](serve-decisions.md#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does). + **The fix is to choose the provider before you start serving:** ```bash @@ -212,7 +229,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, with two differences that follow from the fixed model: a new chat starts on the host's configured model, and a private chat opens only on a host whose configured model is private. See [The model is fixed before you start](#the-model-is-fixed-before-you-start). | | 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. | @@ -241,6 +258,13 @@ you ran, and finds it either next to that CLI or next to the application the CLI recorded). If the application has moved or been reinstalled since, run `biorouter setup-path` again from inside it — `\resources\bin\biorouter.exe setup-path`. +**A message does not start a chat.** The composer keeps what you typed, and a notice in the corner +says why. The commonest cause is on the host rather than in the browser — for example *Failed to +configure the selected provider for the new chat: Configuration value not found: +OPENAI_API_KEY* means the model chosen with `biorouter configure` has no credential on the serving +machine. Fix it there, restart `serve`, and open the new address it prints. **Copy error** on the +notice copies the daemon's own words, for a bug report. + **The tab says the link needs its access token.** The `?t=` part was dropped — from a copy-paste, a chat client shortening the link, or a bookmark saved after the redirect. Use the full address as printed. If the launch has since restarted, the token has changed; read the new one from the diff --git a/docs/deployment/programmatic-session-access.md b/docs/deployment/programmatic-session-access.md index 7d976e883..8f1bcaf6c 100644 --- a/docs/deployment/programmatic-session-access.md +++ b/docs/deployment/programmatic-session-access.md @@ -53,7 +53,10 @@ case with its own logic — it is that rule, stated on a request. The header is read by [`crates/biorouter-server/src/routes/session_reach.rs`](../../crates/biorouter-server/src/routes/session_reach.rs); `biorouter session watch`, `send` and `attach` already send it, which is why those commands reach a -private chat from a terminal that can never prove a human is present. +private chat from a terminal that can never prove a human is present. So does the browser interface +`biorouter serve` serves, naming the model the host was configured with +([SD-12](serve-decisions.md#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does)): +a browser, like a terminal, can never carry the proof, and runs the model its host chose. ## What the header is *not* @@ -67,9 +70,13 @@ which got the answer backwards in both directions: a terminal running an institu refused, while the desktop app was admitted for the same chat while running a public one. **It is not a way to raise or lower a tier.** Reaching a chat and *reclassifying* one are separate -decisions. Raising a session's classification, declassifying it, and binding a private model all -still require proof that the person at the keyboard acted (`X-User-Action`), and no header changes -that. A capability is a fact about a model; neither of those is a decision a model may make. +decisions. Raising a session's classification, declassifying it, and binding a private model to a +chat all still require proof that the person at the keyboard acted (`X-User-Action`), and no header +changes that. A capability is a fact about a model; neither of those is a decision a model may make. +The one bind that needs no proof is not a header's doing either: on a daemon with no user-action +key, a **new** chat starts on the model the operator configured, private or not, because choosing +it with `biorouter configure` was the decision +([SD-12](serve-decisions.md#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does)). **It is not a per-request opt-out.** There is no header that turns the gate off. The only machine-wide switch is the privacy master switch, which lives in its own record beside diff --git a/docs/deployment/serve-decisions.md b/docs/deployment/serve-decisions.md index 24b74e3a5..bfb4aee4a 100644 --- a/docs/deployment/serve-decisions.md +++ b/docs/deployment/serve-decisions.md @@ -1,10 +1,10 @@ # Decisions behind `biorouter serve` > **What this is.** The decision records governing browser-served Biorouter — why the daemon -> serves the interface itself, why a browser session cannot change its model, why the -> standalone `biorouter-headless` binary was retired, and how long the launch token stays good -> for. Each record states the ruling, the alternatives it displaced, and the consequence a -> future change would have to accept. +> serves the interface itself, why a browser session cannot change its model yet starts every +> chat on the one the operator chose, why the standalone `biorouter-headless` binary was +> retired, and how long the launch token stays good for. Each record states the ruling, the +> alternatives it displaced, and the consequence a future change would have to accept. > **Status:** Current. > **Audience:** developers working on the daemon, the CLI, or release packaging; agents making > changes anywhere near the serving path. @@ -18,7 +18,8 @@ This page records the decisions that replaced that arrangement. They were taken several of them only make sense as a set: the reason a browser session cannot switch models (SD-1) is also the reason it needs no proof-of-user mechanism, which is the reason the daemon can be spawned with a closed stdin (SD-7) — and the reason every control that needs that proof -must say so before the user reaches for it (SD-8). Read [the architecture](serve-architecture.md) +must say so before the user reaches for it (SD-8), and the reason the one model the operator +chose must not need that proof at all (SD-12). Read [the architecture](serve-architecture.md) for how the result is built, and [browser access](browser-access.md) for how to use it. Records are identified `SD-n` — *serve decision*. The numbering is stable; a superseded record @@ -52,6 +53,12 @@ anyone opens a tab — and the tier that choice implies holds for every session A run started against an institutional Bedrock model is private for its whole life; one started against a commercial model is public for its whole life. Neither can drift. +> ⚠ **The first half of that sentence was unreachable until SD-12.** A `serve` daemon holds no +> user-action key, and the new-chat bind asked for that key's proof before binding a private +> model — so with an institutional model configured, no chat could be started at all, and the +> interface showed nothing (the 2026-09-10 QA run, finding F1). See +> [SD-12](#sd-12--a-new-chat-starts-on-the-operators-model-without-a-proof-and-nothing-else-does). + **Displaced alternatives.** - *Mint a digest scoped to a loopback bind.* Rejected: it makes the guarantee depend on the @@ -284,6 +291,96 @@ behaviour, so changing it means revisiting this record, not making a quiet fix. --- +## SD-12 — A new chat starts on the operator's model without a proof, and nothing else does + +**Ruling.** On a daemon that holds no user-action key — the one `biorouter serve` starts (SD-7), +or a `biorouterd` started by hand — `POST /agent/start` binds the operator's configured provider +to the new chat without asking for proof of a person, whether that provider is public or private. +Three things hold beside it: + +- On that daemon, `POST /agent/update_provider` refuses every move onto a private model, whatever + the chat runs on now. The configured model is the only private model a chat there can reach. +- A daemon that holds a key — the desktop application's — is unchanged. Its renderer sends the + proof on every start, and a start that lacks it is refused as before. +- The browser interface states the host's configured model on the requests that reach into a chat + (`X-Caller-Provider`), the way `biorouter session` already does from a terminal, so a chat its + first reply made private stays reachable from the tab that started it. + +**Why.** The configured model is the person's decision, made out of band. `/agent/start` names no +provider: it binds `BIOROUTER_PROVIDER`, which only a proven person may write over HTTP, or which +the operator wrote at the terminal with `biorouter configure`. Open question 24 of the privacy plan +already put the raise at that write — *a raise of every future session* — and SD-1 already says the +tier that choice implies *holds for every session in that daemon*. A new chat taking that tier is +the choice being honored, not a switch. DR-16 governs raising a chat that exists, and a chat that +did not exist a moment ago has nothing to raise. + +On a daemon with no key, asking for the proof can only refuse everyone. The 2026-09-10 QA run +measured it: with an institutional model configured, every new chat on a `serve` daemon was +refused 409, in a sentence written for a model that pointed at a model picker SD-1 disables, and +the interface showed nothing at all. A control nobody can pass is not a boundary; it is the +product not working. + +The other two halves close what the exemption would otherwise open. Without the rule on +`/agent/update_provider`, a chat bound to the private default could be moved to a different private +model — `Private → Private`, which DR-16's raise predicate calls sideways and allows — that nobody +configured. Without the capability statement, the tab that started a chat lost it after one reply: +the reply makes the chat private (the classification ratchets on the turn, never on the bind), and a +keyless daemon reaches a private chat only for a caller whose stated capability covers it. Measured: +the chat's next request answered 403 with nothing stated, and 200 with the host's provider stated. + +**Why not on every daemon.** On a daemon that holds a key the proof costs the person nothing — the +renderer attaches it to every start — and it still refuses a caller that cannot present it. That +includes a model holding the daemon secret, which AR-11 found recoverable and which could otherwise +mint a private-capability chat through `/agent/start` with an extension set of its own choosing. +Relaxing the gate there buys the person nothing and gives that model something. + +**Who can do this, and what else reaches the same place.** The two questions every privacy +control answers in writing ([privacy tiers §3.1](../security/privacy-tiers.md)): + +- *Who can initiate it.* On a keyless daemon, anything holding the daemon secret: the person in + the browser and, indistinguishably, a model running in a chat on that daemon that has recovered + the secret. Both get the configured model and nothing else. +- *What else reaches a chat running on the configured private model:* + + | Door | Proof asked | Changed here | + |---|---|---| + | `workspace_open { new: … }` — binds the machine default through `restore_provider_from_session` | None, on every daemon (privacy tiers, "Did not ship") | No | + | `POST /agent/restart` on a row that names no provider — `restore_provider_from_session` falls back to the configured default | None | No | + | An app session's creation bind (DR-21) | None, deliberately | No | + | `POST /agent/update_provider` onto a private model | The proof; on a keyless daemon, refused outright | Yes | + | `POST /config/set_provider`, and `/config/upsert` or `/config/remove` on a capability key | The proof (SD-1, open question 24) | No — the configured model stays the operator's to choose | + +**Displaced alternatives.** + +- *Keep the refusal, and explain it in the interface.* Rejected. SD-8's explanation is for a + control that can never work; this one is the product's core. A `serve` deployment whose only + model is institutional would be a chat application that cannot chat. +- *Exempt every new chat, whatever provider it asks for.* Rejected. The operator's choice is what + makes the bind legitimate, so a provider the request picked would be a switch. `/agent/start` + names none today, and a field that ever let it name one must not inherit this exemption. +- *Exempt the configured model on every daemon.* Rejected; see *Why not on every daemon*. +- *Let a keyless daemon treat its configured model as the capability of any request that states + none.* Rejected. An absent header resolving to Public is the fail-safe the reach gate is built on, + and a default that raised it would speak for every caller rather than for the client that says + what it runs. + +**Consequence to accept.** On a keyless daemon whose configured model is private, a model running +in a chat on that daemon — a public-model chat resumed from the shared session store — that has +recovered the daemon secret can start a private-capability chat through `/agent/start` with +extensions it chose, and can reach private chats by stating the host's provider. It could already +do the first through `workspace_open { new }`, and the second by spelling a provider name (the +header is not authentication, as `session_reach.rs` records); and the filesystem read-deny that +would stop it carrying anything back out did not ship. It is recorded rather than closed: on a +daemon that cannot tell a person from a model, closing it means refusing the person. + +And one visible change: a browser tab on a host configured with a private model now opens private +chats started in the desktop application on the same machine, which it was refused before. That is +the reach rule — *the caller's capability must be at least the chat's classification* — admitting +it, exactly as it admits `biorouter session` configured with the same model. On a host configured +with a public model nothing changes, and private chats stay out of the browser's reach. + +--- + ## Related documentation - [Architecture of the serving path](serve-architecture.md) — how the decisions above are built. diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index cc6888530..c076cf505 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -689,7 +689,7 @@ "description": "Unauthorized - invalid secret key" }, "409": { - "description": "The selected private provider requires user-action proof", + "description": "The configured provider is private and this daemon holds a user-action key, but the request carried no proof it came from the user (SD-12). A daemon with no user-action key binds its configured provider without one.", "content": { "application/json": { "schema": { @@ -881,7 +881,7 @@ "description": "The session is out of reach, or the target is a subagent and the request lacks user-action proof" }, "409": { - "description": "Refused by a privacy boundary (issue #56). Gate A: a public model cannot be bound to a private chat (body = PrivacyBarrierBody). DR-16: the bind raises this chat's capability to Private and the request carried no proof it came from the user (body = plain text)", + "description": "Refused by a privacy boundary (issue #56). Gate A: a public model cannot be bound to a private chat (body = PrivacyBarrierBody). DR-16: the bind raises this chat's capability to Private and the request carried no proof it came from the user; on a daemon with no user-action key, any bind to a private model (SD-12) (body = plain text)", "content": { "application/json": { "schema": { diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 8c43452cb..237e2c913 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -58,6 +58,8 @@ import { View, ViewOptions } from './utils/navigationUtils'; import { useNavigation } from './hooks/useNavigation'; import { errorMessage } from './utils/conversionUtils'; +import { startChatFailureNotice } from './utils/startChatFailure'; +import { toastError } from './toasts'; import { getInitialWorkingDir } from './utils/workingDir'; import { deliverLauncherMessage } from './utils/launcherMessage'; import { ChatStreamProvider } from './hooks/chatStreamStore'; @@ -168,6 +170,14 @@ const PairRouteContent = ({ setChat }: { setChat: (chat: ChatType) => void }) => }); } catch (error) { console.error('Failed to create session:', error); + toastError(startChatFailureNotice(error, { kept: false })); + // Leave. Every input this effect keys on is still true after a + // failure, so staying would re-run it the moment `isCreatingSession` + // drops — a `POST /agent/start` loop for as long as the refusal + // holds. Home is the resting state for a layout with no tabs (#38). + // The cargo here is a workflow window's; a launcher message arrives + // with a session id and never reaches this branch. + navigate('/', { replace: true }); } finally { setIsCreatingSession(false); } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index b35605218..f67d4f7fc 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -4726,7 +4726,7 @@ export type StartAgentErrors = { */ 401: unknown; /** - * The selected private provider requires user-action proof + * The configured provider is private and this daemon holds a user-action key, but the request carried no proof it came from the user (SD-12). A daemon with no user-action key binds its configured provider without one. */ 409: ErrorResponse; /** @@ -4879,7 +4879,7 @@ export type UpdateAgentProviderErrors = { */ 403: unknown; /** - * Refused by a privacy boundary (issue #56). Gate A: a public model cannot be bound to a private chat (body = PrivacyBarrierBody). DR-16: the bind raises this chat's capability to Private and the request carried no proof it came from the user (body = plain text) + * Refused by a privacy boundary (issue #56). Gate A: a public model cannot be bound to a private chat (body = PrivacyBarrierBody). DR-16: the bind raises this chat's capability to Private and the request carried no proof it came from the user; on a daemon with no user-action key, any bind to a private model (SD-12) (body = plain text) */ 409: PrivacyBarrierBody; /** diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 252e4f8b7..20b317105 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -73,7 +73,8 @@ import { useBoundAffiliation } from './privacy/useBoundAffiliation'; import { getSessionTitlePadding } from './Layout/TitlebarControls'; import { announceSessionName, renameSession } from '../utils/sessionNameSync'; import { toastError, toastWarning } from '../toasts'; -import { errorMessage, isConnectionError } from '../utils/conversionUtils'; +import { errorMessage } from '../utils/conversionUtils'; +import { startChatFailureNotice } from '../utils/startChatFailure'; import { Greeting } from './common/Greeting'; import { navigateWithViewTransition } from '../utils/navigationUtils'; import { unwrapGuardrailFrameInContent } from '../utils/guardrailFrame'; @@ -738,8 +739,9 @@ export function collectArtifactsFromMessages( * is unreachable the awaited createSession rejects *after* the text is already * gone — and the bare catch used to show nothing, so the message silently * vanished. Restore the typed text (via a `restore-chat-input` event the composer - * listens for) and surface a visible toast. Connection detection only picks the - * wording; the toast + restore fire on ANY rejection, so no silent path remains. + * listens for) and surface a visible toast. The words are + * `startChatFailureNotice`'s, shared with every other surface that starts a + * chat; the toast + restore fire on ANY rejection, so no silent path remains. * Exported so it can be unit-tested without Electron. */ export function handleCreateSessionError( @@ -756,13 +758,7 @@ export function handleCreateSessionError( }, }) ); - const connection = isConnectionError(err); - toastError({ - title: connection ? 'Backend disconnected' : 'Failed to start chat', - msg: connection - ? 'Biorouter could not reach its backend. Your message was kept - try again in a moment.' - : errorMessage(err), - }); + toastError(startChatFailureNotice(err, { kept: true })); } /** diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx index ed2552cd9..4a6fc4328 100644 --- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -4,6 +4,11 @@ import { Button } from './ui/button'; import { ModalShell } from './ModalShell'; import { NotificationContent, type NotificationStatus } from './alerts/NotificationSurface'; import { startNewSession } from '../sessions'; +// ⚠ `toasts.tsx` renders this component, so this import closes a cycle. It is +// the one `utils/extensionErrorUtils` already closes, and `toastError` is only +// read inside a click handler, long after both modules have evaluated. +import { toastError } from '../toasts'; +import { startChatFailureNotice } from '../utils/startChatFailure'; import { useNavigation } from '../hooks/useNavigation'; import { formatExtensionErrorMessage } from '../utils/extensionErrorUtils'; import { getInitialWorkingDir } from '../utils/workingDir'; @@ -232,7 +237,12 @@ export function GroupedExtensionLoadingToast({ onOpenChange={setReportOpen} extensions={extensions} onAskBiorouter={ - setView ? (hints) => startNewSession(getInitialWorkingDir(), hints, setView) : null + setView + ? (hints) => + void startNewSession(getInitialWorkingDir(), hints, setView).catch((error) => + toastError(startChatFailureNotice(error, { kept: false })) + ) + : null } /> )} diff --git a/ui/desktop/src/components/Hub.startFailure.test.tsx b/ui/desktop/src/components/Hub.startFailure.test.tsx new file mode 100644 index 000000000..21e2649b0 --- /dev/null +++ b/ui/desktop/src/components/Hub.startFailure.test.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +/** + * F1 of the 2026-09-10 QA run, on the surface where it was measured: type into + * the Home composer, press Enter, and have `POST /agent/start` fail. The + * composer had already wiped itself, and the failure went to `console.error` + * and nowhere else — no toast, no message, the text gone. + * + * The real Hub and the real ChatInput are rendered, because the property lives + * in the handshake between them: ChatInput restores its box only when the + * submit resolves `false`, and Hub decides what it resolves. Only the heavy or + * context-hungry children are stubbed, as in `ChatInput.workingDir.test.tsx`. + */ + +const { mockCreateSession, mockToastError } = vi.hoisted(() => ({ + mockCreateSession: vi.fn(), + mockToastError: vi.fn(), +})); + +vi.mock('../sessions', () => ({ createSession: mockCreateSession })); +vi.mock('../toasts', async (importOriginal) => ({ + ...(await importOriginal()), + toastError: mockToastError, +})); +vi.mock('./sessions/SessionsInsights', () => ({ SessionInsights: () => null })); +// The privacy-off note (H3) needs a router and two more ConfigContext hooks, and +// says nothing about starting a chat. +vi.mock('./privacy/PrivacyTiersOffNote', () => ({ PrivacyTiersOffNote: () => null })); +vi.mock('./ConfigContext', () => ({ + useConfig: () => ({ + extensionsList: [], + getProviders: vi.fn(async () => []), + read: vi.fn(async () => null), + }), +})); +vi.mock('./ModelAndProviderContext', () => ({ + useModelAndProvider: () => ({ + getCurrentModelAndProvider: vi.fn(async () => ({ model: null, provider: null })), + currentModel: null, + currentProvider: null, + currentModelSupportsVision: false, + currentModelSupportedInputMimeTypes: null, + }), +})); +vi.mock('../hooks/useDiverge', () => ({ + useDiverge: () => ({ diverge: vi.fn() }), +})); +vi.mock('./settings/models/bottom_bar/ModelsBottomBar', () => ({ default: () => null })); +vi.mock('./bottom_menu/BottomMenuExtensionSelection', () => ({ + BottomMenuExtensionSelection: () => null, +})); +vi.mock('./bottom_menu/BottomMenuSkillSelection', () => ({ + BottomMenuSkillSelection: () => null, +})); +vi.mock('./bottom_menu/BottomMenuKnowledgeSelection', () => ({ + BottomMenuKnowledgeSelection: () => null, +})); +vi.mock('./bottom_menu/BottomMenuReasoningEffort', () => ({ + BottomMenuReasoningEffort: () => null, +})); +vi.mock('./bottom_menu/CostTracker', () => ({ CostTracker: () => null })); +vi.mock('./MessageQueue', () => ({ default: () => null })); +vi.mock('./MentionPopover', () => { + const MentionPopoverMock = React.forwardRef(() => null); + MentionPopoverMock.displayName = 'MentionPopoverMock'; + return { default: MentionPopoverMock }; +}); +vi.mock('../api', () => ({ + getSession: vi.fn(async () => ({ data: null })), + llamacppStatus: vi.fn(async () => ({ data: {} })), + updateWorkingDir: vi.fn(async () => ({ data: {} })), +})); + +import Hub from './Hub'; + +/** What `POST /agent/start` answered on the QA run's `biorouter serve` daemon. */ +const SERVE_DAEMON_REFUSAL = { + message: + "Switching this chat to a private model is the user's decision, not yours. The request to " + + "switch it to 'versa_azure' did not come from the model picker, so the chat is unchanged and " + + 'still on its current model. Do not retry; the same call will be refused again.', +}; + +beforeEach(() => { + vi.clearAllMocks(); + Object.assign(window, { + appConfig: { + get: (key: string) => (key === 'BIOROUTER_WORKING_DIR' ? '/default/workdir' : undefined), + }, + electron: { + directoryChooser: vi.fn(async () => ({ canceled: true, filePaths: [] })), + addRecentDir: vi.fn(), + logInfo: vi.fn(), + getPathForFile: vi.fn(() => ''), + on: vi.fn(), + off: vi.fn(), + }, + }); +}); + +describe('Hub: a chat that fails to start', () => { + it('says so in words for a person, and the composer keeps what was typed', async () => { + mockCreateSession.mockRejectedValueOnce(SERVE_DAEMON_REFUSAL); + const setView = vi.fn(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + + const composer = screen.getByPlaceholderText('Ask Biorouter anything…') as HTMLTextAreaElement; + fireEvent.change(composer, { target: { value: 'Reply with the single word ready.' } }); + fireEvent.keyDown(composer, { key: 'Enter' }); + + await waitFor(() => expect(mockCreateSession).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockToastError).toHaveBeenCalledTimes(1)); + const notice = mockToastError.mock.calls[0][0] as { title: string; msg: string }; + expect(notice.title).toBe('Failed to start chat'); + expect(notice.msg).not.toContain('Do not retry'); + expect(notice.msg).toContain('Your message was kept.'); + + await waitFor(() => expect(composer.value).toBe('Reply with the single word ready.')); + expect(setView).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it('opens the chat when the start succeeds, with nothing to report', async () => { + mockCreateSession.mockResolvedValueOnce({ id: 'session-1' }); + const setView = vi.fn(); + render(); + + const composer = screen.getByPlaceholderText('Ask Biorouter anything…') as HTMLTextAreaElement; + fireEvent.change(composer, { target: { value: 'hello' } }); + fireEvent.keyDown(composer, { key: 'Enter' }); + + await waitFor(() => + expect(setView).toHaveBeenCalledWith( + 'pair', + expect.objectContaining({ resumeSessionId: 'session-1', initialMessage: 'hello' }) + ) + ); + expect(mockToastError).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index b601916f5..ebfa8634a 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -30,6 +30,8 @@ import { createSession } from '../sessions'; import LoadingBioRouter from './LoadingBioRouter'; import { PrivacyTiersOffNote } from './privacy/PrivacyTiersOffNote'; import type { UserAttachment } from '../types/message'; +import { toastError } from '../toasts'; +import { startChatFailureNotice } from '../utils/startChatFailure'; export default function Hub({ setView, @@ -40,7 +42,16 @@ export default function Hub({ const [workingDir, setWorkingDir] = useState(getInitialWorkingDir()); const [isCreatingSession, setIsCreatingSession] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { + /** + * Resolves FALSE when no chat was started, which is ChatInput's signal to put + * the box back exactly as the user left it — text, reference chips and pasted + * images — since it wiped itself before this awaited anything. + * + * ⚠ The failure used to reach `console.error` and nothing else: the text + * vanished and the Home screen sat unchanged (the 2026-09-10 QA run, F1). It + * is now a toast in words for a person — see `startChatFailureNotice`. + */ + const handleSubmit = async (e: React.FormEvent): Promise => { const customEvent = e as unknown as CustomEvent; const combinedTextFromInput = customEvent.detail?.value || ''; const attachments = (customEvent.detail?.attachments ?? []) as UserAttachment[]; @@ -50,6 +61,7 @@ export default function Hub({ const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList); clearExtensionOverrides(); setIsCreatingSession(true); + e.preventDefault(); try { const session = await createSession(workingDir, { @@ -62,13 +74,17 @@ export default function Hub({ initialMessage: combinedTextFromInput, initialAttachments: attachments, }); + return true; } catch (error) { console.error('Failed to create session:', error); setIsCreatingSession(false); + toastError(startChatFailureNotice(error, { kept: true })); + return false; } - - e.preventDefault(); } + // A second send while the first is still creating the chat: refused, so + // the composer keeps it. + return false; }; return ( diff --git a/ui/desktop/src/components/workflows/WorkflowsView.tsx b/ui/desktop/src/components/workflows/WorkflowsView.tsx index fdea8dc64..032bee99a 100644 --- a/ui/desktop/src/components/workflows/WorkflowsView.tsx +++ b/ui/desktop/src/components/workflows/WorkflowsView.tsx @@ -46,6 +46,7 @@ import { import { SearchView } from '../conversation/SearchView'; import cronstrue from 'cronstrue'; import { getInitialWorkingDir } from '../../utils/workingDir'; +import { startChatFailureNotice } from '../../utils/startChatFailure'; import { DropdownMenu, DropdownMenuContent, @@ -147,9 +148,11 @@ export default function WorkflowsView() { resumeSessionId: session.id, }); } catch (error) { - console.error('Failed to load workflow:', error); - const errorMsg = error instanceof Error ? error.message : 'Failed to load workflow'; - setError(errorMsg); + // A toast, not `setError`: that state is the LIST's load error, and + // setting it replaced a list that had loaded fine with "Couldn't load + // workflows", whose Try again reloads the list rather than the chat. + console.error('Failed to start workflow chat:', error); + toastError(startChatFailureNotice(error, { kept: false })); } }; diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index 2671b6340..de32255df 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -8,6 +8,7 @@ import { ExtensionLoadingStatus, } from './components/GroupedExtensionLoadingToast'; import { getInitialWorkingDir } from './utils/workingDir'; +import { startChatFailureNotice } from './utils/startChatFailure'; import { launchDependencyDebugSession } from './utils/launchDependencyDebug'; import type { DependencyFailure } from './utils/dependencyDebugPrompt'; @@ -340,7 +341,11 @@ function ToastErrorContent({ diff --git a/ui/desktop/src/utils/launcherMessage.ts b/ui/desktop/src/utils/launcherMessage.ts index de2924669..832e3f5b5 100644 --- a/ui/desktop/src/utils/launcherMessage.ts +++ b/ui/desktop/src/utils/launcherMessage.ts @@ -1,5 +1,7 @@ import type { NavigateFunction } from 'react-router-dom'; import { createSession } from '../sessions'; +import { toastError } from '../toasts'; +import { startChatFailureNotice } from './startChatFailure'; import { getInitialWorkingDir } from './workingDir'; import type { PairRouteState } from '../components/Pair'; @@ -31,7 +33,9 @@ import type { PairRouteState } from '../components/Pair'; * The message itself still travels as location.state, which is where the IN * effect reads cargo from once the param has opened the gate. On failure we * navigate nowhere: the marker keeps the window parked on the empty pane (the - * pre-#38 resting state) rather than silently discarding the launch intent. + * pre-#38 resting state) rather than silently discarding the launch intent — + * and the failure is said out loud, in `startChatFailureNotice`'s words, since + * a parked pane explains nothing by itself. */ export async function deliverLauncherMessage( navigate: NavigateFunction, @@ -46,5 +50,6 @@ export async function deliverLauncherMessage( }); } catch (error) { console.error('Failed to create session for launcher message:', error); + toastError(startChatFailureNotice(error, { kept: false })); } } diff --git a/ui/desktop/src/utils/startChatFailure.test.ts b/ui/desktop/src/utils/startChatFailure.test.ts new file mode 100644 index 000000000..d9936bbce --- /dev/null +++ b/ui/desktop/src/utils/startChatFailure.test.ts @@ -0,0 +1,148 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + BACKEND_DISCONNECTED_TITLE, + START_CHAT_FAILED_TITLE, + isStartRefusedForWantOfProof, + startChatFailureNotice, +} from './startChatFailure'; + +/** + * The body the 2026-09-10 QA run captured from `POST /agent/start` on a + * `biorouter serve` daemon with `versa_azure` configured (finding F1), verbatim. + * Under `throwOnError` the generated client throws the parsed body, so this + * object IS what a caller's `catch` receives. + */ +const QA_REFUSAL = { + message: + "Switching this chat to a private model is the user's decision, not yours. The request to " + + "switch it to 'versa_azure' did not come from the model picker, so the chat is unchanged and " + + 'still on its current model. Do not retry; the same call will be refused again. If this task ' + + 'genuinely needs a private model, stop and ask the user to switch this chat to a private ' + + 'model first, in the desktop app under Settings > Models, or with the model chip in the ' + + 'composer.', +}; + +describe('startChatFailureNotice', () => { + it('words the privacy refusal for a person and keeps the daemon text behind Copy error', () => { + const notice = startChatFailureNotice(QA_REFUSAL, { kept: true }); + expect(notice.title).toBe(START_CHAT_FAILED_TITLE); + // Every instruction in the refusal is addressed to a model, and the last + // one names a control the browser surface deliberately disables (SD-1). + for (const forAModel of [ + 'Do not retry', + 'not yours', + 'model picker', + 'stop and ask the user', + 'model chip', + ]) { + expect(notice.msg).not.toContain(forAModel); + } + expect(notice.msg).toContain('Your message was kept.'); + expect(notice.traceback).toBe(QA_REFUSAL.message); + }); + + it('only claims the message was kept when the caller put it back', () => { + expect(startChatFailureNotice(QA_REFUSAL, { kept: false }).msg).not.toContain('kept'); + }); + + it('shows a failure already written for a person as it came', () => { + // The A/B leg of the QA run: the same sandbox with a public provider and no key. + const body = { + message: + 'Failed to configure the selected provider for the new chat: Configuration value not ' + + 'found: OPENAI_API_KEY', + }; + expect(startChatFailureNotice(body, { kept: false })).toEqual({ + title: START_CHAT_FAILED_TITLE, + msg: body.message, + // Always copyable: the troubleshooting guide sends people to "Copy error". + traceback: body.message, + }); + expect(startChatFailureNotice(body, { kept: true }).msg).toBe( + `${body.message} Your message was kept.` + ); + }); + + it('says the backend is unreachable when the request never got an answer', () => { + const notice = startChatFailureNotice(new TypeError('Failed to fetch'), { kept: true }); + expect(notice.title).toBe(BACKEND_DISCONNECTED_TITLE); + expect(notice.msg).toContain('could not reach its backend'); + expect(notice.msg).toContain('Your message was kept'); + }); + + it('recognizes the refusal by its marker, in the shapes the daemon sends, and nothing else', () => { + expect(isStartRefusedForWantOfProof(QA_REFUSAL)).toBe(true); + expect(isStartRefusedForWantOfProof(QA_REFUSAL.message)).toBe(true); + // A thrown Error that happens to carry the words is a bug, not a policy. + expect(isStartRefusedForWantOfProof(new Error(QA_REFUSAL.message))).toBe(false); + expect(isStartRefusedForWantOfProof({ message: 'Failed to create session: disk full' })).toBe( + false + ); + expect(isStartRefusedForWantOfProof(null)).toBe(false); + expect(isStartRefusedForWantOfProof(undefined)).toBe(false); + }); +}); + +/** + * Every surface that starts a chat reports a failure through the notice. + * + * Seven surfaces started a chat when this was written, and before it six of + * them handled a failure four different ways: a console line (the Home composer + * the QA run hit, the launcher), an unhandled rejection (both "Ask Biorouter" + * buttons), a retry loop (a window opened for a workflow), and a list-load + * error over a list that had loaded (Workflows). An eighth surface must not get + * to pick a fifth. + */ +describe('every surface that starts a chat', () => { + const SRC = join(__dirname, '..'); + const STARTS_A_CHAT = /\b(?:createSession|startNewSession|startAgent)\(/; + // `sessions.ts` DEFINES the first two and wraps the third; it reports nothing + // because it has no one to report to. + const DEFINITIONS = new Set(['sessions.ts']); + + const sourceFiles = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + // The generated client is not a surface. + return name === 'api' || name === 'node_modules' ? [] : sourceFiles(path); + } + return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : []; + }); + + // Prose about a call is not a call: `navigationUtils.ts` names + // `startNewSession()` in a comment and starts nothing. + const withoutComments = (source: string) => + source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1'); + + const surfaces = sourceFiles(SRC) + .map((path) => ({ + rel: relative(SRC, path).split(sep).join('/'), + source: withoutComments(readFileSync(path, 'utf8')), + })) + .filter(({ rel, source }) => !DEFINITIONS.has(rel) && STARTS_A_CHAT.test(source)); + + it('finds the surfaces it is guarding, so the scan is not vacuous', () => { + const found = surfaces.map(({ rel }) => rel); + for (const known of [ + 'App.tsx', + 'toasts.tsx', + 'utils/launcherMessage.ts', + 'components/Hub.tsx', + 'components/BaseChat.tsx', + 'components/GroupedExtensionLoadingToast.tsx', + 'components/workflows/WorkflowsView.tsx', + ]) { + expect(found).toContain(known); + } + }); + + it.each(surfaces.map(({ rel, source }) => [rel, source] as const))( + '%s reports a failed start in the shared words', + (_rel, source) => { + expect(source).toMatch(/startChatFailureNotice\(|handleCreateSessionError\(/); + } + ); +}); diff --git a/ui/desktop/src/utils/startChatFailure.ts b/ui/desktop/src/utils/startChatFailure.ts new file mode 100644 index 000000000..2aa58b6a1 --- /dev/null +++ b/ui/desktop/src/utils/startChatFailure.ts @@ -0,0 +1,97 @@ +import { errorMessage, isConnectionError } from './conversionUtils'; +import { USER_ACTION_REFUSAL_MARKER } from './userAction'; + +/** + * What a person is told when `POST /agent/start` fails — on every surface that + * starts a chat: the Home composer, a fresh tab's composer, a window opened for + * a workflow, a launcher message, the "Ask Biorouter" buttons. + * + * The 2026-09-10 QA run (finding F1) found the Home composer swallowing this + * failure whole: the typed text vanished, nothing appeared, and the only trace + * was a console line. The daemon's refusal had been correct, and was written + * for a model — "Do not retry", "ask the user to switch this chat" — so showing + * it verbatim would have been the second half of the same bug. Hence one pure + * mapping, shared by every caller, that picks words for a person and keeps the + * daemon's own text in the toast's copyable details. + * + * Pure (no toast, no DOM) so the words are tested without rendering anything, + * and so `toasts.tsx`, which itself starts chats, can use it without an import + * cycle. Each caller hands the result to `toastError`. + */ +export type StartChatFailureNotice = { + title: string; + msg: string; + /** The daemon's own words, behind the toast's "Copy error". */ + traceback: string; +}; + +export const START_CHAT_FAILED_TITLE = 'Failed to start chat'; +export const BACKEND_DISCONNECTED_TITLE = 'Backend disconnected'; + +/** + * The daemon refused to bind its private default because the request carried + * no proof it came from a person, on a backend that holds a user-action key + * (SD-12). The `serve` daemon holds none and binds its default, so this reaches + * a person only on a desktop app pointed at a backend started elsewhere — the + * case `NO_USER_PROOF_TOAST_MSG` in `ModelAndProviderContext` words the same way. + * + * ⚠ The route answers with an `ErrorResponse`, so under `throwOnError` the + * thrown value is the parsed `{ message }` object — not the plain string + * `isUserActionRefusal` tests for, which is `/agent/update_provider`'s shape. + * Both are accepted, keyed on the marker. A real `Error` carrying the same words + * is not a policy refusal, whatever it reads. + */ +export const isStartRefusedForWantOfProof = (error: unknown): boolean => { + if (error instanceof Error) return false; + const text = + typeof error === 'string' + ? error + : typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ? error.message + : null; + return text !== null && text.includes(USER_ACTION_REFUSAL_MARKER); +}; + +/** + * @param kept whether the caller has put the message back where the person can + * see it (the composer). Say so only when it is true. + */ +export function startChatFailureNotice( + error: unknown, + { kept }: { kept: boolean } +): StartChatFailureNotice { + // `errorMessage` answers its default, not the text, for a bare string. + const daemonText = typeof error === 'string' ? error : errorMessage(error); + if (isConnectionError(error)) { + return { + title: BACKEND_DISCONNECTED_TITLE, + msg: kept + ? 'Biorouter could not reach its backend. Your message was kept - try again in a moment.' + : 'Biorouter could not reach its backend. Try again in a moment.', + traceback: daemonText, + }; + } + const keptSentence = kept ? ' Your message was kept.' : ''; + if (isStartRefusedForWantOfProof(error)) { + return { + title: START_CHAT_FAILED_TITLE, + msg: + 'Biorouter is connected to a backend started outside the app, which could not confirm ' + + 'the request came from you, so it did not start a chat on its private model. To use a ' + + `private model, start the chat in the Biorouter app.${keptSentence}`, + traceback: daemonText, + }; + } + // Every other refusal on this route is already written for a person — + // "Failed to configure the selected provider for the new chat: …" — so it is + // shown as it came, rather than replaced by something vaguer. It is still the + // traceback too, which is what puts "Copy error" on the toast. + return { + title: START_CHAT_FAILED_TITLE, + msg: `${daemonText}${keptSentence}`, + traceback: daemonText, + }; +} diff --git a/ui/desktop/src/utils/userAction.surface.test.ts b/ui/desktop/src/utils/userAction.surface.test.ts new file mode 100644 index 000000000..76efc8305 --- /dev/null +++ b/ui/desktop/src/utils/userAction.surface.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadConfig } = vi.hoisted(() => ({ mockReadConfig: vi.fn() })); +vi.mock('../api', () => ({ readConfig: mockReadConfig })); + +import { CALLER_PROVIDER_HEADER, resetHostProviderForTests, userActionHeaders } from './userAction'; +import { BROWSER_SURFACE_MARKER } from './surface'; + +/** + * SD-12: what each surface says on the requests the daemon's reach gate reads. + * + * The browser half is the one that was missing. On a `biorouter serve` daemon a + * chat started on the host's private model is private from its first reply, and + * the daemon reaches a private chat only for a caller whose stated capability + * covers it — so a tab that stated nothing lost its own chat after one answer. + */ +const onBrowser = () => { + document.documentElement.dataset.biorouterSurface = BROWSER_SURFACE_MARKER; +}; + +beforeEach(() => { + vi.clearAllMocks(); + resetHostProviderForTests(); + delete document.documentElement.dataset.biorouterSurface; + Object.assign(window, { + electron: { getUserActionKey: vi.fn(async () => 'desktop-user-action-key') }, + }); +}); + +afterEach(() => { + delete document.documentElement.dataset.biorouterSurface; +}); + +describe('userActionHeaders', () => { + it('proves the person on the desktop, and states no capability', async () => { + expect(await userActionHeaders()).toEqual({ 'X-User-Action': 'desktop-user-action-key' }); + expect(mockReadConfig).not.toHaveBeenCalled(); + }); + + it("states the host's configured model in a browser, and claims no proof", async () => { + onBrowser(); + mockReadConfig.mockResolvedValue({ data: 'versa_azure' }); + + expect(await userActionHeaders()).toEqual({ [CALLER_PROVIDER_HEADER]: 'versa_azure' }); + expect(mockReadConfig).toHaveBeenCalledWith({ + body: { key: 'BIOROUTER_PROVIDER', is_secret: false }, + }); + // Read once per page: the host's model cannot change under a running tab. + await userActionHeaders(); + expect(mockReadConfig).toHaveBeenCalledTimes(1); + }); + + it('says nothing when the host model cannot be read, and asks again next time', async () => { + onBrowser(); + mockReadConfig.mockRejectedValueOnce(new TypeError('Failed to fetch')); + expect(await userActionHeaders()).toEqual({}); + + mockReadConfig.mockResolvedValueOnce({ data: 'versa_azure' }); + expect(await userActionHeaders()).toEqual({ [CALLER_PROVIDER_HEADER]: 'versa_azure' }); + }); + + it('uses the header name the daemon reads', () => { + const gate = readFileSync( + join(__dirname, '../../../../crates/biorouter-server/src/routes/session_reach.rs'), + 'utf8' + ); + expect(gate).toContain(`pub const CALLER_PROVIDER_HEADER: &str = "${CALLER_PROVIDER_HEADER}";`); + }); +}); diff --git a/ui/desktop/src/utils/userAction.ts b/ui/desktop/src/utils/userAction.ts index dd3c5da4c..df23548e6 100644 --- a/ui/desktop/src/utils/userAction.ts +++ b/ui/desktop/src/utils/userAction.ts @@ -1,3 +1,6 @@ +import { readConfig } from '../api'; +import { isBrowserSurface } from './surface'; + /** * Issue #56 DR-16: the header that proves a request came from the person at the * keyboard rather than from the model. @@ -96,7 +99,69 @@ export const COPY_OF_PRIVATE_REFUSAL_MARKER = 'only the person at the keyboard m export const isPrivateCopyRefusal = (error: unknown): boolean => typeof error === 'string' && error.includes(COPY_OF_PRIVATE_REFUSAL_MARKER); +/** + * Mirrored from `CALLER_PROVIDER_HEADER` in + * `crates/biorouter-server/src/routes/session_reach.rs`. It carries the NAME of + * the provider the caller runs under; the daemon resolves the tier itself. + */ +export const CALLER_PROVIDER_HEADER = 'X-Caller-Provider'; + +/** The host's `BIOROUTER_PROVIDER`, once it has been read successfully. */ +let hostProvider: string | undefined; + +/** + * The provider the machine running `biorouter serve` was configured with. + * + * Only a successful read is cached. A failure answers `null` and is asked again + * next time, so a transient error cannot pin the page to the public side for as + * long as it stays open. + */ +async function hostConfiguredProvider(): Promise { + if (hostProvider) return hostProvider; + try { + const { data } = await readConfig({ body: { key: 'BIOROUTER_PROVIDER', is_secret: false } }); + if (typeof data === 'string' && data.trim()) { + hostProvider = data.trim(); + return hostProvider; + } + } catch { + // Say nothing, which the daemon reads as the public side — fail-safe. + } + return null; +} + +/** For tests: forget the cached host provider. */ +export const resetHostProviderForTests = (): void => { + hostProvider = undefined; +}; + +/** + * The headers that answer the daemon's "may this caller reach this chat?" on + * the requests that need an answer — one surface at a time. + * + * * **The desktop app proves the person**: `X-User-Action`, the key the + * Electron main process minted and handed the daemon's digest on stdin. + * * **A browser states its model** (SD-12). The `biorouter serve` daemon holds no + * key (SD-7), so there is no person to prove, and a browser session runs the + * model the host was configured with (SD-1). It says so the way `biorouter + * session` does from a terminal: `X-Caller-Provider` naming that provider. + * Without it, a chat started on a private host model became unreachable from + * the tab the moment its first reply made it private — measured: the next + * request answered 403, the same request stating the host's provider 200. + * + * ⚠ **Not authentication, and no new reach for anything holding the secret** — + * that caller could always send the header (`session_reach.rs` says as much). + * What changes is the browser tab's own reach: on a host configured with a + * private model it now opens private chats, including ones started in the + * desktop app, which SD-12 records as a consequence. On a host configured with a + * public model it states a public one, and private chats stay out of reach + * exactly as before. + */ export const userActionHeaders = async (): Promise> => { + if (isBrowserSurface()) { + const provider = await hostConfiguredProvider(); + return provider ? { [CALLER_PROVIDER_HEADER]: provider } : {}; + } try { return { 'X-User-Action': await window.electron.getUserActionKey() }; } catch {