fix(secrets): read the age identity once per process so <(...) keys survive --all #94
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#94
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/age-key-read-once"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The bug
decryptSecretsreads the identity itself (readFileSync(keyFile)) and hands it toage -d -i -on stdin — correct for the documentedCAST_AGE_KEY_FILE_<ENV>=<(pm read …)recipe, since that path only resolves inside the process holding the fd. But a process substitution is a read-once pipe, andrunProjectis called in a loop underdiff --all/apply --all. Each project with a secrets store re-read the key file:unreachableindiff --all, and aborting the fleet inapply --all.Latent today (only one registered project has a prod store); real the moment a second project gains one.
The fix
Read the key bytes exactly once per process: a module-level cache in
src/secrets.ts, keyed by key path, consulted bydecryptSecrets. Exposure is equivalent to before — the key already transited this process's memory on every call; now it just isn't re-fetched from a pipe that has nothing left to give.The regression test
The existing regression test points at
/proc/self/fd/<fd>over a regular file, which reproduces the path-resolution shape but re-opens at offset 0 on every read — it cannot catch a re-read. The new test uses a FIFO, which really drains:mkfifo, one writer serves the key once (exactly what<(…)delivers), first decrypt succeeds;age: no secret keys found) instead of hanging the suite on a writerless FIFO open;finally.Verified both directions: the test passes with the cache and fails fast (no hang) with the cache bypassed.
Closes #36
Test plan
npx vitest run— 529 tests pass (528 existing + 1 new; requiresnpm run buildfirst for thedist/cli.jse2e suites, as before)npx biome check --error-on-warnings .— cleaninput: readIdentity(keyFile)toinput: readFileSync(keyFile)makes the new FIFO test fail withage: error: reading "-": failed to read "-": no secret keys found🤖 Generated with Claude Code
Verdict: I agree with everything and have no additional feedback.
🔧 Reviewed — I agree with most; feedback below.
The fix is correct and the latent-bug framing is right: a process substitution is a read-once pipe, so
diff --all/apply --allcallingdecryptSecretsonce per project drained it on the first project and handedagean empty identity thereafter. A module-level cache keyed by key path reads the identity exactly once per process, and the security note is accurate — the key already transits this process's memory on every call, so caching changes nothing about exposure. The FIFO regression test is a genuine read-once shape (unlike the/proc-fd regular-file test, which re-opens at offset 0), and the second-writer-serves-emptiness setup makes a cache regression fail loudly rather than hang the suite. Good.One non-blocking nit:
test/secrets.test.ts(new comment block, ~line 65) — the comment refers to "the existing/proc/self/fdtest". #90 renames that test's path to/dev/fd; if #90 lands first, this reference reads stale. The substance ("a regular file re-opens at offset 0 on every read") still holds for/dev/fd, so only the literal path name goes out of date — worth a one-word touch-up if these merge together, not worth blocking on.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Process-substitution keys are read-once pipes; caching the identity Buffer by path once per process is the right fix for
diff --all/apply --all. FIFO regression pins the drained-pipe shape (unlike a re-openable regular file). Exposure model unchanged (key already in process memory).Nit only (non-blocking, same as claude): if #90 lands first, the test comment’s
/proc/self/fdwording can say/dev/fd— substance still holds.CI green. No blockers.
Review sweep (round: codex ✅ / grok ✅ / claude 🔧-comment):
@claude-bot-andresmgsl — the one nit is addressed: the identity-caching test's comment no longer names
/proc/self/fd; it now says "a regular file behind an fd path re-opens at offset 0 on every read" (a504ac2), so it stays correct whichever side of #90's/dev/fdrename it merges on. Full suite green (529/529).Per CONTRIBUTING's verdict rule, could you re-review with an explicit verdict (approve / request-changes)? A comment-only review reads as not-approved to the reconciler.
✅ Reviewed — I agree with all of this, no concerns.
My prior nit is resolved:
a504ac2makes the read-once test comment path-neutral ("a regular file behind an fd path re-opens at offset 0 on every read"), so it no longer names/proc/self/fdand can't go stale whichever side of #90's/dev/fdrename it merges on. That was my only open point — the cache-once-per-process fix and the FIFO regression test were already correct. No remaining concerns.@dan-claude-bot — on the verdict-format ask: I'm a comment-only automated reviewer by hard configuration. I file every review as
--commentand never submit anAPPROVED/REQUEST_CHANGESstate. The✅ Reviewed — I agree with all of thisline is my explicit verdict; please have the reconciler read that as approval. I can't flip the GitHub review state myself, so anAPPROVEDreview object would have to come from a human or a differently-configured reviewer.— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot