fix: CAST_AGE_KEY_FILE_<ENV> maps to a name a shell can set #105
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#105
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/age-key-env-name"
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?
Fixes #102.
What
keyFileForderived the injected-key variable asCAST_AGE_KEY_FILE_${envName.toUpperCase()}(src/secrets.ts:82). For envdrill-bthat advertisesCAST_AGE_KEY_FILE_DRILL-B— a name no POSIX shell can export — so the per-invocation channel, including the<(pm read …)process-substitution pattern the README teaches, was a wall for every hyphenated environment name. Found live in the 2026-07-19 release drill (two real Coolify instances; envdrill-bhad to fall back to a standing key on disk).How
New
ageKeyVarFor(envName): uppercase, then map every character outside[A-Z0-9]to_— envdrill-breadsCAST_AGE_KEY_FILE_DRILL_B. The refusal message advertises the mapped name. The standing-key path (~/.config/cast/age-<env>.key) keeps the exact environment name, so two env names that collide on the variable (drill-b/drill.b) still resolve their own keys on disk — the collision costs nothing.README documents the mapping; CHANGELOG carries the story.
Tests
$HOMEisolated so a dev machine's real standing key can't fake a pass — that bit me on the first run)npm test: 598 passed (33 files) ·npm run checkclean.🤖 Generated with Claude Code
✅ Reviewed — I agree with all of this, no concerns.
ageKeyVarForis the right factoring — one function owns the var name, sokeyFileForand the refusal message can't drift, and.replace(/[^A-Z0-9]/g, "_")after uppercasing is the correct POSIX-safe mapping. Deliberately keeping the exact env name on the standing-key path (sodrill-b/drill.bcollide on the var but still resolve their own keys on disk) is the right call, and it's documented. Tests cover both the hit through the mapped var and the refusal advertising the mapped name — with$HOMEisolated so a dev machine's standing key can't fake a pass. README updated. Clean.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botApprove — the fix is correct, minimal, centralized in one function, and the edge cases I probed all hold.
Verified beyond the diff:
src/secrets.ts:100— uppercase-then-replace(/[^A-Z0-9]/g, "_")is the right order (the class is uppercase-only, so replacing first would have eaten lowercase letters), and it is the identity mapping for every previously settable name, so nothing existing breaks. Leading digits can't produce an invalid name thanks to theCAST_AGE_KEY_FILE_prefix.ageKeyVarFor), so the lookup atsrc/secrets.ts:82and the refusal atsrc/secrets.ts:87can't drift. README (README.md:604-609) documents the mapping right next to the<(pm read …)example it unblocks.drill-b/drill.b→ same var) is real but benign — a mismatched injected key fails age decryption loudly rather than decrypting the wrong env — and it's documented in the code comment and CHANGELOG. Keeping the exact env name on the standing-key path is the right call.test/secrets.test.ts:122-135— isolating$HOMEso a dev machine's real standing key can't turn the refusal test into a false pass is a nice catch.Ran locally at 9e10a89:
npm run build+npm test→ 598/598 passed;npm run checkclean.One optional nit: the new tests exercise only the hyphen case; a dot or mixed-case env name rides the same regex so I don't consider it blocking.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Why
ageKeyVarForis the right single owner of the variable name:keyFileForand the refusal message cannot drift, which is exactly how the old bug stayed invisible (refusal advertised a name no shell can set).[^A-Z0-9]→_turnsdrill-bintoCAST_AGE_KEY_FILE_DRILL_B, which POSIXexportcan set (including process-substitution injection).age-drill-b.key), so a var-name collision (drill-b/drill.b) does not merge identities on disk — the collision is confined to the injected channel, and the PR states that honestly.$HOMEisolation, so a developer standing key cannot fake a pass.Checked
toUpperCase()-only name.No blockers.
Verdict: I agree with everything and have no additional feedback.
The environment-variable mapping is centralized, preserves existing alphanumeric names, produces a shell-settable name for hyphenated environments, keeps exact environment names for standing-key paths, and is covered by lookup and refusal tests. Current checks are green.
@danmt — handing this one to you. Final state:
What it fixes: cast#102 — the injected-key variable was derived as
CAST_AGE_KEY_FILE_${envName.toUpperCase()}, so envdrill-badvertisedCAST_AGE_KEY_FILE_DRILL-B: a name no POSIX shell can export, which walled off the per-invocation key channel (including the<(pm read …)process-substitution pattern the README teaches) for every hyphenated environment name. Found live in the release drill's two-instance run.The fix: one function (
ageKeyVarFor) now owns the mapping — uppercase, then[^A-Z0-9] → _— so the lookup and the refusal message can't drift; envdrill-breadsCAST_AGE_KEY_FILE_DRILL_B. The standing-key path keeps the exact env name, so names that collide on the variable (drill-b/drill.b) still resolve their own keys on disk. README documents the mapping.Review history: one round — all three bots approved, no changes requested.
Verification:
npm test598/0 · biome clean. New tests cover the mapped-var hit and the refusal advertising the mapped name, with$HOMEisolated so a dev machine's real standing key can't fake a pass.No open questions from the round. Ready for your review.
🤖 Generated with Claude Code