Skip to content

fix(serve): a new chat starts on the host's configured private model (QA F1) - #229

Open
Broccolito wants to merge 9 commits into
mainfrom
fix/serve-private-default-new-chat
Open

fix(serve): a new chat starts on the host's configured private model (QA F1)#229
Broccolito wants to merge 9 commits into
mainfrom
fix/serve-private-default-new-chat

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes finding F1 (HIGH) of the 2026-09-10 QA run on merged main (7c96d796). With a private provider configured (versa_azure), biorouter serve refused every new chat. POST /agent/start answered 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)

  1. The new-chat bind asked for a proof a serve daemon cannot hold. bind_new_session_provider treated binding the configured private default as a raise from Public and required X-User-Action. serve spawns the daemon with a closed stdin (SD-7), so no digest exists and the check failed for everyone, the person included.
  2. The renderer swallowed the failure. Hub.tsx and App.tsx only called console.error. Five other chat-start surfaces had their own failure modes: an unhandled rejection (both Ask Biorouter buttons), a POST /agent/start retry loop (a window opened for a workflow re-ran its creation effect every time isCreatingSession dropped), and a list-load error painted over the Workflows list after a failed start.
  3. Fixing (1) alone gives a chat that dies after one reply. The first turn ratchets the chat private (the ratchet fires on the turn, never on the bind). A keyless daemon reaches a private chat only for a caller whose stated capability covers it (session_reach), and the browser never stated one. Measured: the chat's next request answered 403, and the same request with X-Caller-Provider: <host provider> answered 200.

The fix: SD-9, written up in docs/deployment/serve-decisions.md

A new chat starts on the operator's model without a proof, and nothing else does.

  • Server, new-chat bind (new_chat_bind_needs_user, routes/agent.rs): on a daemon with no user-action key, /agent/start binds 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.
  • Server, the complement (raise_baseline): on a keyless daemon, /agent/update_provider measures 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.
  • Renderer, the capability statement (userActionHeaders() on isBrowserSurface()): the browser states the host's BIOROUTER_PROVIDER as X-Caller-Provider, which biorouter session already does from a terminal. The desktop keeps sending X-User-Action and nothing else.
  • Renderer, the notice: one pure startChatFailureNotice gives 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 resolves false on failure, so ChatInput puts the box back exactly as typed, text and pasted images included.

SD-1 still holds: /config/set_provider stays 409 on a keyless daemon, and a test pins that.

Decisions for the reviewer

  • Keyless daemons only, not every surface. The operator's framing allows both. On a keyed daemon the proof costs the person nothing, and it still stops a model holding the recovered daemon secret (AR-11) from minting a private-capability chat with its own extension_overrides. Relaxing it there buys the person nothing. Making it every surface is a one-line change to new_chat_bind_needs_user.
  • Visible behavior change: a browser tab on a host configured with a private model now opens private chats started in the desktop app on the same machine, which the QA's F1b showed refused. The reach rule (caller capability ≥ chat classification) admits this, as it admits biorouter session configured with the same model. On a public-model host nothing changes.
  • Consequence to accept (recorded in SD-9): on a keyless private-model daemon, a model in a chat there that has recovered the secret can start a private-capability chat and state the host's provider. workspace_open { new } already reaches the first on every daemon, and the header was never authentication.
  • I re-pointed a privacy guard. privacy_ar15_is_retired.rs::the_documented_closure_is_the_one_the_code_performs took the first TierRaiseNeedsUser in routes/agent.rs. Since eb594ded that has been the new-chat gate, not AR-15's update_agent_provider. Measured on main: with the proof check deleted from update_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

Test On origin/main On this branch
new_chat_no_user_key::a_keyless_daemon_starts_a_new_chat_on_its_configured_private_model FAIL: 409, the QA's exact prose pass: 200, row names versa_azure, privacy_tier still public
…::the_first_turn_on_a_keyless_default_chat_ratchets_it_as_usual (loopback stub, no model runs) FAIL: 409 at start pass: turn finishes, row becomes 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_configured FAIL: 409 at start pass: update_provider 409, row unchanged. Also fails with the bind fix but without raise_baseline: 200
…::a_keyless_daemon_still_refuses_to_change_the_configured_default pass pass (SD-1 guard)
routes::agent new unit tests (both pure verdicts, every proof arm) n/a pass
privacy_ar15_is_retired (re-pointed) original scan vacuous (above) pass; red when the proof is deleted
Hub.startFailure.test.tsx (real Hub + real ChatInput) FAIL: no toast, text lost pass
startChatFailure.test.ts (words + seven-surface scan) FAIL: 6 of 7 surfaces pass
userAction.surface.test.ts FAIL pass

The keyless tests live in their own integration binary because the digest is a process-global OnceLock and the lib's tests install one.

CI does not run these. rust.yml's test job runs cargo test --workspace --lib --bins, and no workflow runs crates/biorouter-server/tests/*.rs. That covers the new keyless binary, the AR-15 guard and the five privacy integration binaries (clippy --all-targets compiles them, nothing runs them). They were run locally, in the matrix below. The CI gap predates this PR and is flagged separately.

Verification matrix: all green, run on the final tree with BIOROUTER_DISABLE_KEYRING=true. cargo fmt --check clean · cargo test -p biorouter --lib privacy:: 232 passed · the five privacy integration binaries: privacy_toggle 4, privacy_capability 4, privacy_disclosure_toggle 1 (-p biorouter), privacy_toggle_config 12 (-p biorouter-server), privacy_toggle_export 1 (-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_sandboxed 19 passed · cargo test -p biorouter-cli --lib commands::serve (isolated HOME) 16 passed · scripts/clippy-lint.sh, including the too_many_lines baseline, clean · OpenAPI drift check clean · npm run lint:check clean · Prettier clean on every changed file · full npm run test:run: 445 files, 4,990 passed, 1 skipped.

Runtime: a real biorouter serve from this branch, versa_azure, agent-browser

Throwaway BIOROUTER_PATH_ROOT cloned from the seed config:

  • Home composer: "Reply with the single word ready."ready. POST /agent/start 200 with no proof.
  • Same chat, now private: "Reply with the single word again."again. Both /reply requests carried x-caller-provider: versa_azure.
  • A full page reload reopens the private chat with its transcript.
  • select provider, model_id from token_events order by id desc limit 1versa_azure|gpt-5.5-2026-04-24. Session row: privacy_tier=private, privacy_reason=turn:versa_azure.
  • Forced failure (a second serve whose 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)

  • Stop and steering can't work on any serve daemon. /agent/cancel and /interrupt require 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.
  • Knowledge-selection reads send no reach headers on either surface (GET /knowledge/active in KnowledgeContext, MentionPopover, CreateWorkflowFromSessionModal), so a private chat's knowledge-base selection doesn't hydrate from the server, on the desktop too. The chat itself works.
  • The QA run's other findings (F2–F10) are untouched.

Also in this PR

  • browser-access.md no 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.md names the browser as a sender of X-Caller-Provider, and names the one proof-less bind.
  • SD-1 named a route that doesn't exist (/config/provider; it's /config/set_provider, per QA F10).
  • The keyless daemon's startup warning no longer says a private model can't work.
  • The two 409 descriptions in the OpenAPI spec, regenerated.
  • CLAUDE.md: an SD-9 bullet in "Browser access".

🤖 Generated with Claude Code

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant