From 936650001fb53c86a52e20413a35a42cfa640363 Mon Sep 17 00:00:00 2001 From: Ryan Lopopolo Date: Fri, 14 Nov 2025 11:44:19 -0800 Subject: [PATCH] feat(ts-sdk): allow overriding CLI environment (#6648) ## Summary - add an `env` option for the TypeScript Codex client and plumb it into `CodexExec` so the CLI can run without inheriting `process.env` - extend the test spy to capture spawn environments, add coverage for the new option, and document how to use it ## Testing - `pnpm test` *(fails: corepack cannot download pnpm because outbound network access is blocked in the sandbox)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_6916b2d7c7548322a72d61d91a2dac85) --- sdk/typescript/README.md | 16 +++++++++++ sdk/typescript/src/codex.ts | 2 +- sdk/typescript/src/codexOptions.ts | 5 ++++ sdk/typescript/src/exec.ts | 18 +++++++++--- sdk/typescript/tests/codexExecSpy.ts | 10 ++++++- sdk/typescript/tests/run.test.ts | 43 ++++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 967beed11..09e8a513d 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -115,3 +115,19 @@ const thread = codex.startThread({ skipGitRepoCheck: true, }); ``` + +### Controlling the Codex CLI environment + +By default, the Codex CLI inherits the Node.js process environment. Provide the optional `env` parameter when instantiating the +`Codex` client to fully control which variables the CLI receives—useful for sandboxed hosts like Electron apps. + +```typescript +const codex = new Codex({ + env: { + PATH: "/usr/local/bin", + }, +}); +``` + +The SDK still injects its required variables (such as `OPENAI_BASE_URL` and `CODEX_API_KEY`) on top of the environment you +provide. diff --git a/sdk/typescript/src/codex.ts b/sdk/typescript/src/codex.ts index 84376e677..a42159232 100644 --- a/sdk/typescript/src/codex.ts +++ b/sdk/typescript/src/codex.ts @@ -13,7 +13,7 @@ export class Codex { private options: CodexOptions; constructor(options: CodexOptions = {}) { - this.exec = new CodexExec(options.codexPathOverride); + this.exec = new CodexExec(options.codexPathOverride, options.env); this.options = options; } diff --git a/sdk/typescript/src/codexOptions.ts b/sdk/typescript/src/codexOptions.ts index 2d22bcf22..31fb637d4 100644 --- a/sdk/typescript/src/codexOptions.ts +++ b/sdk/typescript/src/codexOptions.ts @@ -2,4 +2,9 @@ export type CodexOptions = { codexPathOverride?: string; baseUrl?: string; apiKey?: string; + /** + * Environment variables passed to the Codex CLI process. When provided, the SDK + * will not inherit variables from `process.env`. + */ + env?: Record; }; diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 3c47c5572..fb7ed54ad 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -41,8 +41,11 @@ const TYPESCRIPT_SDK_ORIGINATOR = "codex_sdk_ts"; export class CodexExec { private executablePath: string; - constructor(executablePath: string | null = null) { + private envOverride?: Record; + + constructor(executablePath: string | null = null, env?: Record) { this.executablePath = executablePath || findCodexPath(); + this.envOverride = env; } async *run(args: CodexExecArgs): AsyncGenerator { @@ -103,9 +106,16 @@ export class CodexExec { commandArgs.push("resume", args.threadId); } - const env = { - ...process.env, - }; + const env: Record = {}; + if (this.envOverride) { + Object.assign(env, this.envOverride); + } else { + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value; + } + } + } if (!env[INTERNAL_ORIGINATOR_ENV]) { env[INTERNAL_ORIGINATOR_ENV] = TYPESCRIPT_SDK_ORIGINATOR; } diff --git a/sdk/typescript/tests/codexExecSpy.ts b/sdk/typescript/tests/codexExecSpy.ts index bf7cbd6cc..81092b742 100644 --- a/sdk/typescript/tests/codexExecSpy.ts +++ b/sdk/typescript/tests/codexExecSpy.ts @@ -9,18 +9,26 @@ const actualChildProcess = jest.requireActual("node:child_process"); const spawnMock = child_process.spawn as jest.MockedFunction; -export function codexExecSpy(): { args: string[][]; restore: () => void } { +export function codexExecSpy(): { + args: string[][]; + envs: (Record | undefined)[]; + restore: () => void; +} { const previousImplementation = spawnMock.getMockImplementation() ?? actualChildProcess.spawn; const args: string[][] = []; + const envs: (Record | undefined)[] = []; spawnMock.mockImplementation(((...spawnArgs: Parameters) => { const commandArgs = spawnArgs[1]; args.push(Array.isArray(commandArgs) ? [...commandArgs] : []); + const options = spawnArgs[2] as child_process.SpawnOptions | undefined; + envs.push(options?.env as Record | undefined); return previousImplementation(...spawnArgs); }) as typeof actualChildProcess.spawn); return { args, + envs, restore: () => { spawnMock.mockClear(); spawnMock.mockImplementation(previousImplementation); diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index 586e396a3..fcd9fea83 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -348,6 +348,49 @@ describe("Codex", () => { } }); + it("allows overriding the env passed to the Codex CLI", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Custom env", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { envs: spawnEnvs, restore } = codexExecSpy(); + process.env.CODEX_ENV_SHOULD_NOT_LEAK = "leak"; + + try { + const client = new Codex({ + codexPathOverride: codexExecPath, + baseUrl: url, + apiKey: "test", + env: { CUSTOM_ENV: "custom" }, + }); + + const thread = client.startThread(); + await thread.run("custom env"); + + const spawnEnv = spawnEnvs[0]; + expect(spawnEnv).toBeDefined(); + if (!spawnEnv) { + throw new Error("Spawn env missing"); + } + expect(spawnEnv.CUSTOM_ENV).toBe("custom"); + expect(spawnEnv.CODEX_ENV_SHOULD_NOT_LEAK).toBeUndefined(); + expect(spawnEnv.OPENAI_BASE_URL).toBe(url); + expect(spawnEnv.CODEX_API_KEY).toBe("test"); + expect(spawnEnv.CODEX_INTERNAL_ORIGINATOR_OVERRIDE).toBeDefined(); + } finally { + delete process.env.CODEX_ENV_SHOULD_NOT_LEAK; + restore(); + await close(); + } + }); + it("passes additionalDirectories as repeated flags", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200,