fix(desktop): skip keychain write when blob contents are unchanged#1377
Merged
Conversation
On macOS the legacy SecKeychain write is a distinct ACL operation from the read that was "Always Allow"-ed. Every call to mutate_blob() unconditionally called write_blob_raw(), so saving an agent record (e.g. a model change that touches no key) still triggered N keychain prompts — one per agent — because persist_agent_keys re-serialises the blob on every save. Fix by snapshotting the map before applying the mutation and skipping write_blob_raw() when the map is byte-for-byte equal afterwards. HashMap PartialEq compares by content not iteration order, so the check is order-safe. Once all agent keys are in the keyring and inline copies are cleared, every subsequent model/persona save becomes a no-op write and issues zero prompts. Also extracts persist_agent_keys_with() over the KeyStore seam (matching the existing hydrate_keys_with() pattern) and adds three new tests: - mutate_blob_skips_write_when_map_unchanged: proves the skip fires on a no-op store() call without touching the real keychain (would fail in CI unsigned builds if write_blob_raw were reached). - persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared: the dominant prompt-storm scenario — all keys already persisted, model changed, write count must be 0. - persist_agent_keys_writes_once_per_record_with_inline_key: inline keys trigger exactly one write each and are cleared afterwards. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…loss
Review finding (Thufir, pass 1): the previous implementation mutated the
cached map in place before write_blob_raw succeeded. If write_blob_raw
failed (denied prompt, transient outage, ACL rejection), the in-memory
cache was left ahead of durable storage. A subsequent store() call with
the same key/value would compare equal to the dirty cache, skip the
write, and return Ok(()). If persist_agent_keys then cleared the inline
copy and wrote JSON, the agent key would be absent from both JSON and
keychain on next launch — silent data loss.
Fix: apply the mutation to a candidate map ('next'), leaving the cached
'current' untouched. Only after write_blob_raw succeeds does the cache
advance to 'next'. On write failure the cache remains at the last known
durable state, so the next store() attempt for the same key still sees a
diff and retries the write.
Also addresses the review MINOR: persist_agent_keys_writes_once_per_record
now uses distinct pubkeys (pubkey-agent-alpha / pubkey-agent-beta) so it
verifies two distinct keyring names rather than two writes to the same
name. Adds record_with_pubkey_and_key helper and asserts both keyring
entries are stored under the correct names.
New test: mutate_blob_does_not_advance_cache_on_write_failure — pre-seeds
the cache, attempts to store a new key (fails on unsigned CI builds),
asserts the cache remains at the pre-seeded state and the failed key is
not visible via load.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
10dbe46 to
30eacf5
Compare
wpfleger96
pushed a commit
that referenced
this pull request
Jun 30, 2026
…work * origin/main: (25 commits) fix(thread): stop mid-scroll content jump in live threads (#1397) fix(ci): restore main to green — tauri fmt, personas.rs file-size split, Windows path test (#1399) fix(desktop): enable buzz-dev-mcp MCP server for Codex agents (#1394) fix(ci): restore E2E flakiness fixes for pgschema, docker-pull, and spec timing (#1396) fix(personas): persist pack-backed persona UI edits across reboot (#1392) fix(buzz-acp): clear steer_rx on all run_prompt_task exit paths (#1391) Restore channel date divider rule (#1395) Speed up profile wave action (#1379) Restore visible links for rich previews (#1378) Mobile channel list polish (#1367) style(desktop): unify corner radii to rounded-2xl (16px) (#1393) fix(desktop): skip keychain write when blob contents are unchanged (#1377) fix(desktop): stop clipping the agent-activity row under the composer (#1371) Constrain macOS overscroll to conversations (#1317) Mobile appearance foundation (#1366) chore(release): release Buzz Desktop version 0.3.38 (#1375) feat(desktop): provider-agnostic model selection + databricks discovery (#1307) release(helm): buzz chart 0.1.1 (#1374) Harden relay attack surfaces (#1369) ci(helm): publish chart to GHCR on chart-v* tags (#1372) ...
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.
On v0.3.38, every persona/model edit re-prompts for the macOS keychain password. Root cause:
mutate_blob()unconditionally calledwrite_blob_raw()on every invocation, and on macOS the legacySecKeychainwrite is a distinct ACL operation from the "Always Allow"-ed read.save_managed_agents→persist_agent_keysserialises the blob on every save — even for a model change that touches no key — producing N prompts per agent per save.What this changes
secret_store.rs—mutate_blob()copy-on-write idempotent writeBuilds the candidate state in a separate
nextallocation. Comparesnextto the current cached map (current) — if equal, returnsOk(())without callingwrite_blob_raw(). Only afterwrite_blob_raw()succeeds does the cache advance tonext.Two invariants this preserves:
Idempotent: a model/persona save that changes no key produces zero keychain writes and zero prompts.
Copy-on-write durable: if
write_blob_raw()fails (denied prompt, transient outage, ACL rejection), the cache stays at the last known durable state. A subsequentstore()for the same key/value still sees a diff from the durable cache and retries the write. Without this, the prior in-place mutation left the cache ahead of storage — a later equal-valuestore()would skip the write,persist_agent_keyswould clear the inline copy, and on restart the key would be absent from both JSON and keychain.managed_agents/storage.rs—persist_agent_keys_with()seam + test fixesExtracts
persist_agent_keys_with(store, records)over theKeyStoretrait seam (mirrorshydrate_keys_with()). Addsrecord_with_pubkey_and_keyhelper so tests can use distinct pubkeys per record.Tests (828 passing)
mutate_blob_skips_write_when_map_unchanged— pre-seeded cache, re-store same value, returnsOk(())without reachingwrite_blob_raw()(would error on unsigned CI).mutate_blob_does_not_advance_cache_on_write_failure— pre-seeded cache, store new key (fails on unsigned CI), asserts cache remains at pre-seeded state and failed key is not visible viaload().persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared— dominant prompt-storm path: all keys already persisted, model changes, write_count = 0.persist_agent_keys_writes_once_per_record_with_inline_key— two agents with distinct pubkeys/keyring names, each triggers exactly one write, inline copies cleared after persist.