fix(serve): a new chat starts on the host's configured private model (QA F1) - #229
Open
Broccolito wants to merge 9 commits into
Open
fix(serve): a new chat starts on the host's configured private model (QA F1)#229Broccolito wants to merge 9 commits into
Broccolito wants to merge 9 commits into
Conversation
…vate model biorouter serve spawns biorouterd with no user-action key (SD-7), and the new-chat gate demanded that key's proof before binding a private default, so every POST /agent/start on a serve daemon configured with a private provider was refused 409 — the 2026-09-10 QA run's F1. The configured default is the person's choice, made out of band with biorouter configure (open question 24 put the raise at the write), so on a daemon that holds no key the new-chat bind no longer asks for a proof nobody can give. A daemon that holds a key (the desktop's) still refuses a proof-less first bind: the renderer sends the proof for free, and a model holding the recovered secret should not mint a private chat. The exemption covers one provider at one moment. On a keyless daemon, /agent/update_provider now measures every move onto a private model from Public (raise_baseline), so a new chat cannot be moved sideways to a private model nobody configured.
the_documented_closure_is_the_one_the_code_performs took the FIRST TierRaiseNeedsUser in routes/agent.rs. Since eb594de that has been the new-chat gate, not update_agent_provider's, so deleting the proof check from update_provider left the scan green (measured). The scan now starts at the handler AR-15 is about and is bounded by the next route.
A failed POST /agent/start reached console.error and nothing else on the Home composer (the QA run's F1): the text vanished and nothing appeared. One pure notice, startChatFailureNotice, now words every failure for a person (the daemon's own text stays behind Copy error), and every surface that starts a chat reports through it: Home, a fresh tab, a workflow window, the launcher, both Ask Biorouter buttons and Workflows. A source scan keeps an eighth surface from picking its own way to fail. Also fixed on the way: a workflow window whose start failed re-ran its creation effect in a loop, and a failed workflow start replaced the Workflows list with a list-load error. A browser tab now states the host's configured model (X-Caller-Provider) where the desktop proves the person: measured, a chat started on a private host model 403s its next request once its first reply makes it private, and 200s with the host provider stated.
The notice set a traceback only when it replaced the daemon's words, so the commonest failure on a serve host — the configured model has no credential there — showed no Copy error. It is always set now.
… a proof Records the ruling behind the new-chat fix: what it exempts (the configured default, at creation, on a daemon with no user-action key), what it does not (any other private model; any daemon that holds a key), the two halves that keep it from opening more, and the consequence to accept, with the privacy checklist's two questions answered as an enumeration. browser-access.md no longer promises a private-provider serve host works while it refused every chat, and says what a browser chat on one does. programmatic-session-access.md names the browser as a sender of X-Caller-Provider and the one proof-less bind. SD-1 named a route that does not exist (/config/provider); it is /config/set_provider. The two 409 descriptions in the OpenAPI spec now say which daemon refuses.
It said the daemon refuses every request that raises a session's privacy capability. A new chat binding the configured provider is no longer one of them, so an operator reading it would conclude a private model cannot work.
It claimed 'not a widening'. It adds no reach for a caller holding the daemon secret, but a tab on a private-model host now opens private chats, including desktop-started ones — which SD-9 records.
This was referenced Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes finding F1 (HIGH) of the 2026-09-10 QA run on merged
main(7c96d796). With a private provider configured (versa_azure),biorouter serverefused every new chat.POST /agent/startanswered 409 with a refusal written for a model, and the browser showed nothing: the Home composer cleared and left one console line.What was wrong (three things, one found while fixing the first)
servedaemon cannot hold.bind_new_session_providertreated binding the configured private default as a raise from Public and requiredX-User-Action.servespawns the daemon with a closed stdin (SD-7), so no digest exists and the check failed for everyone, the person included.Hub.tsxandApp.tsxonly calledconsole.error. Five other chat-start surfaces had their own failure modes: an unhandled rejection (both Ask Biorouter buttons), aPOST /agent/startretry loop (a window opened for a workflow re-ran its creation effect every timeisCreatingSessiondropped), and a list-load error painted over the Workflows list after a failed start.session_reach), and the browser never stated one. Measured: the chat's next request answered 403, and the same request withX-Caller-Provider: <host provider>answered 200.The fix: SD-9, written up in
docs/deployment/serve-decisions.mdnew_chat_bind_needs_user,routes/agent.rs): on a daemon with no user-action key,/agent/startbinds the configured provider without asking for a proof. A daemon that holds a key (the desktop's) is unchanged: the renderer already sends the proof on every start (sessions.ts), and a proof-less start is still refused.raise_baseline): on a keyless daemon,/agent/update_providermeasures every move onto a private model from Public. A new chat on the private default therefore can't be moved sideways (Private → Private, which the raise predicate calls sideways) to a private model nobody configured. This is the "a request for a DIFFERENT private provider must still 409" requirement.userActionHeaders()onisBrowserSurface()): the browser states the host'sBIOROUTER_PROVIDERasX-Caller-Provider, whichbiorouter sessionalready does from a terminal. The desktop keeps sendingX-User-Actionand nothing else.startChatFailureNoticegives every failure human wording (the daemon's own text stays behind Copy error). All seven surfaces that start a chat report through it, and a source scan fails if an eighth doesn't. The Home composer resolvesfalseon failure, soChatInputputs the box back exactly as typed, text and pasted images included.SD-1 still holds:
/config/set_providerstays 409 on a keyless daemon, and a test pins that.Decisions for the reviewer
extension_overrides. Relaxing it there buys the person nothing. Making it every surface is a one-line change tonew_chat_bind_needs_user.biorouter sessionconfigured with the same model. On a public-model host nothing changes.workspace_open { new }already reaches the first on every daemon, and the header was never authentication.privacy_ar15_is_retired.rs::the_documented_closure_is_the_one_the_code_performstook the firstTierRaiseNeedsUserinroutes/agent.rs. Sinceeb594dedthat has been the new-chat gate, not AR-15'supdate_agent_provider. Measured onmain: with the proof check deleted fromupdate_agent_provider, the original scan stays green. The re-pointed scan goes red. It's a separate commit, easy to review or revert on its own.Tests: fail-before shown
origin/mainnew_chat_no_user_key::a_keyless_daemon_starts_a_new_chat_on_its_configured_private_modelversa_azure,privacy_tierstillpublic…::the_first_turn_on_a_keyless_default_chat_ratchets_it_as_usual(loopback stub, no model runs)private/turn:ollama; next reach 403 without the header, 200 with it…::a_keyless_daemon_will_not_move_a_new_chat_to_a_private_model_nobody_configuredupdate_provider409, row unchanged. Also fails with the bind fix but withoutraise_baseline: 200…::a_keyless_daemon_still_refuses_to_change_the_configured_defaultroutes::agentnew unit tests (both pure verdicts, every proof arm)privacy_ar15_is_retired(re-pointed)Hub.startFailure.test.tsx(real Hub + real ChatInput)startChatFailure.test.ts(words + seven-surface scan)userAction.surface.test.tsThe keyless tests live in their own integration binary because the digest is a process-global
OnceLockand the lib's tests install one.Verification matrix: all green, run on the final tree with
BIOROUTER_DISABLE_KEYRING=true.cargo fmt --checkclean ·cargo test -p biorouter --lib privacy::232 passed · the five privacy integration binaries:privacy_toggle4,privacy_capability4,privacy_disclosure_toggle1 (-p biorouter),privacy_toggle_config12 (-p biorouter-server),privacy_toggle_export1 (-p biorouter-mcp) ·cargo test -p biorouter-server --lib -- routes::agent routes::web_ui routes::shell auth::119 passed ·new_chat_no_user_key,privacy_ar15_is_retired,approval_no_user_key,knowledge_tier_no_user_key,every_test_binary_is_sandboxed19 passed ·cargo test -p biorouter-cli --lib commands::serve(isolatedHOME) 16 passed ·scripts/clippy-lint.sh, including thetoo_many_linesbaseline, clean · OpenAPI drift check clean ·npm run lint:checkclean · Prettier clean on every changed file · fullnpm run test:run: 445 files, 4,990 passed, 1 skipped.Runtime: a real
biorouter servefrom this branch,versa_azure, agent-browserThrowaway
BIOROUTER_PATH_ROOTcloned from the seed config:ready.POST /agent/start200 with no proof.again. Both/replyrequests carriedx-caller-provider: versa_azure.select provider, model_id from token_events order by id desc limit 1→versa_azure|gpt-5.5-2026-04-24. Session row:privacy_tier=private,privacy_reason=turn:versa_azure.servewhose configured model has no key): the toast reads "Failed to start chat — Failed to configure the selected provider for the new chat: Configuration value not found: OPENAI_API_KEY Your message was kept." with Copy error, and the composer still holds the prompt.Found, not fixed (flagged separately)
servedaemon./agent/canceland/interruptrequire the proof unconditionally. Measured 403 from the page, capability header included. This predates the change (a public-model host has it too), and it needs its own ruling on who may stop a turn there.GET /knowledge/activeinKnowledgeContext,MentionPopover,CreateWorkflowFromSessionModal), so a private chat's knowledge-base selection doesn't hydrate from the server, on the desktop too. The chat itself works.Also in this PR
browser-access.mdno longer promises that a private-provider host works while it refused every chat. It now says what a browser chat on one does, and has a troubleshooting entry for a chat that won't start.programmatic-session-access.mdnames the browser as a sender ofX-Caller-Provider, and names the one proof-less bind./config/provider; it's/config/set_provider, per QA F10).CLAUDE.md: an SD-9 bullet in "Browser access".🤖 Generated with Claude Code