diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index cb90e089..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, @@ -467,10 +468,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) => @@ -492,7 +490,6 @@ export class CodexSecurity { ); if ( runtime === previousRuntime && - runtime.persistentCredentialHome === true && this.#dependencies.prepareRuntime === undefined ) { await this.#refreshPersistentRuntime(runtime, scanEnvironment, signal); @@ -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 = + validateLocation === undefined + ? await prepareCodexSecurityCredentialHome(processEnvironment) + : await realpath(codexSecurityCredentialHome(processEnvironment)); 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, @@ -1594,20 +1577,16 @@ export class CodexSecurity { effectiveConfig: mergedConfig, }; } catch (error) { - const cleanupResults = await Promise.allSettled( - [bootstrapWorkspace, persistentCredentialHome ? undefined : codexHome] - .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 ec8076ae..8991361f 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3692,6 +3692,186 @@ describe("CodexSecurity orchestration", () => { expect(runtimeHomes).toEqual([credentialHome, credentialHome]); }); + test.each([ + ["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, 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, + codexOverrides: { + model, + ...(provider === undefined + ? {} + : { + model_provider: provider, + model_providers: { [provider]: providerConfig }, + }), + }, + }, + { + environment: { + CODEX_SECURITY_STATE_DIR: configuredStateDirectory, + [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( + 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 scansStarted = 0; + let releaseScans!: () => void; + const concurrentScans = new Promise((resolve) => { + releaseScans = resolve; + }); + + 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() { + if (++scansStarted === 2) releaseScans(); + await concurrentScans; + throw new Error("parallel API-key scan reached"); + }, + }), + }; + }, + }, + ); + }), + ); + + try { + const results = await Promise.allSettled( + clients.map((client) => client.run(repository)), + ); + for (const result of results) { + expect(result).toMatchObject({ + status: "rejected", + reason: expect.objectContaining({ + message: "parallel API-key scan reached", + }), + }); + } + expect(scansStarted).toBe(2); + } finally { + await Promise.all(clients.map(async (client) => await client.close())); + } + }); + test("serializes parallel scans sharing a managed credential home", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -4528,7 +4708,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 +4746,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 +4757,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 +4768,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 () => {