fix: use codex sdk env and thread options

This commit is contained in:
Andrey Avtomonov 2026-06-01 17:42:34 +02:00
parent f07f3f320b
commit d86d4b05c7
2 changed files with 91 additions and 61 deletions

View file

@ -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<string, unknown>;
env?: NodeJS.ProcessEnv;
env?: Record<string, string>;
outputSchema?: Record<string, unknown>;
}
@ -13,62 +13,79 @@ export interface CodexSdkRunner {
runStreamed(input: CodexSdkRunnerInput): Promise<AsyncIterable<unknown>>;
}
export interface CodexSdkCliRunnerOptions {
envBase?: NodeJS.ProcessEnv;
codexPathOverride?: string;
}
type CodexThread = {
runStreamed(input: string, turnOptions?: { outputSchema?: Record<string, unknown> }): Promise<{ events: AsyncIterable<unknown> }>;
};
type CodexClient = {
startThread(options: { workingDirectory: string; skipGitRepoCheck: true }): CodexThread;
startThread(options: ThreadOptions): CodexThread;
};
type CodexConstructor = new (options?: { config?: Record<string, unknown> }) => CodexClient;
type CodexConstructor = new (options?: CodexOptions) => CodexClient;
function applyRunnerEnv(env: NodeJS.ProcessEnv | undefined): () => void {
if (!env) {
return () => undefined;
}
const previous = new Map<string, string | undefined>();
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<string, string> | undefined): Record<string, string> {
const env: Record<string, string> = {};
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<AsyncIterable<unknown>> {
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;
}
}

View file

@ -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<string | undefined> = [];
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<T>(items: AsyncIterable<T>): Promise<T[]> {
}
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) {