diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index afbbde57e5..6ac29c3b48 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -146,4 +146,15 @@ const codex = new Codex({ }); ``` -Thread options still take precedence for overlapping settings because they are emitted after these global overrides. +For configuration keys that cannot be expressed as dotted paths, pass raw TOML overrides with `configOverrides`. Each entry +is forwarded unchanged as a separate `--config` argument, without modifying `CODEX_HOME`: + +```typescript +const codex = new Codex({ + config: { default_permissions: "audit" }, + configOverrides: ['permissions.audit.filesystem={":root"="read","/path/to/project/.env"="deny"}'], +}); +``` + +Raw overrides are applied after structured `config` overrides and take precedence over them. SDK-managed settings, such as +`baseUrl`, and thread-specific options are applied afterward and take precedence. diff --git a/sdk/typescript/src/codex.ts b/sdk/typescript/src/codex.ts index e3ce4aa0de..c90e935987 100644 --- a/sdk/typescript/src/codex.ts +++ b/sdk/typescript/src/codex.ts @@ -13,8 +13,8 @@ export class Codex { private options: CodexOptions; constructor(options: CodexOptions = {}) { - const { codexPathOverride, env, config } = options; - this.exec = new CodexExec(codexPathOverride, env, config); + const { codexPathOverride, env, config, configOverrides } = options; + this.exec = new CodexExec(codexPathOverride, env, config, configOverrides); this.options = options; } diff --git a/sdk/typescript/src/codexOptions.ts b/sdk/typescript/src/codexOptions.ts index b6abb94b79..caa731ed95 100644 --- a/sdk/typescript/src/codexOptions.ts +++ b/sdk/typescript/src/codexOptions.ts @@ -14,6 +14,11 @@ export type CodexOptions = { * `--config` parsing. */ config?: CodexConfigObject; + /** + * Raw `--config key=value` overrides to pass unchanged to the Codex CLI after + * structured configuration and before SDK-managed or thread-specific overrides. + */ + configOverrides?: string[]; /** * Environment variables passed to the Codex CLI process. When provided, the SDK * will not inherit variables from `process.env`. diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 26741bcc31..e7120bb45e 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -65,11 +65,13 @@ export class CodexExec { private pathDirs: string[]; private envOverride?: Record; private configOverrides?: CodexConfigObject; + private rawConfigOverrides?: string[]; constructor( executablePath: string | null = null, env?: Record, configOverrides?: CodexConfigObject, + rawConfigOverrides?: string[], ) { if (executablePath) { this.executablePath = executablePath; @@ -81,6 +83,7 @@ export class CodexExec { } this.envOverride = env; this.configOverrides = configOverrides; + this.rawConfigOverrides = rawConfigOverrides; } async *run(args: CodexExecArgs): AsyncGenerator { @@ -92,6 +95,12 @@ export class CodexExec { } } + if (this.rawConfigOverrides) { + for (const override of this.rawConfigOverrides) { + commandArgs.push("--config", override); + } + } + if (args.baseUrl) { commandArgs.push( "--config", diff --git a/sdk/typescript/tests/exec.test.ts b/sdk/typescript/tests/exec.test.ts index eb068f3f1a..24965252a1 100644 --- a/sdk/typescript/tests/exec.test.ts +++ b/sdk/typescript/tests/exec.test.ts @@ -7,6 +7,8 @@ import { PassThrough } from "node:stream"; import { describe, expect, it } from "@jest/globals"; +import type { CodexConfigObject } from "../src/codexOptions"; + jest.mock("node:child_process", () => { const actual = jest.requireActual("node:child_process"); return { ...actual, spawn: jest.fn() }; @@ -97,6 +99,142 @@ describe("CodexExec", () => { expect(resumeIndex).toBeLessThan(imageIndex); }); + const configOverrideCases: { + name: string; + config?: CodexConfigObject; + configOverrides?: string[]; + expectedOverrides: string[]; + }[] = [ + { + name: "ordinary and dotted structured keys without changing their meaning", + config: { + model_providers: { "mock.name": "Mock provider" }, + "features.shell_snapshot": false, + features: { plugins: false }, + sandbox_workspace_write: { network_access: true }, + }, + expectedOverrides: [ + 'model_providers.mock.name="Mock provider"', + "features.shell_snapshot=false", + "features.plugins=false", + "sandbox_workspace_write.network_access=true", + ], + }, + { + name: "raw inline filesystem permissions without altering literal map keys", + configOverrides: [ + 'permissions.worker.filesystem={glob_scan_max_depth=4,":root"="read",":workspace_roots"="read","/repo/.env"="deny","/repo/**/*.pem"="deny","/repo/with spaces/.env"="deny","C:\\\\repo\\\\secret.env"="deny"}', + ], + expectedOverrides: [ + 'permissions.worker.filesystem={glob_scan_max_depth=4,":root"="read",":workspace_roots"="read","/repo/.env"="deny","/repo/**/*.pem"="deny","/repo/with spaces/.env"="deny","C:\\\\repo\\\\secret.env"="deny"}', + ], + }, + { + name: "raw profile names containing periods and spaces", + configOverrides: [ + 'permissions={"scan.profile with spaces"={filesystem={":root"="read","/repo/.env"="deny"}}}', + ], + expectedOverrides: [ + 'permissions={"scan.profile with spaces"={filesystem={":root"="read","/repo/.env"="deny"}}}', + ], + }, + { + name: "structured overrides before ordered raw overrides and duplicates", + config: { approval_policy: "never", features: { plugins: false } }, + configOverrides: ['approval_policy="on-failure"', 'approval_policy="on-request"'], + expectedOverrides: [ + 'approval_policy="never"', + "features.plugins=false", + 'approval_policy="on-failure"', + 'approval_policy="on-request"', + ], + }, + { + name: "an empty raw override list without changing structured configuration", + config: { retry_budget: 3 }, + configOverrides: [], + expectedOverrides: ["retry_budget=3"], + }, + ]; + + it.each(configOverrideCases)( + "passes $name to the Codex CLI", + async ({ config, configOverrides, expectedOverrides }) => { + const { CodexExec } = await import("../src/exec"); + spawnMock.mockClear(); + const child = new FakeChildProcess(); + spawnMock.mockReturnValue(child as unknown as child_process.ChildProcess); + + setImmediate(() => { + child.stdout.end(); + child.stderr.end(); + child.emit("exit", 0, null); + }); + + const exec = new CodexExec("codex", undefined, config, configOverrides); + for await (const _ of exec.run({ input: "hi" })) { + // no-op + } + + const commandArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined; + expect(commandArgs).toEqual([ + "exec", + "--experimental-json", + ...expectedOverrides.flatMap((override) => ["--config", override]), + ]); + }, + ); + + it("lets SDK-managed and thread settings override raw configuration when resuming", async () => { + const { CodexExec } = await import("../src/exec"); + spawnMock.mockClear(); + const child = new FakeChildProcess(); + spawnMock.mockReturnValue(child as unknown as child_process.ChildProcess); + + setImmediate(() => { + child.stdout.end(); + child.stderr.end(); + child.emit("exit", 0, null); + }); + + const exec = new CodexExec("codex", undefined, { approval_policy: "never" }, [ + 'approval_policy="on-failure"', + 'openai_base_url="https://raw.example.test"', + "sandbox_workspace_write={network_access=true}", + ]); + for await (const _ of exec.run({ + input: "resume with overrides", + threadId: "thread-id", + baseUrl: "https://managed.example.test", + approvalPolicy: "on-request", + networkAccessEnabled: false, + })) { + // no-op + } + + const commandArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined; + expect(commandArgs).toEqual([ + "exec", + "--experimental-json", + "--config", + 'approval_policy="never"', + "--config", + 'approval_policy="on-failure"', + "--config", + 'openai_base_url="https://raw.example.test"', + "--config", + "sandbox_workspace_write={network_access=true}", + "--config", + 'openai_base_url="https://managed.example.test"', + "--config", + "sandbox_workspace_write.network_access=false", + "--config", + 'approval_policy="on-request"', + "resume", + "thread-id", + ]); + }); + it("allows overriding the env passed to the Codex CLI", async () => { const { CodexExec } = await import("../src/exec"); spawnMock.mockClear(); diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index 86b57bc7ee..a359513398 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -487,6 +487,47 @@ describe("Codex", () => { } }); + it("passes raw permission maps unchanged while preserving override precedence", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Raw config overrides applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const deniedPath = path.join(os.tmpdir(), "codex-sdk-config.env"); + const permissionOverride = `permissions.sdk_test.filesystem={":root"="read",${JSON.stringify(deniedPath)}="deny"}`; + const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createTestClient({ + baseUrl: url, + apiKey: "test", + config: { approval_policy: "never", default_permissions: "sdk_test" }, + configOverrides: [permissionOverride, 'approval_policy="on-failure"'], + }); + + try { + const thread = client.startThread({ approvalPolicy: "on-request" }); + await thread.run("apply raw config overrides"); + + const commandArgs = spawnArgs[0]; + expectPair(commandArgs, ["--config", permissionOverride]); + expectPair(commandArgs, ["--config", 'default_permissions="sdk_test"']); + expect(collectConfigValues(commandArgs, "approval_policy")).toEqual([ + 'approval_policy="never"', + 'approval_policy="on-failure"', + 'approval_policy="on-request"', + ]); + } finally { + cleanup(); + restore(); + await close(); + } + }); + it("passes additionalDirectories as repeated flags", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200, diff --git a/sdk/typescript/tests/testCodex.ts b/sdk/typescript/tests/testCodex.ts index a95a685165..5d7b1bc4a4 100644 --- a/sdk/typescript/tests/testCodex.ts +++ b/sdk/typescript/tests/testCodex.ts @@ -11,6 +11,7 @@ type CreateTestClientOptions = { apiKey?: string; baseUrl?: string; config?: CodexConfigObject; + configOverrides?: string[]; env?: Record; inheritEnv?: boolean; }; @@ -47,6 +48,7 @@ export function createTestClient(options: CreateTestClientOptions = {}): TestCli baseUrl: options.baseUrl, apiKey: options.apiKey, config: mergeTestConfig(options.baseUrl, options.config), + configOverrides: options.configOverrides, env, }), };