Skip to content

feat(secrets): persist unlock across upgrade + sleep (--durable), cross-platform#1313

Merged
muqsitnawaz merged 2 commits into
mainfrom
feat-durable-secrets-unlock
Jul 20, 2026
Merged

feat(secrets): persist unlock across upgrade + sleep (--durable), cross-platform#1313
muqsitnawaz merged 2 commits into
mainfrom
feat-durable-secrets-unlock

Conversation

@muqsitnawaz

Copy link
Copy Markdown
Contributor

What

An agents secrets unlock grant lived only in the macOS broker's RAM, so it evaporated on every agents-cli upgrade (the postinstall bounces the daemon) and on sleep — forcing a Touch ID re-tap and breaking headless reads with not unlocked in the secrets agent. (This bit us all through the release + fleet work in this session.)

Persist the resolved bundle+env as a device-local, non-biometry keychain session item (the existing set-no-acl path — reads never prompt) that the broker rehydrates on start and that reads fall back to when the broker RAM misses.

Split default (chosen posture): survives upgrade/daemon-restart by default; pass --durable (or set secrets.agent.durable: true) to survive sleep + reboot too — otherwise a bundle re-locks on sleep as before. lock / rotate / delete clear it.

Cross-platform: this is a macOS-only concern — Linux (libsecret) and Windows (Credential Manager) already resolve secrets durably from the OS store with no broker and no prompt, so agents secrets unlock there is now a friendly no-op and the command behaves the same on all three OSes.

