Skip to content

fix(desktop): skip keychain write when blob contents are unchanged#1377

Merged
wpfleger96 merged 2 commits into
mainfrom
duncan/keychain-idempotent-write
Jun 30, 2026
Merged

fix(desktop): skip keychain write when blob contents are unchanged#1377
wpfleger96 merged 2 commits into
mainfrom
duncan/keychain-idempotent-write

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

On v0.3.38, every persona/model edit re-prompts for the macOS keychain password. Root cause: mutate_blob() unconditionally called write_blob_raw() on every invocation, and on macOS the legacy SecKeychain write is a distinct ACL operation from the "Always Allow"-ed read. save_managed_agentspersist_agent_keys serialises 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.rsmutate_blob() copy-on-write idempotent write

Builds the candidate state in a separate next allocation. Compares next to the current cached map (current) — if equal, returns Ok(()) without calling write_blob_raw(). Only after write_blob_raw() succeeds does the cache advance to next.

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 subsequent store() 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-value store() would skip the write, persist_agent_keys would clear the inline copy, and on restart the key would be absent from both JSON and keychain.

managed_agents/storage.rspersist_agent_keys_with() seam + test fixes

Extracts persist_agent_keys_with(store, records) over the KeyStore trait seam (mirrors hydrate_keys_with()). Adds record_with_pubkey_and_key helper 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, returns Ok(()) without reaching write_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 via load().
  • 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.

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits June 30, 2026 10:55
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>
@wpfleger96 wpfleger96 force-pushed the duncan/keychain-idempotent-write branch from 10dbe46 to 30eacf5 Compare June 30, 2026 14:55
@wpfleger96 wpfleger96 merged commit 5a64ee4 into main Jun 30, 2026
23 of 25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/keychain-idempotent-write branch June 30, 2026 15:09
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)
  ...
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