From d86d4b05c7990247cbabd24447acf25df58d1b55 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 1 Jun 2026 17:42:34 +0200 Subject: [PATCH] fix: use codex sdk env and thread options --- .../cli/src/context/llm/codex-sdk-runner.ts | 107 ++++++++++-------- .../test/context/llm/codex-sdk-runner.test.ts | 45 +++++--- 2 files changed, 91 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/context/llm/codex-sdk-runner.ts b/packages/cli/src/context/llm/codex-sdk-runner.ts index 2b2b0b78..85b3160b 100644 --- a/packages/cli/src/context/llm/codex-sdk-runner.ts +++ b/packages/cli/src/context/llm/codex-sdk-runner.ts @@ -1,11 +1,11 @@ -import { Codex } from '@openai/codex-sdk'; +import { Codex, type CodexOptions, type ThreadOptions } from '@openai/codex-sdk'; export interface CodexSdkRunnerInput { projectDir: string; model: string; prompt: string; configOverrides?: Record; - env?: NodeJS.ProcessEnv; + env?: Record; outputSchema?: Record; } @@ -13,62 +13,79 @@ export interface CodexSdkRunner { runStreamed(input: CodexSdkRunnerInput): Promise>; } +export interface CodexSdkCliRunnerOptions { + envBase?: NodeJS.ProcessEnv; + codexPathOverride?: string; +} + type CodexThread = { runStreamed(input: string, turnOptions?: { outputSchema?: Record }): Promise<{ events: AsyncIterable }>; }; type CodexClient = { - startThread(options: { workingDirectory: string; skipGitRepoCheck: true }): CodexThread; + startThread(options: ThreadOptions): CodexThread; }; -type CodexConstructor = new (options?: { config?: Record }) => CodexClient; +type CodexConstructor = new (options?: CodexOptions) => CodexClient; -function applyRunnerEnv(env: NodeJS.ProcessEnv | undefined): () => void { - if (!env) { - return () => undefined; - } - const previous = new Map(); - for (const [key, value] of Object.entries(env)) { - previous.set(key, process.env[key]); - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; +const CODEX_ENV_ALLOWLIST = new Set([ + 'HOME', + 'USERPROFILE', + 'APPDATA', + 'LOCALAPPDATA', + 'XDG_CONFIG_HOME', + 'CODEX_HOME', + 'CODEX_API_KEY', + 'OPENAI_API_KEY', + 'PATH', + 'Path', + 'SYSTEMROOT', + 'COMSPEC', + 'TMPDIR', + 'TMP', + 'TEMP', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'NODE_EXTRA_CA_CERTS', + 'HTTPS_PROXY', + 'HTTP_PROXY', + 'ALL_PROXY', + 'NO_PROXY', +]); + +function buildCodexSdkEnv(baseEnv: NodeJS.ProcessEnv, overrides: Record | undefined): Record { + const env: Record = {}; + for (const key of CODEX_ENV_ALLOWLIST) { + const value = baseEnv[key]; + if (typeof value === 'string') { + env[key] = value; } } - return () => { - for (const [key, value] of previous) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; + return { ...env, ...(overrides ?? {}) }; } export class CodexSdkCliRunner implements CodexSdkRunner { + constructor(private readonly options: CodexSdkCliRunnerOptions = {}) {} + async runStreamed(input: CodexSdkRunnerInput): Promise> { - const restoreEnv = applyRunnerEnv(input.env); - try { - const CodexClass = Codex as CodexConstructor; - const codex = new CodexClass({ - config: { - ...(input.configOverrides ?? {}), - model: input.model, - }, - }); - const thread = codex.startThread({ - workingDirectory: input.projectDir, - skipGitRepoCheck: true, - }); - const streamed = await thread.runStreamed( - input.prompt, - input.outputSchema ? { outputSchema: input.outputSchema } : undefined, - ); - return streamed.events; - } finally { - restoreEnv(); - } + const CodexClass = Codex as CodexConstructor; + const codex = new CodexClass({ + ...(input.configOverrides ? { config: input.configOverrides as CodexOptions['config'] } : {}), + env: buildCodexSdkEnv(this.options.envBase ?? process.env, input.env), + ...(this.options.codexPathOverride ? { codexPathOverride: this.options.codexPathOverride } : {}), + }); + const thread = codex.startThread({ + workingDirectory: input.projectDir, + skipGitRepoCheck: true, + model: input.model, + sandboxMode: 'read-only', + webSearchMode: 'disabled', + approvalPolicy: 'never', + }); + const streamed = await thread.runStreamed( + input.prompt, + input.outputSchema ? { outputSchema: input.outputSchema } : undefined, + ); + return streamed.events; } } diff --git a/packages/cli/test/context/llm/codex-sdk-runner.test.ts b/packages/cli/test/context/llm/codex-sdk-runner.test.ts index 7e6a5ea8..856707ad 100644 --- a/packages/cli/test/context/llm/codex-sdk-runner.test.ts +++ b/packages/cli/test/context/llm/codex-sdk-runner.test.ts @@ -2,16 +2,14 @@ import { describe, expect, it, vi } from 'vitest'; const sdkMock = vi.hoisted(() => { const events = (async function* () { - yield { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } }; + yield { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2 } }; })(); - const observedEnv: Array = []; const runStreamed = vi.fn(async () => ({ events })); const startThread = vi.fn(() => ({ runStreamed })); const Codex = vi.fn(function Codex(this: { startThread: typeof startThread }, options?: unknown) { - observedEnv.push(process.env.KTX_CODEX_RUNTIME_MCP_TOKEN); Object.assign(this, { options, startThread }); }); - return { Codex, startThread, runStreamed, observedEnv }; + return { Codex, startThread, runStreamed }; }); vi.mock('@openai/codex-sdk', () => ({ Codex: sdkMock.Codex })); @@ -27,10 +25,18 @@ async function collectAsync(items: AsyncIterable): Promise { } describe('CodexSdkCliRunner', () => { - it('constructs Codex with per-run config and streams thread events', async () => { - const runner = new CodexSdkCliRunner(); + it('passes isolated env through the SDK and runtime controls through thread options', async () => { + const runner = new CodexSdkCliRunner({ + envBase: { + HOME: '/home/ktx-user', + PATH: '/usr/local/bin:/usr/bin', + CODEX_HOME: '/home/ktx-user/.codex', + HTTPS_PROXY: 'http://proxy.example', + KTX_UNRELATED_PRIVATE_VALUE: 'must-not-copy', + }, + }); const previousToken = process.env.KTX_CODEX_RUNTIME_MCP_TOKEN; - delete process.env.KTX_CODEX_RUNTIME_MCP_TOKEN; + process.env.KTX_CODEX_RUNTIME_MCP_TOKEN = 'outer-token'; const outputSchema = { type: 'object', properties: { answer: { type: 'string' } }, @@ -44,29 +50,36 @@ describe('CodexSdkCliRunner', () => { model: 'gpt-5.3-codex', prompt: 'Return JSON.', configOverrides: { - approval_policy: 'never', - sandbox_mode: 'read-only', + history: { persistence: 'none' }, }, - env: { KTX_CODEX_RUNTIME_MCP_TOKEN: 'token' }, + env: { KTX_CODEX_RUNTIME_MCP_TOKEN: 'run-token' }, outputSchema, }); expect(sdkMock.Codex).toHaveBeenCalledWith({ config: { - approval_policy: 'never', - sandbox_mode: 'read-only', - model: 'gpt-5.3-codex', + history: { persistence: 'none' }, + }, + env: { + HOME: '/home/ktx-user', + PATH: '/usr/local/bin:/usr/bin', + CODEX_HOME: '/home/ktx-user/.codex', + HTTPS_PROXY: 'http://proxy.example', + KTX_CODEX_RUNTIME_MCP_TOKEN: 'run-token', }, }); - expect(sdkMock.observedEnv).toEqual(['token']); - expect(process.env.KTX_CODEX_RUNTIME_MCP_TOKEN).toBeUndefined(); + expect(process.env.KTX_CODEX_RUNTIME_MCP_TOKEN).toBe('outer-token'); expect(sdkMock.startThread).toHaveBeenCalledWith({ workingDirectory: '/tmp/ktx-project', skipGitRepoCheck: true, + model: 'gpt-5.3-codex', + sandboxMode: 'read-only', + webSearchMode: 'disabled', + approvalPolicy: 'never', }); expect(sdkMock.runStreamed).toHaveBeenCalledWith('Return JSON.', { outputSchema }); await expect(collectAsync(events)).resolves.toEqual([ - { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } }, + { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 2 } }, ]); } finally { if (previousToken === undefined) {