mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
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:
committed by
copyberry
parent
b3cc217378
commit
5ba12929f8
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user