diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 52d65c9edc8..973f2b14630 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -187,6 +187,40 @@ describe("shell env fallback", () => { expect(exec2).not.toHaveBeenCalled(); }); + it("reuses the cached login-shell env probe across repeated fallback reads", () => { + resetShellPathCacheForTests(); + const env: NodeJS.ProcessEnv = {}; + const exec = vi.fn(() => + Buffer.from("OPENAI_API_KEY=from-shell\0ANTHROPIC_API_KEY=from-shell-anthropic\0"), + ); + + expect( + loadShellEnvFallback({ + enabled: true, + env, + expectedKeys: ["OPENAI_API_KEY"], + exec: exec as unknown as Parameters[0]["exec"], + }), + ).toEqual({ + ok: true, + applied: ["OPENAI_API_KEY"], + }); + + expect( + loadShellEnvFallback({ + enabled: true, + env, + expectedKeys: ["ANTHROPIC_API_KEY"], + exec: exec as unknown as Parameters[0]["exec"], + }), + ).toEqual({ + ok: true, + applied: ["ANTHROPIC_API_KEY"], + }); + + expect(exec).toHaveBeenCalledTimes(1); + }); + it("tracks last applied keys across success, skip, and failure paths", () => { const successEnv: NodeJS.ProcessEnv = {}; const successExec = vi.fn(() => diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 820c11ea848..db9dbf47948 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -12,6 +12,9 @@ const DEFAULT_SHELL = "/bin/sh"; let lastAppliedKeys: string[] = []; let cachedShellPath: string | null | undefined; let cachedEtcShells: Set | null | undefined; +let nextExecCacheId = 1; +const loginShellEnvProbeCache = new Map>(); +const execCacheIds = new WeakMap(); function resolveShellExecEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const execEnv = sanitizeHostExecEnv({ baseEnv: env }); @@ -111,6 +114,52 @@ function parseShellEnv(stdout: Buffer): Map { return shellEnv; } +function resolveExecCacheId(exec: typeof execFileSync | undefined): string { + if (!exec) { + return "default"; + } + const key = exec as object; + let id = execCacheIds.get(key); + if (!id) { + id = nextExecCacheId; + nextExecCacheId += 1; + execCacheIds.set(key, id); + } + return `exec:${id}`; +} + +function createLoginShellEnvCacheKey(params: { + shell: string; + timeoutMs: number; + exec?: typeof execFileSync; + execEnv: NodeJS.ProcessEnv; +}): string { + const startupEnvEntries = Object.entries(params.execEnv) + .filter(([key]) => { + if ( + key === "HOME" || + key === "PATH" || + key === "TERM" || + key === "LANG" || + key === "LC_ALL" || + key === "LC_CTYPE" || + key === "USER" || + key === "LOGNAME" || + key === "TMPDIR" + ) { + return true; + } + return key.startsWith("XDG_") || key.startsWith("OPENCLAW_"); + }) + .toSorted(([left], [right]) => left.localeCompare(right)); + return JSON.stringify([ + params.shell, + params.timeoutMs, + resolveExecCacheId(params.exec), + startupEnvEntries, + ]); +} + type LoginShellEnvProbeResult = | { ok: true; shellEnv: Map } | { ok: false; error: string }; @@ -124,10 +173,22 @@ function probeLoginShellEnv(params: { const timeoutMs = resolveTimeoutMs(params.timeoutMs); const shell = resolveShell(params.env); const execEnv = resolveShellExecEnv(params.env); + const cacheKey = createLoginShellEnvCacheKey({ + shell, + timeoutMs, + exec: params.exec, + execEnv, + }); + const cached = loginShellEnvProbeCache.get(cacheKey); + if (cached) { + return { ok: true, shellEnv: new Map(cached) }; + } try { const stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); - return { ok: true, shellEnv: parseShellEnv(stdout) }; + const shellEnv = parseShellEnv(stdout); + loginShellEnvProbeCache.set(cacheKey, [...shellEnv.entries()]); + return { ok: true, shellEnv }; } catch (err) { return { ok: false, error: formatErrorMessage(err) }; } @@ -242,6 +303,8 @@ export function getShellPathFromLoginShell(opts: { export function resetShellPathCacheForTests(): void { cachedShellPath = undefined; cachedEtcShells = undefined; + loginShellEnvProbeCache.clear(); + nextExecCacheId = 1; } export function getShellEnvAppliedKeys(): string[] {