Skip to content

feat(desktop): move persisted secrets from JSON into keyring via gen-ref protocol - #5486

Draft
wpfleger96 wants to merge 22 commits into
mainfrom
duncan/keyring-secret-projection
Draft

feat(desktop): move persisted secrets from JSON into keyring via gen-ref protocol#5486
wpfleger96 wants to merge 22 commits into
mainfrom
duncan/keyring-secret-projection

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 10, 2026

Copy link
Copy Markdown
Member

What

Provider API keys and other secret-shaped values set as agent env vars were persisted plaintext in global-agent-config.json, managed-agents.json, agent-definitions.json, and the custom_harnesses/*.json files. This moves every persisted secret out of those JSON files and into the OS keyring, leaving each JSON file holding only an opaque generation reference. The nostr identity keys already lived in the keyring; agent env vars were the missed surface, and custom-harness env maps were a second plaintext surface on the same footing.

How

Gen-ref protocol

Each secret field (env_vars, auth_tag, provider_config, and harness env) gets a companion *_ref: Option<String> holding a generation UUID. On load, when the inline field is empty and a ref is present, the value is hydrated from the keyring. On save, a non-empty inline value is written to the keyring under a versioned coordinate, the inline JSON field is cleared, and the ref is updated to the new generation. Superseded generations are never deleted eagerly — they are retired by a two-cycle GC only once no committed JSON references them.

Coordinates are keyed by namespace: global:env:<gen>, agent:<pubkey>:env|auth_tag|provider_config:<gen>, definition:<slug>:env:<gen>, and harness:<id>:env:<gen>. All coordinates are keys inside the single JSON blob the keyring already uses for identity (service buzz-desktop, or buzz-desktop-dev in debug builds; username secrets) — one blob entry, one OS prompt per process, shared with the identity store.

Fail-closed hierarchy

Unavailability is tracked at three tiers, and none silently degrades to an empty env:

  1. Instance tierManagedAgentRecord.secrets_unavailable, set when an instance ref fails to hydrate.
  2. Definition tierAgentDefinition.secrets_unavailable, set when a definition env_vars_ref fails to hydrate.
  3. Global tierload_global_agent_config returns Err when a global env_vars_ref is unresolvable.

All three gate spawn, readiness (local_setup: false), and deploy through the single require_effective_secrets_available gate. The definition-tier spawn refusal is one shared predicate (unavailable_definition_id) rather than a per-call-site copy.

Commit-point discipline

Old-generation deletion is removed from every save path. The invariant is: write keyring → cancel GC candidacy → clear the inline field → set the ref → commit the JSON atomically. A failed JSON commit still finds the on-disk ref's generation live, because nothing was deleted before the commit landed.

Batched write with generation reuse

A metadata-only save no longer churns the keyring. write_secrets_batched persists env / auth_tag / provider_config in a single blob mutation and, for any field whose bytes byte-equal what the live ref already stores, keeps the existing generation and writes nothing — no new UUID, no mutation, no GC candidacy. Changed fields are staged and committed together in one atomic store_batch_verified; if that one write fails, all staged fields fall back to KeptInline together, so there is no torn partial state. The boot-migration path keeps its explicit cancel_gc_candidacy; the command path drops it as provably redundant under the transaction lock (a freshly minted or a live-reused generation can never carry a candidate marker at save time).

GC exclusion and validation

The full extraction + global save + GC runs under managed_agents_store_lock; the GC's final JSON read and remove_batch are indivisible against a concurrent save. collect_live_refs validates every coordinate before the sweep — malformed, duplicate, or inline+ref-conflict states abort the sweep as a no-op rather than reclaiming a live generation.

Cross-process transaction lock

Each save_managed_agents / save_agent_definitions acquires a cross-process transaction lock (acquire_secret_txn_lock) before reading the other half, and holds it across the gen-writes and the atomic JSON commit. The identity-persist path takes the same lock, so an identity save cannot interleave with an agent save against the shared blob. The lock is keyed by the symlink-resolved store directory inode (store_txn_lock_dir), so two Desktop processes sharing one JSON (e.g. just staging + just production on shared worktrees) cannot interleave a read of one half with the other's write. There is no /tmp lock file: the lock is taken directly on the canonical store-directory inode in owner-only app-data (Unix flock on the directory fd; Windows a named kernel mutex via CreateMutexW derived from the resolved path), so a tmp-cleaner cannot unlink the lock target from under a live transaction. The blob lockfile itself is additionally hardened: after the flock is granted, locked_inode_is_live re-checks that the held fd still refers to the live pathname's inode, catching the classic unlink/recreate split where two processes would otherwise each "hold" the lock over different inodes.

Boot migration and scrub

At boot, before the spawn registry warms, migrate_inline_secrets_to_keyring and migrate_harness_secrets_to_keyring lift any remaining inline secrets into the keyring, rewrite the JSON stripped at 0o600, and — once every projected generation reads back cleanly — scrub the plaintext-bearing backup/temp artifacts (*.json.bak, temp files) the save path can leave behind. Extraction is idempotent across launches: an already-projected file (empty inline + live ref) is re-read and nothing is rewritten. Phase-2 cleanup (legacy managed-agents.json scrub/delete) is gated on a verified reload showing zero secrets_unavailable flags.

Dev-service migration

migrate_agent_secrets_to_dev_service copies projection keys from the release keyring service to a scoped dev service for standalone worktree launches. The completion marker (_dev_secrets_migration_v2) is written only on a fully clean run; any conflict withholds it so the migration retries next boot. Per-coordinate conflict:<coord> markers are cleared only on proven convergence or proven non-liveness of the coordinate — a source that merely dropped the coordinate does not clear it.

Known decisions and limitations

  • Harness generations are not GC'd. Superseded harness:<id>:env:<gen> entries are outside is_projection_key, so the two-cycle sweep never reclaims them; they accrete in the keyring blob (encrypted at rest — not the plaintext surface this PR closes) on the rare harness-env edit. Follow-up: make custom-harness saves a second consumer of write_secrets_batched, whose gen-reuse makes most accretion never happen.
  • Boot migration relies on single-instance serialization. The boot extraction path is not itself cross-process-locked; it is safe because Desktop runs single-instance (tauri_plugin_single_instance, lib.rs:116) — a duplicate launch is focused into the existing window rather than running a second boot migration.
  • Caller-snapshot race (pre-existing, deferred). The transaction lock makes each save_* atomic cross-process, but it cannot make a caller's pre-lock snapshot transactional. A command that reads its half under the process-local managed_agents_store_lock, mutates, then calls save_* still races a second OS process reading the same JSON before the first commits (last-writer-wins, as wholesale rewrite always was). The gen-ref protocol's new exposure is that a lost write can orphan a just-committed generation. Closing it means promoting ~60 save_* call sites to caller-level load → mutate → commit transactions, several spanning .await — beyond this security fix's seam. The seam is marked at acquire_secret_txn_lock in storage.rs.

Test coverage

  • Two-launch ref preservation and GC-cycle survival across the projection seams.
  • Raw re-read + ? propagation on the save paths (no silent inline re-materialization).
  • Projection / hydrate / 0o600 / boot-migration coverage including two-launch generation stability, and 64 harness tests (16 migration-harness-secrets + 48 custom-harness).
  • Batched-mutation gen-reuse and single-mutation atomicity.
  • Blob-lock inode recheck, identity txn-lock entry point, and an identity-vs-agent-save interleave test.
  • Instance-vs-definition save_* interleave test (handshake-deterministic flock EWOULDBLOCK probe), asserting neither half is lost or re-inlined.
  • Definition-tier spawn/readiness driven through the production predicate (mutation-checked), with a positive control isolating the definition gate.
  • The two global-config Err-path boundaries (refuse_save_on_unavailable_current, resolve_snapshot_global), each with an Ok pass-through control and an Err-arm regression. The get_agent_models, card-mint, and profile-signing boundaries route through require_effective_secrets_available, whose all-tier refusal is already saturated by the effective-config gate tests — no per-command duplicates.

…ref protocol

All three secret tiers (global env vars, per-agent env/auth_tag/
provider_config, definition env vars) now move from plaintext JSON
into the existing SecretStore keyring blob using an immutable
generation-reference protocol:

  - Each write creates a new immutable generation entry under a UUID-
    keyed coordinate (e.g. agent:<pubkey>:env:<gen>). The stripped JSON
    carries a non-secret *_ref field; the atomic JSON write is THE commit
    point.
  - Crash before JSON commit: old ref stays authoritative, orphaned gen
    is swept by GC. Crash after: new ref is authoritative, old gen swept.
  - Empty vs unavailable distinguished by ref presence: no ref = field
    intentionally empty (agent runs); ref present but entry missing =
    unavailable, fail closed (nsec-style refusal).
  - Inline precedence: on keyring write failure (Windows TooLong /
    backend error) value stays inline in 0o600 JSON with a named warning
    and the ref is cleared. Inline is authoritative over any keyring state
    for that boot; extraction retries next boot.
  - Two-cycle GC: sweep 1 marks unreferenced generations as candidates in
    the blob; sweep 2 (next verified boot) deletes still-unreferenced
    candidates. Saves cancel their generation's candidacy before the JSON
    commit. GC is a no-op when either JSON store is absent, unreadable,
    or changes between reference collection and blob mutation.
  - Boot migration runs at the end of run_boot_migrations_inner (after
    materialize_agent_runtimes) so raw-JSON migrations see inline values.
  - OSS keyringless builds are unchanged (inline 0o600 JSON).

Coordinates:
  global:env:<gen>
  agent:<pubkey>:env:<gen>
  agent:<pubkey>:auth_tag:<gen>
  agent:<pubkey>:provider_config:<gen>   (entire BackendKind::Provider.config)
  definition:<id>:env:<gen>

Files changed:
  secret_store.rs              — add remove_keys() for batch blob deletion
  managed_agents/mod.rs        — expose secret_projection module
  managed_agents/secret_projection.rs — new: full gen-ref protocol impl + tests
  managed_agents/types.rs      — add auth_tag_ref, env_vars_ref, provider_config_ref
  managed_agents/global_config — add env_vars_ref, hydrate-on-load/strip-on-save
  managed_agents/storage.rs    — hydrate/strip seam, migration helpers
  migration.rs                 — boot migration + two-cycle GC
  commands/agents.rs           — new ref fields initialised to None
  commands/personas/snapshot/import.rs — same
  commands/team_snapshot.rs    — same

Deferred (noted in PR body, not a separate issue): existing nsec blob
(~2.8 KB) already exceeds the Windows 2,560-byte cap — pre-existing,
same accepted-risk class as the overflow path added here.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 10, 2026 15:57
@wpfleger96
wpfleger96 marked this pull request as draft August 10, 2026 16:00
Duncan and others added 21 commits August 10, 2026 13:24
… tests

Paul-review deltas on PR #5486:

- local_setup now reflects secrets_unavailable: both status_for_with and
  unkeyable_failed_status set local_setup = false when a record has a
  dangling keyring ref. Previously, runtimes like claude/codex (which pass
  agent_readiness without API-key checks) would show local_setup = true
  even when spawn was blocked by spawn_key_refusal. The flag was already
  wired to spawn refusal; this closes the status gap.

- Legacy Sprout app-data agents dir cleanup: migrate_legacy_app_data_dir
  copies the old xyz.block.sprout.app agents/ into Buzz but leaves the
  source. cleanup_secret_artifacts now also runs on the legacy source dir
  after a verified extraction boot, scrubbing backups and deleting
  .invalid/temp artifacts from the Sprout-era plaintext store.

- Phase 2 artifact cleanup tests: add filesystem-backed tests for
  atomic-write temp deletion, .invalid deletion, parseable-backup scrub,
  symlink escape (unix-only), and deletion-failure tolerance. These cover
  the cleanup_secret_artifacts inventory that the spec requires.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…sal + dev secrets migration

Commit the three items that were in working-tree state but not pushed:

- types.rs: add secrets_unavailable: bool field (transient, #[serde(skip)]) to
  ManagedAgentRecord with doc comment explaining load-time semantics

- secret_seam.rs: set record.secrets_unavailable = true when hydration errors
  are present, making the setter actually wire unavailability into the record

- storage.rs: extend spawn_key_refusal to check secrets_unavailable (in addition
  to empty nsec), refuse spawn with a named error message; update comments at
  both load_managed_agents callers to reflect that unavailability is now set
  directly on the record and consulted by spawn_key_refusal

- storage.rs: add migrate_agent_secrets_to_dev_service (v2 marker, separately
  versioned from _dev_migration_v1) covering canonical dev → prod and scoped
  dev → canonical dev, with conflict detection, global:env ref check, and
  collect_global_env_refs helper

- migration.rs: wire migrate_agent_secrets_to_dev_service into the boot path
  (cfg(debug_assertions), after migrate_inline_secrets_to_keyring)

- secret_projection.rs: add test_cancel_before_mark_ordering_protects_in_flight_gen
  covering the cancel-before-mark scenario Paul flagged (step 4 safe: a marked-
  then-referenced gen survives the next boot's delete phase)

- All struct literal sites updated with secrets_unavailable: false

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… ratchet

Complete the module extraction and field wiring left in working-tree state:

- Wire dev_service_migration and secret_projection_tests as modules
- Extract dev keyring secrets migration into dev_service_migration.rs and
  secret_projection tests into secret_projection_tests.rs so both parent
  files stay under the 1000-line desktop ratchet
- Reclaim ratchet lines in the six over-cap files touched by the
  secrets_unavailable field additions (types.rs, migration.rs, agents.rs,
  import.rs, discovery/tests.rs, readiness.rs)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ker, cleanup gate

F1 CRITICAL: Remove all eager old-generation deletion from secret_seam.rs and
global_config/mod.rs. Old-gen retirement moves entirely to two-cycle GC.
Add 5 deterministic JSON-write-failure tests proving old generation still
hydrates after a failed atomic write (instance env/auth/provider, definition
env, global env).

F5b (collect_live_refs validates): Reject malformed/duplicate coordinates and
inline+ref conflicts — any such condition makes the sweep a no-op. Add 12
validation tests.

F5a (GC exclusion, not just time): Wrap extraction + global save + GC under
managed_agents_store_lock so final JSON read and remove_batch are indivisible
against a concurrent save. Add 3 synchronized interleaving tests.

F3 (fail-closed global + definition tiers):
- Global: load_global_agent_config() returns Err on missing/corrupt ref;
  callers in runtime.rs/runtime_commands.rs now surface global_unavailable
  flag instead of unwrap_or_default(); spawn_agent_child uses ? to abort.
- Definition: AgentDefinition gains secrets_unavailable field (#[serde(skip)]);
  hydrate_all_secrets_for_records sets it on definition records; spawn gate in
  runtime.rs refuses linked instances when definition is unavailable;
  status_for_with checks definition_unavailable tier; agents_deploy.rs checks
  it too. Add definition_tier_tests.rs with 3 tests.

F4 (dev migration marker only on clean completion): Extract pure decision core
plan_dev_secrets_migration(); marker withheld when conflict_count > 0; partial
progress (non-conflicting keys) still written for next boot; log emitted.

F2 (cleanup authorization + legacy live file): Replace bare
agent_secret_store_pub().is_some() gate with extraction_verified() that reloads
both stores and checks no secrets_unavailable flags set after hydration.
Add scrub_legacy_live_file() to explicitly scrub/delete legacy managed-agents.json
after verified extraction. Add 4 tests.

AgentDefinition derives Default to support test helper construction.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ict hydration refusal, GC live-ref + cross-process lock

Close the five pass-2 blocking findings on the keyring secret-projection PR.

F3: add require_effective_secrets_available — a single fail-closed gate
(strict global loader, instance secrets_unavailable, linked-definition
secrets_unavailable) consumed by every side-effecting path that was
degrading silently: get_agent_models, mint_agent_card, the
update_managed_agent rename, persona name/avatar propagation signing, and
set_global_agent_config (which now refuses to overwrite a committed global
whose ref cannot load rather than unwrap_or_default over it).

F4: a dev-migration value conflict now writes a conflict:<coord> marker into
the keyring blob. load_secret fails closed whenever a coordinate carries one,
so hydration sets secrets_unavailable and every downstream gate refuses —
making the conflicted value genuinely unavailable for the whole retry window
instead of merely withholding the completion marker. A resolved conflict
clears its marker.

F5b: collect_live_refs now returns full expected blob coordinates alongside
gen ids, and both GC sweeps no-op when any committed live reference is missing
from the blob — so a dangling live ref can no longer let GC delete an older
unreferenced generation that may be the only recoverable payload.

F5a: add a cross-process transaction lock (a second advisory lockfile,
distinct from the per-op mutate_blob lock) held from generation write through
the JSON commit on every save path, and across the live-ref read through
remove_batch in GC — closing the two-process interleave where one process
could delete a generation another had written but not yet committed.

Snapshot export: materialize_snapshot_bytes propagates the global loader Err
instead of unwrap_or_default, refusing export/send rather than silently
shipping empty inherited runtime/provider/model defaults.

agent_models.rs, card.rs, and secret_store.rs are kept under the desktop
file-size ratchet via #[path] sibling extraction (naming helpers, env-layer
key resolution, and the secret_store test module respectively).

Adds unit tests for the gate (all three tiers + precedence), conflict
hydration→spawn-refusal, GC missing-coordinate freeze (both sweeps + positive
bound), independent-participant txn-lock exclusion, and snapshot degradation
refusal.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ocess txn lock

Close the r4 blocking findings on the keyring secret-projection PR:

- Fail-closed load: a conflict-marker read that returns Err is now treated
  as unavailable, not fall-through. load_blob caches successful reads but
  never errors, so a transient marker-read failure could be followed by a
  cached-success value read that hydrates a known-conflicted credential.

- Conflict-marker cleanup only on proven resolution: the dev-migration
  planner clears a conflict:<coord> marker only when source and destination
  converge OR the coordinate is proven no longer live in canonical JSON. A
  disappearing source while the destination value is still live retains the
  marker (fail closed).

- Cross-process transaction lock (Race 1): the lock now spans the other-half
  read through generation writes to the JSON commit in both save paths, so
  two Desktop processes cannot interleave a cross-half overwrite. Keyed by
  the symlink-resolved canonical store directory inode, not the keyring
  service, so processes sharing one JSON file always contend on one lock.

- Transaction lockfile out of /tmp: the lock target is the store directory
  inode in the owner's app-data tree, immune to the tmp-cleaner
  unlink/recreate split that let two processes both "hold" a /tmp lockfile.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Bring in main's runtime.rs mesh acp_model wire translation so local
checks and CI both run on the merged tree. Clean auto-merge; the PR's
fail-closed spawn gating and main's model translation touch disjoint
regions of spawn_agent_child.

* origin/main: (24 commits)
  Improve desktop search scoping (#5306)
  Add glass appearance and cohesive settings (#5478)
  Add Send to channel for thread messages (#5305)
  Fix macOS attachment picker lifecycle and allow inert HTML downloads (#5569)
  fix(desktop): preserve fresh channel timelines (#5577)
  fix(desktop): suppress fresh focus-return refetches for channels and home-feed (#5535)
  chore: mesh upgrade, clean up legacy special case code, simplify model selection for mesh (#5289)
  fix(desktop): preserve theme when opening communities (#5266)
  fix(link-preview): resolve YouTube videos through oEmbed (#5520)
  fix(buzz-agent): harden Databricks OAuth token cache and callback (#5534)
  fix(link-preview): reliably render previews sent right after they resolve (#5245)
  fix(link-preview): restore Buzz entity link cards (#5494)
  chore(release): release Buzz Desktop version 0.5.9 (#5521)
  feat(cli): add --visibility flag to channels update (#5119)
  Polish desktop onboarding flow (#5310)
  fix(desktop): quiesce renderer polling while hidden (#3677) (#5490)
  fix(channels): restore member invitations to private channels (#5493)
  perf(ci): experiment with sccache for relay builds (#5224)
  fix(desktop): bound nine unbounded localStorage stores (#5454)
  feat(desktop): time-based sweep for stale localStorage caches (#5453)
  ...

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Merge-collision ratchet relief. Merging current main pushed managed_agents/runtime.rs to 1012 split-count (>1000 limit): the PR's fail-closed spawn gating (+16) and main's mesh acp_model translation (+12) landed in disjoint regions of one file. Move the self-contained persona_drift_state helper verbatim to a new runtime/drift.rs sibling (the file's existing plain-submodule pattern) and re-export it, dropping runtime.rs to 986. No logic change: the fn body is identical; only visibility (pub(crate)) and an import replacing the inline fully-qualified type path differ.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…yring secret refs

The boot migration ran already-projected records (empty inline, live ref)
through the strip-on-save seam, which reads an empty inline field as a user
clear and drops the ref — silently wiping every committed secret ref on the
second launch, then GC deletes the orphaned generations. And save_managed_agents
re-read the definition half through the hydrating loader, so every instance-side
save re-inlined definition secrets back into plaintext JSON and froze GC on the
inline+ref conflict.

Give the migration its own field-granular transition (migrate_inline_field ->
FieldMigration) that only projects a non-empty inline value and never clears a
ref it did not write; expose it as a pub(crate) seam so the custom-harness
migration shares one W1-safe semantic. Re-read the definition half RAW under the
txn lock and propagate a parse error with ? instead of unwrap_or_default(), so a
malformed store fails the save rather than deleting every definition. Broaden the
backup recognizer structurally to catch this repo's own pre-backfill.bak and
pre-team-suffix-strip.bak producers.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Pull W8 forward: main advanced 18 commits and touched two files this branch
also touches (managed_agents/runtime.rs, commands/agent_models_tests.rs), so
the branch-skew guard blocked pushes. Merge (not rebase) to preserve the
reviewed r5 SHAs; both overlaps auto-resolved cleanly (runtime.rs keeps main's
idle_pool_sleep env line alongside this branch's persona_drift extraction;
agent_models_tests.rs keeps both sides' record fields and new tests).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
storage.rs and storage_tests.rs crossed the desktop 1000-line file-size
ratchet after the keyring merge. Lift the log/receipt/PID domain (log-path
resolution, rotation, install logs, runtime receipts, PID files, log-tail
error extraction) into a storage/logs.rs submodule re-exported via
`pub use logs::*`, and move its tests to storage/logs_tests.rs.

Pure movement: each item keeps its original visibility so all caller paths
resolve unchanged, and the workspace test count is identical before and
after (2623 passed / 0 failed / 15 ignored).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The service-keyed /tmp blob lockfile could be unlinked by a tmp cleaner
while a holder kept its flock; a recreate under the same pathname is a
fresh inode a second process can lock in parallel, splitting mutual
exclusion across two inodes. Recheck (dev, ino) after the lock is granted
and re-acquire against the live pathname on a mismatch, bounded by
MAX_BLOB_LOCK_REACQUIRE so a pathname churned faster than we can lock
fails loudly instead of spinning.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n reuse

The seam wrote each secret field with its own mutate_blob call and minted
a fresh generation every save, so a metadata-only save churned the keyring
blob and left GC-eligible orphan generations behind. write_secrets_batched
reuses the live generation when a field's bytes are unchanged (no write,
no new UUID, no GC churn) and commits every changed field in a single
store_batch_verified mutation. Per-field cancel_gc_candidacy is dropped on
this path: under the txn lock GC cannot run, a fresh UUID gen was never
observed by a sweep, and a reused gen is a live ref sweeps skip, so no
candidate marker can exist at save time. Verification bypasses the cache
via verify_stored_raw so a backend that acks a write it did not persist is
still caught.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…xn lock

Identity and agent secrets share one SecretStore blob, but the identity
persist path took no transaction lock, so an identity keyring write could
interleave with a concurrent agent save/GC projection transaction on the
same blob. Acquire the secret txn lock at the AppHandle-bearing entry
points (import_identity, persist_current_identity, pairing recover), held
across the persist span only. Lock order is identity_mutation -> txn ->
blob on identity paths and txn -> blob on save/GC paths, so no path
acquires them in opposing order. The boot path is left to
single-instance serialization (lib.rs single-instance plugin).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…-projection

* origin/main:
  Harden shared agent instruction review (#4220)
  chore(release): release Buzz Desktop version 0.5.11 (#5714)
  feat(acp): report standard adapter usage (#4950)
  fix(mobile): settle hydrated threads on latest reply (#4702)
  perf(desktop): persist channel snapshot hash (#5684)
  fix(agent): raise output limit and allow 3 recoveries (#5475)
  fix(desktop): defer foreground resume work (#5696)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

# Conflicts:
#	desktop/src-tauri/src/commands/personas/update.rs
#	desktop/src-tauri/src/managed_agents/mod.rs
custom_harnesses.rs is about to grow with keyring env projection, so lift
its unit tests into a #[path]-included custom_harnesses_tests.rs sibling to
stay under the desktop 1000-line file-size ratchet.

Pure movement: production code is byte-identical, and the 43 test functions
move unchanged into the sibling module.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
migration.rs is at the desktop 1000-line file-size ratchet's frozen base (1408, pinned on origin/main), so the custom-harness boot-migration wiring cannot add its module registration without tripping the ratchet. Lift the self-contained databricks V1->V2 reconcile pair (reconcile_databricks_v1_to_v2 + its _in_file helper) into a migration/databricks_reconcile.rs submodule and move its tests to the sibling databricks_reconcile_tests.rs, reclaiming headroom.

Pure movement: function bodies are byte-identical; only patch_json_records/canonical_dev_data_dir gain a super:: qualifier, the entry point narrows pub -> pub(super) (its sole caller is the boot call), and the test file's test_support import re-roots to crate::migration::test_support. All 11 databricks reconcile tests stay green.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Custom harness definitions carried their `env` map (which can hold provider secrets like ANTHROPIC_API_KEY) in plaintext on disk. Mirror the agent-store secret seam: add an `env_ref` generation pointer, strip `env` into the OS keyring under `harness:<id>:env:<gen>` on save, hydrate it back on load, and write the stripped JSON 0o600. A boot migration lifts pre-existing inline env, verifies each projected generation reads back, then scrubs plaintext-bearing backup/temp artifacts. Keyless builds and keyring outages keep env inline as an authoritative fallback.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The save-path .bak comments called a leaked backup unconditionally harmless. That holds only when the harness env was projected into the keyring; in the keyring-unavailable/keyless fallback the env stays inline, so the .bak carries plaintext secrets. State the real contract and point at the boot migration that scrubs *.json.bak for exactly that case.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The definition tier of the fail-closed spawn gate — refuse to launch or
deploy a linked instance whose definition's env_vars ref could not be
hydrated from the keyring — was open-coded identically at four sites
(spawn, two status rows, deploy) and reimplemented again in tests.

Extract `unavailable_definition_id` next to its sibling `spawn_key_refusal`
and route every site through it. It returns the offending definition id so
the spawn and deploy paths can name it in the refusal message without a
second lookup; status rows use `.is_some()`.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…seam tests

The r5 review flagged several fail-closed seams as under- or falsely
tested. This makes the coverage and comments truthful at each:

- global-config save and snapshot export each extract an AppHandle-free
  seam (refuse_save_on_unavailable_current, resolve_snapshot_global) so
  the missing/unreadable-ref refusal is unit-testable; both map only the
  Err arm and pass Ok through unchanged.
- runtime_commands readiness tests drop two assertion-free let _ = status
  bodies for real local_setup assertions, with a positive control that
  proves the negatives are caused by the definition-unavailable gate.
- a concurrent instance/definition save interleave test pins that neither
  half is lost or re-inlined under the shared txn lock; save_agent_
  definitions_at is split out as the path-based seam it drives.
- dev_service_migration, secret_store, and runtime comments corrected to
  match the code (marker-clear proof conditions, completion vs per-
  coordinate markers, Windows named mutex, lone degraded-empty consumer).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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