From 3d35b3c8c8a584222d8edc8b98f8e9ef4b2738d7 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 11 Aug 2026 09:39:01 -0700 Subject: [PATCH 1/3] fix(sdk): keep API-key scan sessions in the persistent Codex home --- sdk/typescript/src/api.ts | 49 ++----- sdk/typescript/tests-ts/api.test.ts | 217 +++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 40 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index cb90e089..28bb7a1a 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -467,10 +467,7 @@ export class CodexSecurity { options.auth, modelProvider, ); - if ( - authentication.method === "stored_credentials" && - this.#dependencies.prepareRuntime === undefined - ) { + if (this.#dependencies.prepareRuntime === undefined) { const credentialHome = await prepareCodexSecurityCredentialHome( scanEnvironment, (path) => @@ -572,6 +569,10 @@ export class CodexSecurity { options.auth, modelProvider, ); + if (authentication.method !== "stored_credentials") { + await releaseCredentialHome?.(); + releaseCredentialHome = null; + } notifyObserver( "onAuthentication", options.onAuthentication, @@ -941,12 +942,15 @@ export class CodexSecurity { CODEX_HOME: runtime.codexHome, ...runtimePaths, }; + const sdkCodexConfig = scanPreflightCodexConfig(effectiveConfig); + delete sdkCodexConfig["projects"]; const codex = this.#dependencies.createCodex({ ...(externalProvider !== null || apiKey === null ? {} : { apiKey }), env: definedEnvironment( selectedScanEnvironment(environment, "chatgpt"), ), config: { + ...(sdkCodexConfig as NonNullable), default_permissions: SCAN_PERMISSION_PROFILE, allow_login_shell: false, }, @@ -1379,23 +1383,7 @@ export class CodexSecurity { modelProvider?: unknown, ): Promise { this.#requireOpen(); - if (this.#runtime !== null) { - const usePersistentCredentials = - scanAuthentication(this.#dependencies.environment, auth, modelProvider) - .method === "stored_credentials"; - if ( - this.#dependencies.prepareRuntime !== undefined || - this.#runtime.persistentCredentialHome === undefined || - this.#runtime.persistentCredentialHome === usePersistentCredentials - ) { - return this.#runtime; - } - await this.#cleanupRuntime(this.#runtime); - this.#runtime = null; - this.#runtimePromise = null; - this.#runtimeCredentialSource = null; - this.#requireOpen(); - } + if (this.#runtime !== null) return this.#runtime; if (this.#runtimePromise === null) { const runtimePromise = this.#prepareRuntime( signal ?? this.#abortController.signal, @@ -1522,15 +1510,10 @@ export class CodexSecurity { auth, modelProvider, ); - const persistentCredentialHome = - scanAuthentication(this.#dependencies.environment, auth, modelProvider) - .method === "stored_credentials"; - const codexHome = persistentCredentialHome - ? await prepareCodexSecurityCredentialHome( - processEnvironment, - validateLocation, - ) - : await createIsolatedHome(temporaryRoot, validateLocation); + const codexHome = await prepareCodexSecurityCredentialHome( + processEnvironment, + validateLocation, + ); let bootstrapWorkspace: string | undefined; try { throwIfAborted(signal); @@ -1555,7 +1538,7 @@ export class CodexSecurity { scanRuntimeCodexConfig( mergedConfig, codexSecurityStateDirectory(processEnvironment), - persistentCredentialHome ? codexHome : undefined, + codexHome, ), ); await writeCodexConfig(join(codexHome, "config.toml"), codexConfig); @@ -1580,7 +1563,7 @@ export class CodexSecurity { ); return { codexHome, - persistentCredentialHome, + persistentCredentialHome: true, bootstrapWorkspace, configPath, plugin, @@ -1595,7 +1578,7 @@ export class CodexSecurity { }; } catch (error) { const cleanupResults = await Promise.allSettled( - [bootstrapWorkspace, persistentCredentialHome ? undefined : codexHome] + [bootstrapWorkspace] .filter((path): path is string => path !== undefined) .map((path) => cleanupSdkDirectory(path)), ); diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index ec8076ae..e3b36489 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3692,6 +3692,211 @@ describe("CodexSecurity orchestration", () => { expect(runtimeHomes).toEqual([credentialHome, credentialHome]); }); + test.each([ + ["OpenAI", "OPENAI_API_KEY", undefined, "gpt-5.6-sol"], + [ + "OpenRouter", + "OPENROUTER_API_KEY", + "openrouter", + "anthropic/claude-sonnet-4.5", + ], + [ + "Fireworks AI", + "FIREWORKS_API_KEY", + "fireworks", + "accounts/fireworks/models/qwen3-235b-a22b", + ], + ] as const)( + "retains %s scan sessions in the managed Codex home", + async (_name, apiKey, provider, model) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const stateDirectory = join(root, "state"); + const codexHome = join(stateDirectory, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(scanDir, { mode: 0o700 }); + const client = new TestClient( + { + pluginPath: PLUGIN_ROOT, + codexOverrides: { + model, + ...(provider === undefined + ? {} + : { + model_provider: provider, + model_providers: { + [provider]: + provider === "openrouter" + ? OPENROUTER_CODEX_PROVIDER + : FIREWORKS_CODEX_PROVIDER, + }, + }), + }, + }, + { + environment: { + CODEX_SECURITY_STATE_DIR: stateDirectory, + [apiKey]: "synthetic-transient-key", + }, + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => ({ + startThread: () => ({ + id: null, + async runStreamed() { + expect(options.env?.["CODEX_HOME"]).toBe(codexHome); + expect(options.apiKey).toBe( + provider === undefined + ? "synthetic-transient-key" + : undefined, + ); + await writeUsageSession(codexHome, "persistent-thread", { + input_tokens: 1, + }); + throw new Error("persistent session recorded"); + }, + }), + }), + }, + ); + + try { + await expect(client.run(repository)).rejects.toThrow( + "persistent session recorded", + ); + } finally { + await client.close(); + } + + expect(existsSync(codexHome)).toBe(true); + expect( + existsSync( + join( + codexHome, + "sessions", + "2026", + "07", + "26", + "rollout-persistent-thread.jsonl", + ), + ), + ).toBe(true); + expect(existsSync(join(codexHome, "auth.json"))).toBe(false); + expect( + await readFile(join(codexHome, "config.toml"), "utf8"), + ).not.toContain("synthetic-transient-key"); + }, + ); + + test("runs API-key scans in parallel through the same managed home", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const stateDirectory = join(root, "state"); + const codexHome = join(stateDirectory, "codex-home"); + await mkdir(repository); + let activeScans = 0; + let maximumActiveScans = 0; + let releaseScans!: () => void; + const timeout = AbortSignal.timeout(5_000); + const concurrentScans = new Promise((resolve, reject) => { + releaseScans = resolve; + timeout.addEventListener( + "abort", + () => reject(new Error("API-key scans were serialized")), + { once: true }, + ); + }); + + const clients = await Promise.all( + [ + ["OPENAI_API_KEY", "gpt-5.6-sol", undefined], + ["OPENROUTER_API_KEY", "anthropic/claude-sonnet-4.5", "openrouter"], + ].map(async ([apiKey, model, provider], index) => { + const scanDir = join(root, `parallel-api-key-scan-${index}`); + await mkdir(scanDir, { mode: 0o700 }); + return new TestClient( + { + pluginPath: PLUGIN_ROOT, + codexOverrides: { + model, + ...(provider === undefined + ? {} + : { + model_provider: provider, + model_providers: { + [provider]: OPENROUTER_CODEX_PROVIDER, + }, + }), + }, + }, + { + environment: { + CODEX_SECURITY_STATE_DIR: stateDirectory, + [apiKey!]: `synthetic-key-${index}`, + }, + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => { + expect(options.env?.["CODEX_HOME"]).toBe(codexHome); + expect(options.config).toMatchObject({ + model, + ...(provider === undefined + ? {} + : { + model_provider: provider, + model_providers: { + [provider]: OPENROUTER_CODEX_PROVIDER, + }, + }), + }); + return { + startThread: () => ({ + id: null, + async runStreamed() { + activeScans += 1; + maximumActiveScans = Math.max( + maximumActiveScans, + activeScans, + ); + if (activeScans === 2) releaseScans(); + try { + await concurrentScans; + throw new Error("parallel API-key scan reached"); + } finally { + activeScans -= 1; + } + }, + }), + }; + }, + }, + ); + }), + ); + + try { + const results = await Promise.allSettled( + clients.map(async (client) => await client.run(repository)), + ); + for (const result of results) { + expect(result).toMatchObject({ + status: "rejected", + reason: expect.objectContaining({ + message: "parallel API-key scan reached", + }), + }); + } + expect(maximumActiveScans).toBe(2); + expect(existsSync(join(codexHome, "auth.json"))).toBe(false); + } finally { + await Promise.all(clients.map(async (client) => await client.close())); + } + expect(existsSync(codexHome)).toBe(true); + }); + test("serializes parallel scans sharing a managed credential home", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -4528,7 +4733,7 @@ if (process.argv.slice(2).join(" ") !== "login status") { } }); - test("recreates isolated and managed runtimes when scan authentication changes", async () => { + test("reuses the managed runtime when scan authentication changes", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const ambientHome = join(root, "ambient-codex-home"); @@ -4566,10 +4771,9 @@ if (process.argv.slice(2).join(" ") !== "login status") { await expect(client.run(repository, { auth: "api-key" })).rejects.toThrow( "authentication-selected scan reached", ); - const firstIsolatedHome = runs[0]?.home; - expect(firstIsolatedHome).toBeDefined(); - expect(firstIsolatedHome).not.toBe(dedicatedHome); + expect(runs[0]?.home).toBe(dedicatedHome); expect(runs[0]?.apiKey).toBe("synthetic-transient-key"); + expect(existsSync(join(dedicatedHome, "auth.json"))).toBe(false); await expect(client.run(repository, { auth: "chatgpt" })).rejects.toThrow( "authentication-selected scan reached", @@ -4578,12 +4782,10 @@ if (process.argv.slice(2).join(" ") !== "login status") { expect(await readFile(join(dedicatedHome, "auth.json"), "utf8")).toBe( ambientAuthentication, ); - expect(existsSync(firstIsolatedHome!)).toBe(false); - await expect(client.run(repository, { auth: "api-key" })).rejects.toThrow( "authentication-selected scan reached", ); - expect(runs[2]?.home).not.toBe(dedicatedHome); + expect(runs[2]?.home).toBe(dedicatedHome); expect(runs[2]?.apiKey).toBe("synthetic-transient-key"); expect(await readFile(join(dedicatedHome, "auth.json"), "utf8")).toBe( ambientAuthentication, @@ -4591,6 +4793,7 @@ if (process.argv.slice(2).join(" ") !== "login status") { } finally { await client.close(); } + expect(existsSync(dedicatedHome)).toBe(true); }); test("does not cache an environment key as reusable file authentication", async () => { From 33bd30b4606e47403d28ce412c9ac907fe7baf53 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 11 Aug 2026 09:49:26 -0700 Subject: [PATCH 2/3] test(sdk): wait for shared scan homes on Windows --- sdk/typescript/tests-ts/api.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index e3b36489..2736521d 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3799,14 +3799,8 @@ describe("CodexSecurity orchestration", () => { let activeScans = 0; let maximumActiveScans = 0; let releaseScans!: () => void; - const timeout = AbortSignal.timeout(5_000); - const concurrentScans = new Promise((resolve, reject) => { + const concurrentScans = new Promise((resolve) => { releaseScans = resolve; - timeout.addEventListener( - "abort", - () => reject(new Error("API-key scans were serialized")), - { once: true }, - ); }); const clients = await Promise.all( From 2c4e0512333a1482546703c98c8b821e6df096c0 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 11 Aug 2026 10:10:02 -0700 Subject: [PATCH 3/3] refactor(sdk): prepare shared scan homes once --- sdk/typescript/src/api.ts | 34 +++++++--------- sdk/typescript/tests-ts/api.test.ts | 61 ++++++++++------------------- 2 files changed, 36 insertions(+), 59 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 28bb7a1a..e90b42a0 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -71,6 +71,7 @@ import { bootstrapPlugin, cleanupSdkDirectory, codexSecurityCredentialAllowsAmbientImport, + codexSecurityCredentialHome, codexSecurityHasStoredFileCredentials, codexSecurityStateDirectory, createIsolatedHome, @@ -489,7 +490,6 @@ export class CodexSecurity { ); if ( runtime === previousRuntime && - runtime.persistentCredentialHome === true && this.#dependencies.prepareRuntime === undefined ) { await this.#refreshPersistentRuntime(runtime, scanEnvironment, signal); @@ -1510,10 +1510,10 @@ export class CodexSecurity { auth, modelProvider, ); - const codexHome = await prepareCodexSecurityCredentialHome( - processEnvironment, - validateLocation, - ); + const codexHome = + validateLocation === undefined + ? await prepareCodexSecurityCredentialHome(processEnvironment) + : await realpath(codexSecurityCredentialHome(processEnvironment)); let bootstrapWorkspace: string | undefined; try { throwIfAborted(signal); @@ -1577,20 +1577,16 @@ export class CodexSecurity { effectiveConfig: mergedConfig, }; } catch (error) { - const cleanupResults = await Promise.allSettled( - [bootstrapWorkspace] - .filter((path): path is string => path !== undefined) - .map((path) => cleanupSdkDirectory(path)), - ); - const cleanupFailures = cleanupResults.flatMap((result) => - result.status === "rejected" ? [result.reason] : [], - ); - if (cleanupFailures.length > 0) { - throw new AggregateError( - [error, ...cleanupFailures], - "Codex Security runtime preparation failed and its isolated runtime could not be cleaned up.", - { cause: error }, - ); + if (bootstrapWorkspace !== undefined) { + try { + await cleanupSdkDirectory(bootstrapWorkspace); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Codex Security runtime preparation failed and its isolated runtime could not be cleaned up.", + { cause: error }, + ); + } } throw error; } diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 2736521d..8991361f 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3693,29 +3693,28 @@ describe("CodexSecurity orchestration", () => { }); test.each([ - ["OpenAI", "OPENAI_API_KEY", undefined, "gpt-5.6-sol"], - [ - "OpenRouter", - "OPENROUTER_API_KEY", - "openrouter", - "anthropic/claude-sonnet-4.5", - ], - [ - "Fireworks AI", - "FIREWORKS_API_KEY", - "fireworks", - "accounts/fireworks/models/qwen3-235b-a22b", - ], + ["OpenAI", undefined, "OPENAI_API_KEY", "gpt-5.6-sol", undefined], + ...EXTERNAL_PROVIDER_CASES, ] as const)( "retains %s scan sessions in the managed Codex home", - async (_name, apiKey, provider, model) => { + async (_name, provider, apiKey, model, providerConfig) => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const stateDirectory = join(root, "state"); + const configuredStateDirectory = + provider === "openrouter" ? join(root, "linked-state") : stateDirectory; const codexHome = join(stateDirectory, "codex-home"); const scanDir = join(root, "scan"); await mkdir(repository); await mkdir(scanDir, { mode: 0o700 }); + if (configuredStateDirectory !== stateDirectory) { + await mkdir(stateDirectory, { mode: 0o700 }); + await symlink( + stateDirectory, + configuredStateDirectory, + process.platform === "win32" ? "junction" : "dir", + ); + } const client = new TestClient( { pluginPath: PLUGIN_ROOT, @@ -3725,18 +3724,13 @@ describe("CodexSecurity orchestration", () => { ? {} : { model_provider: provider, - model_providers: { - [provider]: - provider === "openrouter" - ? OPENROUTER_CODEX_PROVIDER - : FIREWORKS_CODEX_PROVIDER, - }, + model_providers: { [provider]: providerConfig }, }), }, }, { environment: { - CODEX_SECURITY_STATE_DIR: stateDirectory, + CODEX_SECURITY_STATE_DIR: configuredStateDirectory, [apiKey]: "synthetic-transient-key", }, resolvePluginPython: async () => "/managed/python", @@ -3770,7 +3764,6 @@ describe("CodexSecurity orchestration", () => { await client.close(); } - expect(existsSync(codexHome)).toBe(true); expect( existsSync( join( @@ -3796,8 +3789,7 @@ describe("CodexSecurity orchestration", () => { const stateDirectory = join(root, "state"); const codexHome = join(stateDirectory, "codex-home"); await mkdir(repository); - let activeScans = 0; - let maximumActiveScans = 0; + let scansStarted = 0; let releaseScans!: () => void; const concurrentScans = new Promise((resolve) => { releaseScans = resolve; @@ -3850,18 +3842,9 @@ describe("CodexSecurity orchestration", () => { startThread: () => ({ id: null, async runStreamed() { - activeScans += 1; - maximumActiveScans = Math.max( - maximumActiveScans, - activeScans, - ); - if (activeScans === 2) releaseScans(); - try { - await concurrentScans; - throw new Error("parallel API-key scan reached"); - } finally { - activeScans -= 1; - } + if (++scansStarted === 2) releaseScans(); + await concurrentScans; + throw new Error("parallel API-key scan reached"); }, }), }; @@ -3873,7 +3856,7 @@ describe("CodexSecurity orchestration", () => { try { const results = await Promise.allSettled( - clients.map(async (client) => await client.run(repository)), + clients.map((client) => client.run(repository)), ); for (const result of results) { expect(result).toMatchObject({ @@ -3883,12 +3866,10 @@ describe("CodexSecurity orchestration", () => { }), }); } - expect(maximumActiveScans).toBe(2); - expect(existsSync(join(codexHome, "auth.json"))).toBe(false); + expect(scansStarted).toBe(2); } finally { await Promise.all(clients.map(async (client) => await client.close())); } - expect(existsSync(codexHome)).toBe(true); }); test("serializes parallel scans sharing a managed credential home", async () => {