diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index e11bb6e79..eb1622c88 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -19,6 +19,7 @@ describe("secret ref resolver", () => { let execProtocolV2ScriptPath = ""; let execMissingIdScriptPath = ""; let execInvalidJsonScriptPath = ""; + let execFastExitScriptPath = ""; const createCaseDir = async (label: string): Promise => { const dir = path.join(fixtureRoot, `${label}-${caseId++}`); @@ -68,6 +69,9 @@ describe("secret ref resolver", () => { ["#!/bin/sh", "printf 'not-json'"].join("\n"), 0o700, ); + + execFastExitScriptPath = path.join(sharedExecDir, "resolver-fast-exit.sh"); + await writeSecureFile(execFastExitScriptPath, ["#!/bin/sh", "exit 0"].join("\n"), 0o700); }); afterAll(async () => { @@ -174,6 +178,30 @@ describe("secret ref resolver", () => { expect(value).toBe("plain-secret"); }); + it("ignores EPIPE when exec provider exits before consuming stdin", async () => { + if (process.platform === "win32") { + return; + } + const oversizedId = `openai/${"x".repeat(120_000)}`; + await expect( + resolveSecretRefString( + { source: "exec", provider: "execmain", id: oversizedId }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: execFastExitScriptPath, + }, + }, + }, + }, + }, + ), + ).rejects.toThrow('Exec provider "execmain" returned empty stdout.'); + }); + it("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => { if (process.platform === "win32") { return; diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index 9d81486ac..6c34b58a0 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -308,6 +308,14 @@ type ExecRunResult = { termination: "exit" | "timeout" | "no-output-timeout"; }; +function isIgnorableStdinWriteError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) { + return false; + } + const code = String(error.code); + return code === "EPIPE" || code === "ERR_STREAM_DESTROYED"; +} + async function runExecResolver(params: { command: string; args: string[]; @@ -405,7 +413,20 @@ async function runExecResolver(params: { }); }); - child.stdin?.end(params.input); + const handleStdinError = (error: unknown) => { + if (isIgnorableStdinWriteError(error) || settled) { + return; + } + settled = true; + clearTimers(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + child.stdin?.on("error", handleStdinError); + try { + child.stdin?.end(params.input); + } catch (error) { + handleStdinError(error); + } }); }