Close four measured defects in the config and workspace surfaces - #272
Merged
Conversation
Broccolito
force-pushed
the
fix/config-atomicity-mask-and-cold-roster
branch
from
September 12, 2026 03:34
8ccf6f8 to
7018cf8
Compare
…_tools `workspace_set_tools` on a conversation with no live agent replaced its whole saved extension list with just the one change, and reported success. The handler fetched the target's agent with `get_or_create_agent`, whose miss path mints a bare, extension-less agent and caches it under the target's id. `Agent::persist_extension_state` then wrote that empty manager's snapshot as the conversation's entire roster -- the write is a whole-key REPLACE, which is correct for the reply loop (a removal is expressed by an absence) and wrong for a chat that is not open. Measured on 5181f54, on a cold chat holding three extensions: `remove_extensions: ["roster-beta"]` answered `Applied to session ...: -roster-beta.` and left the saved roster holding NONE. `add_extensions` left it holding one. Data loss, announced as a change applied, with nothing at the call site able to tell. Three changes: * `session_extensions::apply_saved_roster_delta` writes `stored - remove + add` onto the session row instead of snapshotting a manager. It is the right answer for an open chat too: this tool knows exactly what it changed, and everything else in the row is state it was never asked to touch. This also closes the same hole reached the other way -- a cold `{provider, model}` call mints a bare agent, so the NEXT call's `peek_agent` would have found one and trusted it. * `session_extensions::saved_roster_of` tells "nothing saved" apart from "saved and unreadable". `EnabledExtensionsState::from_extension_data` ends in `.ok()` and collapses them; to a writer about to replace the key they are opposites, so an unreadable roster now refuses the call loudly rather than being overwritten. * the handler peeks instead of creating for the extension dimension, so a cold conversation is changed where its roster actually lives and an agent is minted only for the provider switch, which genuinely needs one. Gate F1's unload arm asks the same `manageability_refusal(name, None, cap)` the pre-flight's cold branch asks -- judging a cold removal against the saved roster would LOOSEN it, because an unknown name reading Private is what stops the refusal being an existence oracle. The pre-flight's cold branch also answers the existence half now, against the saved roster and below both privacy arms. It used to `continue` past it, so a cold removal of a name the chat never had came back as `-name` -- and then took the rest of the roster with it.
`POST /config/set_provider` called `set_biorouter_provider` and then `set_biorouter_model`. Those are two `save_values` calls, so between them `config.yaml` held the new provider beside the OLD model: measured at ~55 ms of `versa_azure` next to `gpt-6-astra` during one switch. A chat started in that window binds a pair nobody chose, and on this product the PROVIDER decides the privacy capability a session starts at, so the mismatch is privacy-relevant and not only cosmetic. `Config::set_params` writes several non-secret keys under one `guard` and one `save_values`, and `set_biorouter_provider_and_model` is the pair that only ever means anything together. All six sites that wrote the two keys in sequence now use it -- the route, both `biorouter configure` paths, `biorouter models`, and the OpenRouter and Tetrate sign-up flows. Deliberately NOT a second temp-file-and-rename: `save_values` already stages under a per-process, per-call name (see `Config::staging_path`, and the shared-`config.tmp` race its doc records) and that is where the atomicity lives. The fix is to make one write carry both keys, not to invent another writer. The test asserts the write COUNT, because that is the mechanism: two writes is a window by construction and one has none. Reading the file back cannot see this -- the final state was always correct, which is why the gap survived.
`POST /config/read` with `is_secret: true` answered
`{"maskedValue":"Y2EzNTgy********..."}`. `mask_secret` showed the first
`min(len / 2, 8)` characters, so roughly eight characters of the real credential
survived the mask -- a partial secret in the one response whose whole purpose is
not to contain one, and a prefix long enough to identify which key is stored and
to narrow a search for the rest.
The mask is now a fixed eight-bullet placeholder carrying none of the secret's
bytes. Its LENGTH is fixed for the same reason: how long a stored credential is
fingerprints which kind it is. Nothing renders it as anything but placeholder
text (`DefaultProviderSetupForm.tsx` puts it straight into a field), so no caller
needed it to resemble the value, and the response shape is unchanged -- still
`{ maskedValue: string }`, so the generated client is untouched.
The test's fail-before is the prefix loop: `masked != secret` passes against the
old helper, and so does "contains asterisks".
Swept the neighbours. `mask_secret` had exactly one call site and is the only
secret-masking helper in the crates; no route or log line emits a partial secret.
The one other partial reveal is `crates/biorouter/examples/tetrate_auth.rs:28`,
which prints the first 10 characters of a key the developer has just obtained
interactively, to their own terminal -- not a route, not a log, not shipped.
Left alone and recorded here rather than folded into a security fix.
Broccolito
force-pushed
the
fix/config-atomicity-mask-and-cold-roster
branch
from
September 12, 2026 03:35
7018cf8 to
b285212
Compare
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.
Four measured defects in the config and workspace surfaces. One commit per item, so any one can be reverted alone. Item 4 turned out to be already fixed on
mainand is not in the diff.⚠ Item 3 is security-sensitive (a partial credential in an HTTP response) — please review that commit as such, and note it does not conflict with #237.
workspace_set_toolswiped a cold chat's extension roster and reported successc0f7a248/config/set_providerdid not write the provider/model pair atomicallye4fcafa9/config/readrevealed the first ~8 characters of a masked secretf2e8452f/config/recover's "could not be written" note is sticky after the file healsmain, skippedNo route's response shape changed, so no OpenAPI/TS-client regeneration is needed (
/config/readstill answers{ maskedValue: string }).1 — A cold chat's extension roster survives
workspace_set_tools(c0f7a248)handle_set_toolsfetched the target's agent withAgentManager::get_or_create_agent, whose miss path mints a bare, extension-less agent and caches it under the target's id.apply_extension_changesthen finished withAgent::persist_extension_state—session_extensions::record, a whole-key REPLACE ofenabled_extensions.v0built from the live manager's snapshot. That basis is right for the reply loop (the chat is open, its manager is its roster, and a removal is expressed by an absence) and wrong for a conversation that is not open, where the manager is an agent the handler just created empty.Fail-before
set_tools_on_a_cold_conversation_keeps_the_extensions_it_did_not_name— a session row seeded with threeStdioextensions, asserted to have no live agent (peek_agentisNone), thenworkspace_set_tools { remove_extensions: ["roster-beta"] }from a private caller:Three extensions in, zero out — and the call answered
Applied to session <id>: -roster-beta.add_extensionsis the same shape in the other direction (the roster becomes exactly the one added extension). Data loss reported as success is what makes this the worst item here: nothing at the call site can tell.Two more, both
left: Some(false) right: Some(true)on the same revert:set_tools_refuses_a_cold_conversation_a_removal_it_has_nothing_for— a cold removal of a name the chat never had answeredApplied to session <id>: -ghost-fixture.The live branch has refused this since QA finding F4; the cold branchcontinued past the existence check, so the phantom was reported as applied and took the rest of the roster with it.a_saved_roster_this_build_cannot_read_is_refused_rather_than_replaced— a roster the build cannot parse answeredApplied to session <id>: -roster-alpha.and was overwritten with{"extensions": []}.How the revert was staged (the first attempt measured the wrong thing)
Reverting to
Agent::persist_extension_stateverbatim fails in a unit test for an unrelated reason:AgentManager::instance()carriesSessionManager::instance()while the test'sWorkspaceClientcarries its own temp store, so the write landsSession not found. In the daemon both are the same on-disk store, which is why the wipe is real in production. The faithful revert askssession_extensions::record(exactly whatpersist_extension_statecalls) through the client's store, removing the harness artifact. The numbers above are from that run.The fix
session_extensions::apply_saved_roster_deltawritesstored − remove + addonto the session row instead of snapshotting a manager. Right for an open chat too: this tool knows exactly what it changed, and the rest of the row is state it was never asked to touch. This is also what closes the hole reached the other way — a cold{provider, model}call still mints a bare agent, so apeek_agent-only fix would have had the next call find one and trust it.session_extensions::saved_roster_ofseparates "nothing saved" from "saved and unreadable".EnabledExtensionsState::from_extension_dataends in.ok()and collapses them; to a writer about to replace the key they are opposites. An unreadable roster now fails the call loudly rather than being overwritten.set_tools_preflight_refusalruns the same function).Gates preserved, deliberately
Gate F1's unload arm on the cold branch asks
manageability_refusal(name, None, cap)— the same predicate with the sameNonethe pre-flight's cold branch asks. Judging a cold removal against the saved roster would have loosened it: an unknown name reading Private is what stops the refusal being an existence oracle over exactly the private names Gate E hides. The pinned-source needle.assert_extension_manageable(name, cap)is intact, and neither repo-grep census counts an ident this touches (manageability_refusalappears in both census files only in prose; theprivate_or_absent_refusalrow countsextension_manager.rsalone). No census row was added or re-counted.Also deduplicated: the "is not enabled in conversation" refusal now has one definition (
Self::nothing_to_remove) asked by both branches instead of two copies — this file's own history of two doors drifting is the reason.2 — The default provider and its model are one write (
e4fcafa9)set_biorouter_provider(...)thenset_biorouter_model(...)is twoset_paramcalls, hence twosave_valuescalls, soconfig.yamlheld the new provider beside the old model in between — measured at ~55 ms ofversa_azurenext togpt-6-astraduring one switch. A chat started in that window binds a pair nobody chose, and the provider decides the privacy capability a session starts at, so the mismatch is privacy-relevant and not only cosmetic.Mechanism: the existing one, extended — deliberately not a second temp-file-and-rename.
save_valuesalready stages under a per-process, per-call name (Config::staging_path, whose doc records the shared-config.tmprace and the WindowsERROR_LOCK_VIOLATIONit caused) and renames into place. File-level atomicity was never missing; one write carrying both keys was. SoConfig::set_paramsholds the sameguardacross oneload, the inserts and onesave_values, exactly asset_paramdoes, andset_biorouter_provider_and_modelis the pair that only ever means anything together.All six sites that wrote the two keys in sequence now use it: the route, both
biorouter configurepaths,biorouter models, and the OpenRouter and Tetrate sign-up flows.Fail-before
the_default_provider_and_its_model_are_one_config_writeasserts the write count, because the count is the mechanism — two writes is a window by construction, one has none. Reading the file back cannot see this: the final state was always correct, which is exactly why the gap survived.3 — ⚠ SECURITY: a masked secret reveals none of the secret (
f2e8452f)#237 checked first, as asked.
git diff origin/main...origin/claude/sweet-pare-965d18touches 54 files and neitherroutes/config_management.rsnorconfig/base.rsis among them (empty diff for both paths). No conflict with that branch.mask_secretshowedmin(len / 2, 8)leading characters, so a 40-character key came back as eight real characters followed by asterisks —{"maskedValue":"Y2EzNTgy********…"}— a partial credential in the one response whose whole purpose is not to contain one, and a prefix long enough to identify which key is stored and to narrow a search for the rest.It is now a fixed eight-bullet placeholder carrying none of the secret's bytes. The length is fixed for the same reason: how long a stored credential is fingerprints which kind it is. The response shape is unchanged, and the only renderer puts the string straight into a field (
DefaultProviderSetupForm.tsx:108), so nothing needed it to resemble the value.Fail-before
a_masked_secret_reveals_nothing_of_itfails against the old helper. The load-bearing part is the prefix loop (for n in 1..=secret.len()):masked != secretpasses against the old code, and so does "contains asterisks" — only asking whether any prefix survives catches this.Neighbour sweep
mask_secrethad exactly one call site and is the only secret-masking helper incrates/; no route or log line emits a partially masked secret. One other partial reveal exists and was left alone on purpose:crates/biorouter/examples/tetrate_auth.rs:28prints the first 10 characters of a key the developer has just obtained interactively, to their own terminal — not a route, not a log, not shipped. Flagged rather than folded into a security commit.4 — Already fixed on
main, no commitNot a defect on
5181f544; it was closed as finding F2 of the 2026-09-10 QA run, and by the mechanism the brief asks for — the verdict re-evaluates instead of being cached.Config::outstanding_write_failure()is the only reader and re-checks the recorded failure against the disk as it is now: no record ⇒ no I/O; a config that does not load keeps its record; a config that loads is re-probed throughprobe_config_write(everything a write does except the rename), and a probe that now succeeds clears the record — only the failure it actually tested (slot.recorded == recorded).last_write_error()is its string view through the same check, and the recover route reads it after the reload (config_management.rs:1466). Existing coverage:a_recovery_after_the_config_was_healed_reports_persisted_with_no_note(F2 by name) anda_recorded_write_failure_is_cleared_once_the_config_loads_and_can_be_written_again.Verification
All run with
BIOROUTER_DISABLE_KEYRING=true, on the committed tree:cargo check -p biorouter -p biorouter-server -p biorouter-cli --lib --testscargo test -p biorouter --lib -- config:: agents::workspace agents::session_extensionscargo test -p biorouter-server --lib -- routes::config routes::workspacecargo test -p biorouter-server --testscargo test -p biorouter --test privacy_capability --test privacy_guard_wiringcargo fmt --all && cargo fmt --check./scripts/clippy-lint.shclippy-lint.shfails itstoo_many_linesbaseline onsend_prompt_turn(
workspace_extension.rs, 101/100), which arrived withe60213c8(#244) and isuntouched by this branch —
git diff origin/main...HEADcontains no line of thatfunction. It was the only finding; one
clippy::string_sliceerror this branchdid introduce (a byte slice in the new masking test) is fixed in
f2e8452f.Rebased onto
38270a50after an automation mergedorigin/maininto the branchmid-review, so that the history stays three commits — one per item, each
revertible alone. The rebased tree is byte-identical to the merged state
(
git diff <merge> HEADempty) and the whole table above was re-run on it.Six new tests: three cold-roster end-to-end, one pure reader test (
an_unreadable_saved_roster_is_not_an_absent_one), one config write-count test, one masking test. Every one of them was demonstrated to fail against the reverted behaviour before being accepted.🤖 Generated with Claude Code