From 8c396736c8a8e176a3cd3430c5643dcb02e43b1b Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Thu, 16 Jul 2026 18:27:53 +0000 Subject: [PATCH 1/2] fix(secrets): read the age identity once per process so <(...) keys survive --all (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A process substitution (`CAST_AGE_KEY_FILE_=<(pm read ...)`) is a read-once pipe, but `diff --all` / `apply --all` call decryptSecrets once per project. The first project drained the pipe; every later project re-read the key file, handed age an empty identity, and failed — the fleet loop then misreported the project as unreachable (diff) or aborted the fleet (apply). Latent today because only one registered project has a prod store; real the moment a second one gains one. Cache the key bytes by key path, module-level, so the identity is read exactly once per process. Exposure is unchanged: the key already transits this process's memory on every call. The regression test uses a FIFO, which really drains — unlike the existing /proc/self/fd test, whose regular file re-opens at offset 0 on every read. A second writer serves emptiness after the first decrypt so a regression fails loudly (age: no secret keys found) instead of blocking the suite on a writerless FIFO open. Co-Authored-By: Claude Fable 5 --- src/secrets.ts | 20 +++++++++++++++++++- test/secrets.test.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/secrets.ts b/src/secrets.ts index 064ffcd..6556d6f 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -9,12 +9,30 @@ import { join } from "node:path"; // holding the fd — this one. A freshly spawned age has no such fd and fails // with ENOENT. Not `-i /dev/stdin` either: node closes the pipe before age // re-opens it by path (ENXIO); `-` makes age read the inherited fd directly. +// +// And it is read exactly ONCE per process: a process substitution is a +// read-once pipe, but `diff --all` / `apply --all` call decryptSecrets once +// per project. The first read drains the pipe; a re-read would hand age an +// empty identity and the second project's decrypt would fail. Caching by key +// path changes nothing about exposure — the key already transits this +// process's memory on every call. +const identities = new Map(); + +function readIdentity(keyFile: string): Buffer { + let identity = identities.get(keyFile); + if (identity === undefined) { + identity = readFileSync(keyFile); + identities.set(keyFile, identity); + } + return identity; +} + export function decryptSecrets( file: string, keyFile: string, ): Record { const out = execFileSync("age", ["-d", "-i", "-", file], { - input: readFileSync(keyFile), + input: readIdentity(keyFile), encoding: "utf8", }); const secrets: Record = {}; diff --git a/test/secrets.test.ts b/test/secrets.test.ts index 5dd2a0b..64cbe18 100644 --- a/test/secrets.test.ts +++ b/test/secrets.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { closeSync, mkdirSync, @@ -62,6 +62,38 @@ describe("secretsFileFor", () => { }); }); +// Pins the read-once shape of `<(pm read …)` that the fd-path test above +// cannot: a regular file behind /proc/self/fd re-opens at offset 0 on every +// read, but a pipe drains. `diff --all` / `apply --all` decrypt once per +// project, so the identity must be read once per process and reused. +describe("decryptSecrets identity caching", () => { + it("a read-once pipe key survives two decrypts — the --all loop shape", () => { + const { keyFile, enc } = ageFixture(); + const dir = mkdtempSync(join(tmpdir(), "infra-fifo-")); + const fifo = join(dir, "key.fifo"); + execFileSync("mkfifo", [fifo]); + // One writer, one serving of the key: exactly what a process substitution + // delivers. It pairs with the first decrypt's open and exits. + const once = spawn("sh", ["-c", `cat "${keyFile}" > "${fifo}"`], { + stdio: "ignore", + }); + const expected = { MAILGUN_KEY: "mk-123", OPENROUTER_KEY: "or-456" }; + expect(decryptSecrets(enc, fifo)).toEqual(expected); + // The pipe is now drained. A second writer serves nothing, so if the + // per-process cache ever regresses, the re-read hands age an empty + // identity and fails loudly instead of blocking the suite on a + // writerless FIFO open. With the cache, nobody opens the FIFO again and + // the writer is still blocked in open() when we kill it. + const drained = spawn("sh", ["-c", `: > "${fifo}"`], { stdio: "ignore" }); + try { + expect(decryptSecrets(enc, fifo)).toEqual(expected); + } finally { + once.kill("SIGKILL"); + drained.kill("SIGKILL"); + } + }); +}); + describe("keyFileFor", () => { it("an env with no injected var and no standing key refuses, naming both ways in", () => { Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD"); From a504ac2b54512dc3761cdf04c7d42b48a8de3508 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 19:22:16 +0000 Subject: [PATCH 2/2] test(secrets): make the read-once comment path-neutral so #90's /dev/fd rename cannot stale it Co-Authored-By: Claude Fable 5 --- test/secrets.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/secrets.test.ts b/test/secrets.test.ts index 64cbe18..9f7a2f3 100644 --- a/test/secrets.test.ts +++ b/test/secrets.test.ts @@ -63,7 +63,7 @@ describe("secretsFileFor", () => { }); // Pins the read-once shape of `<(pm read …)` that the fd-path test above -// cannot: a regular file behind /proc/self/fd re-opens at offset 0 on every +// cannot: a regular file behind an fd path re-opens at offset 0 on every // read, but a pipe drains. `diff --all` / `apply --all` decrypt once per // project, so the identity must be read once per process and reused. describe("decryptSecrets identity caching", () => {