Design (validated; 4 review-caught corrections baked in)

  • A — never enumerate session.* (breaks under #316 hashing); a fixed-name session.index + per-bundle blobs, all read by known name.
  • B — the read fallback is on the normal path (not just the headless-throw branch), so interactive reads don't re-prompt after restart.
  • Clock + every evict site also deleteSession.
  • Drekey keeps agents-cli.session.* no-ACL.

Files

lib/secrets/session-store.ts (new), agent.ts (rehydrate + sleep-prune + secretsAgentDurable), bundles.ts (fallback + evict), index.ts (rekey), commands/secrets.ts (--durable + off-darwin no-op), types.ts (secrets.agent.durable).

Verified

  • tsc clean; 114 secrets tests pass (new session-store.test.ts incl. the #316-hashed pass, the bundles.ts read-fallback resolving instead of throwing, and the rekey no-ACL guard). Run on Linux CI via the KeychainBackend in-memory seam.
  • Full suite: only pre-existing unrelated flakes (codex exec/models, a 135s SSE serve test) — my diff touches none of those files.

Ships in 1.20.71, then the fleet gets agents devices update.

Session transcript (confidential; not uploaded per public-repo convention): zion:/Users/muqsit/.agents/.history/versions/claude/2.1.207/home/.claude/projects/-Users-muqsit-src-github-com-muqsitnawaz/f2314c23-cca7-436a-8535-3bb177497c15.jsonl

muqsitnawaz and others added 2 commits July 19, 2026 16:59
The macOS secrets broker held an unlock only in RAM, so it evaporated on every
daemon restart (agents-cli upgrade) and on sleep — forcing a Touch ID re-tap and
breaking headless reads with "not unlocked in the secrets agent".

Persist the resolved bundle+env of an unlocked bundle as a device-local,
non-biometry keychain session item (the existing set-no-acl path — reads never
prompt) that the broker REHYDRATES on start and that reads FALL BACK to when the
broker RAM misses. Split default: survives upgrade/restart automatically; pass
--durable (or set secrets.agent.durable) to survive sleep + reboot too, else the
bundle re-locks on sleep as before. lock/rotate/delete clear it.

- lib/secrets/session-store.ts (new): pure core (selectRehydratable, pruneOnSleep,
  pruneExpired) + a best-effort keychain adapter, all by KNOWN item name (never
  enumerated, so it round-trips under #316 hashing).
- agent.ts: rehydrateStore() replaces new Map() in both broker paths; SLEEP prunes
  non-durable sessions; secretsAgentDurable() config reader.
- bundles.ts: session read-fallback on the normal path (fixes both the interactive
  re-prompt and the headless throw); evict/delete also deleteSession.
- index.ts: rekey keeps agents-cli.session.* no-ACL.
- secrets.ts: --durable flag; off-darwin unlock/lock is a friendly no-op (Linux/
  Windows already persist durably) so the command is uniform cross-platform.
- types.ts: secrets.agent.durable config.

Tests run on Linux CI via the KeychainBackend seam (in-memory), incl. the #316
hashed-name pass and the read-fallback resolving instead of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o-op

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@prix-cloud

prix-cloud Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Code Reviewer

Verdict: Ready to merge

Build:bun install --frozen-lockfile && bun run build (which runs tsc) — clean, no errors.
Tests: ✅ Secrets-scoped suite (vitest run src/lib/secrets/ src/commands/secrets) — 393 passed, 13 skipped, 0 failed, including all three new/changed test files (session-store.test.ts, the new bundles.test.ts durable-fallback describe block, and the rekey no-ACL guard in index.test.ts). A full unsharded run in this sandbox didn't finish in the time budget — it stalls on the pre-existing slow sessions.test.ts suite (6-8s/test, unrelated to this diff), matching the PR description's own note about a 135s SSE test and flaky codex tests. One unrelated failure surfaced in src/lib/daemon.test.ts (getDaemonLaunch > runs a non-JS launcher (resolved shim) directly) — traced it to this sandbox having a real global npm install at /usr/local/bin/agents (a symlink to dist/index.js) that collides with the test's hardcoded path assumption; GitHub's CI runners won't have that collision, and the file isn't touched by this diff.

Read AGENTS.md (repo root) and apps/cli/AGENTS.md before building — confirmed the actual build/test commands (bun install --frozen-lockfile && bun run build, vitest run --shard=N/3) match what CI runs, and that gitleaks + test are the two required checks.

CI status: gitleaks shows as failed, but the log is a transient GitHub API 503 fetching /pulls/1313/commits before any scanning happened — not a detected secret. Worth re-running the job; unrelated to the diff's content.

Changes that work well

  • Correction A (never enumerate session.*) is followed correctly everywhere — every session-store read/write goes through the fixed-name index (SESSION_INDEX_ITEM) or a name-derived blob key, never a list() call. The #316-hashed test pass in session-store.test.ts is a real regression guard for this.
  • Correction C (evict-site parity) checked directly: both agentEvictSync call sites in bundles.ts (writeBundle:486, deleteBundle:499) are paired with deleteSession, and both commands/secrets.ts lock branches pair agentLock/agentLock() with deleteSession/deleteAllSessions.
  • The new readAndResolveBundleEnv fallback (bundles.ts:980-996) sits inside the same backend === 'keychain' && !opts.noAgent && ... gate as the existing agent fast-path, so it's genuinely on the normal read path (Correction B), not just the headless throw — confirmed by reading the surrounding function.
  • session-store.ts's pure core (selectRehydratable, pruneOnSleep, pruneExpired, upsertEntry, removeEntry) is cleanly separated from the keychain adapter, which is what makes it unit-testable on Linux CI without a real Keychain — good structure for a macOS-only feature.
  • Every I/O function in session-store.ts is best-effort (try/catch, never throws) per its own doc comment, consistent with "persistence is an optimization, not a correctness dependency."
  • The session-fallback re-warm reuses the pre-existing agentAutoLoadSync pattern (already called elsewhere in bundles.ts:1160), so it isn't a novel risk introduced by this PR.

Issues that need attention

Minor — --durable is silently dropped on --host remote unlock. buildRemoteUnlockArgs (src/commands/secrets.ts:332-338) only forwards --all and --ttl to the remote invocation:

export function buildRemoteUnlockArgs(names: string[], opts: { all?: boolean; ttl?: string }): string[] {
  return [
    'unlock',
    ...(opts.all ? ['--all'] : names),
    ...(opts.ttl ? ['--ttl', opts.ttl] : []),
  ];
}

But --durable is a valid flag on the same unlock command regardless of --host (opts: { ttl?: string; durable?: boolean; all?: boolean; host?: string }). Running agents secrets unlock foo --host mac2 --durable parses fine and prints "mac2: unlocked", but the remote process never sees --durable — it falls back to whatever secrets.agent.durable is configured on the remote (default off). A user relying on the flag to make a remote unlock survive sleep gets silently downgraded to the non-durable default with no warning. Since this PR is what introduces --durable, this is a new gap rather than pre-existing scope. Not a regression or security issue (it fails toward the more conservative/less-durable behavior), so it doesn't block merge, but worth a one-line fix (...(opts.durable ? ['--durable'] : [])) either in this PR or a fast follow-up.

Things to verify manually

  • End-to-end macOS behavior (Touch ID unlock → daemon restart → headless read resolves silently, then sleep → default bundle re-locks but --durable survives) can't be exercised in this Linux sandbox — the KeychainBackend seam and unit tests cover the logic, but the real broker/keychain-helper interaction on macOS is worth a manual smoke test before the 1.20.71 release.
  • agents secrets status won't show a rehydrated session until the broker actually restarts and calls rehydrateStore() — confirmed this is how it's designed (rehydration happens on broker start, not on-demand from the session store directly), but worth a quick manual check that this doesn't read as "the unlock silently vanished" to a user checking status right after an upgrade before the broker has restarted.

Reviewed by Code Reviewer — actually ran the build and tests on this branch.

@muqsitnawaz
muqsitnawaz merged commit 2b62a3f into main Jul 20, 2026
8 of 12 checks passed
@muqsitnawaz
muqsitnawaz deleted the feat-durable-secrets-unlock branch July 20, 2026 06:18
muqsitnawaz added a commit that referenced this pull request Jul 20, 2026
buildRemoteUnlockArgs forwarded --all/--ttl but not --durable, so
`agents secrets unlock <b> --host <m> --durable` silently fell back to the
remote's secrets.agent.durable default (off). Forward the flag so a remote
unlock honors it. Completes the unreleased --durable feature (#1313); flagged by
the PR review. Fails safe today (toward less-durable), so it's a gap not a
regression.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.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