Add raw config overrides to the TypeScript SDK (#38817)

## Why

Some TOML configuration, such as permission maps with literal path keys, cannot be represented safely through the SDK's structured dotted-key configuration.

## What changed

- Add `CodexOptions.configOverrides` for passing ordered `--config key=value` arguments to the Codex CLI unchanged.
- Apply raw overrides after structured `config` values, while preserving precedence for SDK-managed and thread-specific settings.
- Document raw permission-map configuration and cover literal keys, duplicate overrides, ordering, and resume behavior.

GitOrigin-RevId: 3b0f5824ba8cb77d0eecb11ed6f32ead8fc674f6
This commit is contained in:
Dane Schneider
2026-08-16 00:26:22 +00:00
committed by copyberry
parent b3cc217378
commit 5ba12929f8
7 changed files with 209 additions and 3 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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`.

View File

@@ -65,11 +65,13 @@ export class CodexExec {
private pathDirs: string[];
private envOverride?: Record<string, string>;
private configOverrides?: CodexConfigObject;
private rawConfigOverrides?: string[];
constructor(
executablePath: string | null = null,
env?: Record<string, string>,
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<string> {
@@ -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",

View File

@@ -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<typeof import("node:child_process")>("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();

View File

@@ -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,

View File

@@ -11,6 +11,7 @@ type CreateTestClientOptions = {
apiKey?: string;
baseUrl?: string;
config?: CodexConfigObject;
configOverrides?: string[];
env?: Record<string, string>;
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,
}),
};