From adc9031379f0866580f17a2c9520d8d6442bf73a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 07:52:20 +0000 Subject: [PATCH 01/18] refactor: share Codex construction and stream reduction --- .../mcp-app/src/deep-scan/executor.ts | 165 +++--- .../mcp-app/tests/test_deep_scan_executor.mjs | 224 +++++++- .../tests/test_deep_scan_stdio_lifecycle.mjs | 11 +- sdk/typescript/scripts/check-package.mjs | 1 + .../scripts/fixtures/package-deep-codex.mjs | 175 +++++++ .../scripts/fixtures/package-deep-scan.mjs | 489 ++++++++++++++++++ .../scripts/fixtures/package-deep-spawn.mjs | 20 + .../scripts/fixtures/package-rpc.mjs | 90 ++++ sdk/typescript/scripts/smoke-package.mjs | 9 + sdk/typescript/src/api.ts | 109 ++-- sdk/typescript/src/codex-session.ts | 94 ++++ sdk/typescript/tests-ts/api.test.ts | 161 ++++++ .../deep-scan-worker-shutdown.test.ts | 20 +- 13 files changed, 1395 insertions(+), 173 deletions(-) create mode 100644 sdk/typescript/scripts/fixtures/package-deep-codex.mjs create mode 100644 sdk/typescript/scripts/fixtures/package-deep-scan.mjs create mode 100644 sdk/typescript/scripts/fixtures/package-deep-spawn.mjs create mode 100644 sdk/typescript/scripts/fixtures/package-rpc.mjs create mode 100644 sdk/typescript/src/codex-session.ts diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 788745ae4..51db8dedd 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -1,7 +1,11 @@ import { accessSync, constants as fsConstants, existsSync, promises as fs, readdirSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import { delimiter, dirname, isAbsolute, join, resolve, win32 } from "node:path"; -import { Codex } from "@openai/codex-sdk"; +import { + createCodexClient, + readCodexSessionTurn +} from "../../../../../sdk/typescript/src/codex-session.js"; +import type { CodexOptions } from "@openai/codex-sdk"; import { parse as parseToml } from "smol-toml"; import { executablePathForSpawn } from "./executable-path.js"; import { @@ -22,6 +26,8 @@ import type { } from "./types.js"; export interface CodexSdkWorkerModelSettings { + /** Resolved by the execution owner, including when reconstructing a scan. */ + codexOptions?: CodexOptions; model?: string; reasoningEffort?: string; artifactContext?: CodexSdkWorkerArtifactContext; @@ -39,7 +45,7 @@ export interface CodexSdkWorkerArtifactContext { } export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { - private runtimeReasoningSummary?: Promise; + private runtimeModelConfig?: Promise>; constructor(private readonly modelSettings: CodexSdkWorkerModelSettings = {}) {} @@ -52,15 +58,32 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { ); } const workerProfile = workerPermissionProfile(parentSandbox); - const configOverrides = workerPermissionProfileConfigOverrides(workerProfile); + const resolved = this.modelSettings.codexOptions; const originalCwd = process.cwd(); - const childEnv = await snapshotWorkerEnvironment(); - // Snapshot the SDK's per-scan config once for this coordinator, including resumes. - const reasoningSummary = await (this.runtimeReasoningSummary ??= workerReasoningSummary(childEnv)); + const childEnv = await snapshotWorkerEnvironment(resolved?.env); + if (resolved?.apiKey !== undefined) childEnv.CODEX_API_KEY = resolved.apiKey; + // Snapshot per-scan selections once; a reconstructed owner can supply them. + // Native account credentials continue to refresh in the selected home. + const modelConfig: NonNullable = { + ...await (this.runtimeModelConfig ??= resolved?.config + ? Promise.resolve(resolved.config) + : workerModelConfig(childEnv)), + ...(this.modelSettings.model ? { model: this.modelSettings.model } : {}), + // The CLI can add effort levels before the pinned SDK widens ThreadOptions. + ...(this.modelSettings.reasoningEffort + ? { model_reasoning_effort: this.modelSettings.reasoningEffort } + : {}) + }; + const configOverrides = [ + ...(resolved?.configOverrides ?? []), + ...workerPermissionProfileConfigOverrides(workerProfile) + ]; const openAiApiKey = environmentVariable(childEnv, "OPENAI_API_KEY", process.platform)?.trim(); const codexApiKey = environmentVariable(childEnv, "CODEX_API_KEY", process.platform)?.trim(); const codexPath = resolveCodexPath( - childEnv, + resolved?.codexPathOverride === undefined + ? childEnv + : { ...childEnv, CODEX_CLI_PATH: resolved.codexPathOverride }, process.platform, process.arch, originalCwd @@ -69,34 +92,35 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { codexPath, cwd: request.workingDirectory, profileId: DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID, - configOverrides, + configOverrides: [ + ...Object.entries(workerModelSelection(modelConfig)) + .map(([key, value]) => `${key}=${tomlInlineValue(value)}`), + ...configOverrides, + ...(resolved?.baseUrl ? [`openai_base_url=${tomlString(resolved.baseUrl)}`] : []) + ], expectedProfile: workerProfile, env: childEnv, allowOpenAiApiKeyFallback: Boolean(openAiApiKey && !codexApiKey), signal: request.signal }); const prompt = await fs.readFile(request.promptPath, "utf8"); - const codex = new Codex({ + const codex = createCodexClient({ + ...resolved, codexPathOverride: executablePathForSpawn(codexPath), env: childEnv, // Codex exec reads CODEX_API_KEY; the SDK maps apiKey to that variable. // Keep native credentials unless the worker has no configured account. ...(useOpenAiApiKey ? { apiKey: openAiApiKey } : {}), config: { - ...(reasoningSummary === undefined - ? {} - : { model_reasoning_summary: reasoningSummary }), - // The CLI can add effort levels before the pinned SDK widens ThreadOptions. - ...(this.modelSettings.reasoningEffort - ? { model_reasoning_effort: this.modelSettings.reasoningEffort } - : {}), + ...modelConfig, mcp_servers: { + ...(isRecord(modelConfig.mcp_servers) ? modelConfig.mcp_servers : {}), // Discovery workers use the bundled skills and artifacts, not the parent workbench MCP. // A disabled server still needs a valid transport while Codex resolves plugin configuration. "codex-security": { command: "node", enabled: false }, ...this.compactArtifactServer(request) }, - ...workerSubagentConfig(request.subagents) + ...workerSubagentConfig(request.subagents, modelConfig) }, // Structured SDK config cannot preserve literal filesystem keys such as // ":root" or "/repo/.env"; raw overrides keep this inline TOML intact. @@ -110,7 +134,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { workingDirectory: request.workingDirectory } as const; const thread = request.resumeThreadId - ? codex.resumeThread(request.resumeThreadId, threadOptions) + ? codex.resumeThread!(request.resumeThreadId, threadOptions) : codex.startThread(threadOptions); const input = request.resumeThreadId ? request.continuationPrompt ?? prompt @@ -125,51 +149,44 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { try { const { events } = await thread.runStreamed(input, { signal: controller.signal }); - let finalResponse = ""; - let threadId: string | undefined; - let turnCompleted = false; - let lastStreamError: string | undefined; const diagnostics: CodexWorkerDiagnostic[] = []; - for await (const event of events) { - if (event.type === "thread.started") { - threadId = event.thread_id; - await request.onThreadStarted?.(threadId); - } else if (event.type === "item.completed") { - const fallbackError = event.item.type === "error" - ? deepScanPermissionProfileFallbackError(event.item.message) - : undefined; - if (fallbackError) { - controller.abort(fallbackError); - throw fallbackError; - } - if (event.item.type === "agent_message") { - finalResponse = event.item.text; - } else { + const turn = await readCodexSessionTurn({ + thread, + events, + stopOnCompletion: true, + onEvent: async (event) => { + if (event.type === "thread.started" && typeof event.thread_id === "string") { + await request.onThreadStarted?.(event.thread_id); + } else if (event.type === "item.completed" && isRecord(event.item)) { + const fallbackError = event.item.type === "error" && typeof event.item.message === "string" + ? deepScanPermissionProfileFallbackError(event.item.message) + : undefined; + if (fallbackError) { + controller.abort(fallbackError); + throw fallbackError; + } appendSafeItemDiagnostic(diagnostics, event.item); + } else if (event.type === "turn.completed") { + request.signal.removeEventListener("abort", forwardAbort); + } else if (event.type === "turn.failed") { + throw new Error((event.error as { message: string }).message); + } else if (event.type === "error" && typeof event.message === "string") { + const fallbackError = deepScanPermissionProfileFallbackError(event.message); + if (fallbackError) { + controller.abort(fallbackError); + throw fallbackError; + } + // Codex exec emits retry-in-progress notifications as error events. } - } else if (event.type === "turn.completed") { - turnCompleted = true; - request.signal.removeEventListener("abort", forwardAbort); - break; - } else if (event.type === "turn.failed") { - throw new Error(event.error.message); - } else if (event.type === "error") { - const fallbackError = deepScanPermissionProfileFallbackError(event.message); - if (fallbackError) { - controller.abort(fallbackError); - throw fallbackError; - } - // Codex exec currently emits retry-in-progress notifications as error events. - lastStreamError = event.message; } - } - if (!turnCompleted) { - const detail = lastStreamError ? `: ${lastStreamError}` : ""; + }); + if (turn.status !== "completed") { + const detail = turn.lastStreamError ? `: ${turn.lastStreamError}` : ""; throw new Error(`Codex worker stream ended before turn.completed${detail}`); } return { - finalResponse, - threadId: threadId ?? thread.id ?? undefined, + finalResponse: turn.finalResponse, + threadId: turn.threadId ?? thread.id ?? undefined, ...(diagnostics.length > 0 ? { diagnostics } : {}) }; } finally { @@ -246,13 +263,16 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { } } -function workerSubagentConfig(subagents: number) { +function workerSubagentConfig(subagents: number, config: NonNullable) { return { // V1 counts children; V2 counts the root plus its children. Keeping its // feature disabled lets the model choose either runtime without rejecting // inherited agents.max_threads configuration. - ...(subagents > 0 ? { agents: { max_threads: subagents } } : {}), + ...(subagents > 0 + ? { agents: { ...(isRecord(config.agents) ? config.agents : {}), max_threads: subagents } } + : {}), features: { + ...(isRecord(config.features) ? config.features : {}), multi_agent_v2: { enabled: false, max_concurrent_threads_per_session: subagents + 1 @@ -268,7 +288,7 @@ function workerSubagentConfig(subagents: number) { }; } -type TomlValue = string | number | boolean | TomlObject; +type TomlValue = string | number | boolean | TomlValue[] | TomlObject; type TomlObject = { [key: string]: TomlValue }; function workerPermissionProfile( @@ -307,6 +327,7 @@ function tomlInlineValue(value: TomlValue): string { if (typeof value === "string") return tomlString(value); if (typeof value === "number") return String(value); if (typeof value === "boolean") return value ? "true" : "false"; + if (Array.isArray(value)) return `[${value.map(tomlInlineValue).join(",")}]`; return `{${Object.entries(value) .map(([key, entry]) => `${tomlKey(key)}=${tomlInlineValue(entry)}`) .join(",")}}`; @@ -383,30 +404,38 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -async function workerReasoningSummary(environment: Record): Promise { +// These are the existing non-secret selections written by the SDK preflight +// adapter. Reading only summary left provider selection in a shared home. +function workerModelSelection(config: NonNullable): TomlObject { + const result: TomlObject = {}; + for (const key of ["model", "model_provider", "model_reasoning_effort", "model_reasoning_summary", "service_tier", "model_providers"]) { + const value = config[key]; + if (value !== undefined) result[key] = value; + } + return result; +} + +async function workerModelConfig(environment: Record): Promise> { const configPath = environmentVariable(environment, "CODEX_SECURITY_CONFIG_PATH", process.platform); - if (!configPath) return undefined; + if (!configPath) return {}; const config = parseToml(await fs.readFile(configPath, "utf8")); const profiles = config.profiles; const profile = typeof config.profile === "string" && isRecord(profiles) ? profiles[config.profile] : undefined; - const summary = isRecord(profile) && profile.model_reasoning_summary !== undefined - ? profile.model_reasoning_summary - : config.model_reasoning_summary; - return typeof summary === "string" ? summary : undefined; + return workerModelSelection({ ...config, ...(isRecord(profile) ? profile : {}) } as NonNullable); } -async function snapshotWorkerEnvironment(): Promise> { +async function snapshotWorkerEnvironment(source: NodeJS.ProcessEnv = process.env): Promise> { const environment = Object.fromEntries( - Object.entries(process.env) + Object.entries(source) .filter((entry): entry is [string, string] => entry[1] !== undefined) ) as Record; if (process.platform === "win32") { // process.env is case-insensitive on Windows; a plain object is not. // Keep its selected values while giving the child one spelling per key. for (const name of ["CODEX_CLI_PATH", "CODEX_HOME", "CODEX_MANAGED_PACKAGE_ROOT", "LOCALAPPDATA"]) { - const value = process.env[name]; + const value = environmentVariable(source, name, process.platform); for (const key of Object.keys(environment)) { if (key.toUpperCase() === name) delete environment[key]; } diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 76112891d..f989542a0 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -74,6 +74,9 @@ const deniedWorkerPermissionProfile = { try { await testOpenAiCredentialsReachWorker(); await testWorkerReasoningSummaries(); + await testWorkerProviderSelection(); + await testIsolatedReconstructedWorkers(); + if (process.platform !== "win32") await testNullUsageCompletion(); if (process.platform !== "win32") { await testMissingParentSandboxFailsBeforeWorkerLaunch(); await testDisallowedWorkerProfileFailsBeforeWorkerLaunch(); @@ -737,6 +740,184 @@ async function testOpenAiCredentialsReachWorker() { } } +async function testIsolatedReconstructedWorkers() { + const previousMarker = process.env.FAKE_CODEX_MARKER; + const originalSpawn = childProcess.spawn; + const scans = []; + try { + for (const name of ["first", "second"]) { + const fixture = await fakeCodexFixture(deniedWorkerPermissionProfile); + const codexHome = path.join(fixture.root, "home"); + const configPath = path.join(fixture.root, "scan config.toml"); + const promptPath = path.join(fixture.root, "prompt.md"); + await mkdir(codexHome); + const config = { + model: `fixture-${name}-inherited`, + model_provider: `fixture-${name}-provider`, + model_reasoning_effort: "medium", + model_reasoning_summary: name === "first" ? "none" : "concise", + service_tier: name === "first" ? "flex" : "fast" + }; + await writeFile(configPath, Object.entries(config).map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); + await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); + const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); + await copyFile(process.execPath, executable); + const codexOptions = { + codexPathOverride: executable, + baseUrl: `https://${name}.example.invalid/v1`, + env: { + PATH: path.dirname(process.execPath), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + CODEX_HOME: codexHome, + CODEX_SECURITY_CONFIG_PATH: configPath, + CODEX_API_KEY: `synthetic-${name}-credential`, + FAKE_CODEX_MARKER: fixture.markerPath, + FAKE_CODEX_SCAN_VALUE: name + } + }; + const settings = { + codexOptions, + model: `fixture-${name}-override`, + reasoningEffort: "ultra", + parentSandbox: trustedParentSandboxWithDenials + }; + scans.push({ name, fixture, config, configPath, promptPath, settings, executor: new CodexSdkWorkerExecutor(settings) }); + } + childProcess.spawn = (command, args, options) => { + const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); + return originalSpawn(command, scan ? [scan.fixture.executablePath, ...args] : args, options); + }; + syncBuiltinESMExports(); + + for (const phase of ["fresh", "resume", "reconstructed"]) { + for (const scan of scans) { + scan.settings.codexOptions.env.CODEX_API_KEY = `synthetic-${scan.name}-${phase}-credential`; + scan.settings.codexOptions.env.FAKE_CODEX_SCAN_VALUE = `${scan.name}-${phase}`; + } + if (phase === "reconstructed") { + for (const scan of scans) { + // The caller restores recorded selections. Its old config file need + // not exist; current credentials still come from the selected home/env. + await rm(scan.configPath); + scan.executor = new CodexSdkWorkerExecutor({ + ...scan.settings, + codexOptions: { ...scan.settings.codexOptions, config: scan.config } + }); + } + } + for (const kind of ["discovery", "dedup"]) { + await Promise.all(scans.map(async (scan) => { + const resumeThreadId = phase === "fresh" ? undefined : `fixture-${scan.name}-resumed`; + const result = await scan.executor.run({ + kind, promptPath: scan.promptPath, workingDirectory: scan.fixture.root, + subagents: scan.name === "first" ? 0 : 2, + resumeThreadId, continuationPrompt: "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE continuation", + signal: new AbortController().signal + }); + assert.equal(result.threadId, resumeThreadId ?? "fixture-thread-id"); + const child = JSON.parse(await readFile(scan.fixture.markerPath, "utf8")); + const preflight = JSON.parse(await readFile(scan.fixture.preflightMarkerPath, "utf8")); + assert.equal(child.executable, scan.settings.codexOptions.codexPathOverride); + assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); + assert.equal(preflight.codexHome, child.codexHome); + assert.equal(child.scanValue, `${scan.name}-${phase}`); + assert.equal(child.configPath, scan.configPath); + assert.deepEqual(child.openaiAuthentication, { CODEX_API_KEY: `synthetic-${scan.name}-${phase}-credential` }); + assertFlagPair(child.argv, "--model", scan.settings.model); + for (const key of ["model_provider", "model_reasoning_summary", "service_tier"]) { + const override = `${key}=${JSON.stringify(scan.config[key])}`; + assert.equal(child.argv.includes(override), true, override); + assert.equal(preflight.argv.includes(override), true, override); + } + assert.equal(child.argv.includes('model_reasoning_effort="ultra"'), true); + assert.equal(preflight.argv.includes('model_reasoning_effort="ultra"'), true); + assert.equal(preflight.argv.includes(`model=${JSON.stringify(scan.settings.model)}`), true); + const baseUrl = `openai_base_url=${JSON.stringify(scan.settings.codexOptions.baseUrl)}`; + assert.equal(child.argv.includes(baseUrl), true); + assert.equal(preflight.argv.includes(baseUrl), true); + assertReadOnlyWorkerPolicy(child.argv); + assertWorkerSubagentPolicy(child.argv, scan.name === "first" ? 0 : 2); + assert.equal(workerPermissionProfileOverride(child.argv).includes('"/repo/.env"="deny"'), true); + assert.equal(child.argv.includes("resume"), resumeThreadId !== undefined); + assert.equal(child.stdin.includes("continuation"), resumeThreadId !== undefined); + })); + } + if (phase === "fresh") { + for (const scan of scans) { + await writeFile(scan.configPath, 'model_provider = "changed-provider"\nmodel_reasoning_summary = "detailed"\n'); + } + } + } + } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); + restoreEnv("FAKE_CODEX_MARKER", previousMarker); + } +} + +async function testWorkerProviderSelection() { + const fixture = await fakeCodexFixture(); + const saved = Object.fromEntries( + ["CODEX_CLI_PATH", "CODEX_SECURITY_CONFIG_PATH", "OPENAI_API_KEY", "CODEX_API_KEY"].map((name) => [name, process.env[name]]) + ); + const originalSpawn = childProcess.spawn; + try { + delete process.env.OPENAI_API_KEY; + delete process.env.CODEX_API_KEY; + const configPath = path.join(fixture.root, "scan config.toml"); + const promptPath = path.join(fixture.root, "prompt.md"); + await writeFile(configPath, 'model_provider = "fixture-provider"\n'); + await writeFile(promptPath, "fixture provider selection"); + process.env.CODEX_CLI_PATH = process.execPath; + process.env.CODEX_SECURITY_CONFIG_PATH = configPath; + childProcess.spawn = (command, args, options) => originalSpawn( + command, + command === process.execPath || command === path.toNamespacedPath(process.execPath) + ? [fixture.executablePath, ...args] + : args, + options + ); + syncBuiltinESMExports(); + const executor = new CodexSdkWorkerExecutor({ parentSandbox: trustedParentSandbox }); + for (const kind of ["discovery", "dedup"]) { + await executor.run({ + kind, promptPath, workingDirectory: fixture.root, subagents: 0, + signal: new AbortController().signal + }); + const invocation = JSON.parse(await readFile(fixture.markerPath, "utf8")); + assert.equal(invocation.argv.includes('model_provider="fixture-provider"'), true); + } + } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); + for (const [name, value] of Object.entries(saved)) restoreEnv(name, value); + } +} + +async function testNullUsageCompletion() { + const fixture = await fakeCodexFixture(); + const previousPath = process.env.CODEX_CLI_PATH; + process.env.CODEX_CLI_PATH = fixture.executablePath; + try { + const promptPath = path.join(fixture.root, "prompt.md"); + await writeFile(promptPath, "NULL_USAGE\n"); + for (const kind of ["discovery", "dedup"]) { + for (const resumeThreadId of [undefined, "fixture-resumed-thread"]) { + const result = await new CodexSdkWorkerExecutor({ + parentSandbox: trustedParentSandbox + }).run({ + kind, promptPath, workingDirectory: fixture.root, subagents: 0, + resumeThreadId, signal: new AbortController().signal + }); + assert.equal(result.threadId, resumeThreadId ?? "fixture-thread-id"); + assert.equal(result.finalResponse, "fixture final response"); + } + } + } finally { + restoreEnv("CODEX_CLI_PATH", previousPath); + } +} + async function testWorkerReasoningSummaries() { const cases = [ ["", undefined], @@ -1400,22 +1581,27 @@ async function testDisallowedWorkerProfileFailsBeforeWorkerLaunch() { await mkdir(workingDirectory); await writeFile(promptPath, "fixture blocked worker prompt\n"); - await assert.rejects( - new CodexSdkWorkerExecutor({ - parentSandbox: trustedParentSandbox - }).run({ - kind: "discovery", - promptPath, - workingDirectory, - subagents: 0, - signal: new AbortController().signal - }), - (error) => error?.name === "DeepScanNonRetryableError" - && error.message.includes("codex_security_deep_scan_worker") - && error.message.includes("[allowed_permission_profiles]") - && error.message.includes("codex_security_deep_scan_worker = true") - && error.message.includes("Deep Scan did not run.") - ); + for (const kind of ["discovery", "dedup"]) { + for (const resumeThreadId of [undefined, "fixture-resumed-worker"]) { + await assert.rejects( + new CodexSdkWorkerExecutor({ + parentSandbox: trustedParentSandbox + }).run({ + kind, + resumeThreadId, + promptPath, + workingDirectory, + subagents: 0, + signal: new AbortController().signal + }), + (error) => error?.name === "DeepScanNonRetryableError" + && error.message.includes("codex_security_deep_scan_worker") + && error.message.includes("[allowed_permission_profiles]") + && error.message.includes("codex_security_deep_scan_worker = true") + && error.message.includes("Deep Scan did not run.") + ); + } + } await assert.rejects( readFile(fixture.markerPath, "utf8"), (error) => error?.code === "ENOENT" @@ -1480,7 +1666,7 @@ async function fakeCodexFixture( `const accountResult = ${JSON.stringify(accountResult)};`, `const preflightMarkerPath = ${JSON.stringify(preflightMarkerPath)};`, "if (process.argv.includes('app-server')) {", - " const preflight = { cwd: process.cwd(), codexHome: process.env.CODEX_HOME, requests: [] };", + " const preflight = { argv: process.argv.slice(2), cwd: process.cwd(), codexHome: process.env.CODEX_HOME, requests: [] };", " writeFileSync(preflightMarkerPath, JSON.stringify(preflight));", " let buffer = '';", " process.stdin.setEncoding('utf8');", @@ -1522,7 +1708,7 @@ async function fakeCodexFixture( "for await (const chunk of process.stdin) stdin += chunk;", "const openaiAuthentication = stdin.includes('CAPTURE_SYNTHETIC_OPENAI_AUTH') ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY } : undefined;", "const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;", - "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", + "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", "if (stdin.includes('COMPLETE_THEN_HANG')) process.on('SIGTERM', () => setTimeout(() => process.exit(0), 100));", "if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); }", "if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); }", @@ -1562,7 +1748,7 @@ async function fakeCodexFixture( " console.log(JSON.stringify({ type: 'item.completed', item }));", "}", "console.log(JSON.stringify({ type: 'item.completed', item: { id: 'message-1', type: 'agent_message', text: 'fixture final response' } }));", - "console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));", + "console.log(JSON.stringify({ type: 'turn.completed', usage: stdin.includes('NULL_USAGE') ? null : { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));", "if (stdin.includes('COMPLETE_THEN_HANG')) { setInterval(() => {}, 1_000); await new Promise(() => {}); }", "}", "" diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index e2a0fd3cd..7d8ca664a 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -10,7 +10,10 @@ import { build } from "esbuild"; const execFileAsync = promisify(execFile); const mcpAppRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const pluginRoot = path.resolve(mcpAppRoot, ".."); +const installedPluginRoot = process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT; +const pluginRoot = installedPluginRoot + ? path.resolve(installedPluginRoot) + : path.resolve(mcpAppRoot, ".."); const workbenchPath = path.join(pluginRoot, "scripts", "workbench_db.py"); const parentSandboxState = { permissionProfile: { @@ -55,7 +58,7 @@ async function testDeepScanStdioLifecycle() { const serverBundlePath = path.join( pluginRoot, "mcp", - `.deep-scan-stdio-test-${randomUUID()}.cjs` + installedPluginRoot ? "server.mjs" : `.deep-scan-stdio-test-${randomUUID()}.cjs` ); const threadId = "deep-scan-stdio-lifecycle-thread"; @@ -86,7 +89,7 @@ async function testDeepScanStdioLifecycle() { '' ].join('\n')); await writePythonWrapper(pythonWrapperPath); - await bundleServer(serverBundlePath); + if (!installedPluginRoot) await bundleServer(serverBundlePath); const environment = { ...process.env, @@ -569,7 +572,7 @@ async function testDeepScanStdioLifecycle() { throw error; } finally { await server.stop(); - await rm(serverBundlePath, { force: true }); + if (!installedPluginRoot) await rm(serverBundlePath, { force: true }); await rm(fixtureRoot, { recursive: true, force: true }); } } diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index f78271374..8fd657e58 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -175,6 +175,7 @@ const distFiles = new Set( "severity-store", "cloud-publish", "codex-prompt", + "codex-session", "component-plan", "component-scan", "config", diff --git a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs new file mode 100644 index 000000000..4d645dfb9 --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { appendFile, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { createInterface } from "node:readline"; +import { startRpc } from "./package-rpc.mjs"; + +try { + await run(); + process.exit(0); +} catch (error) { + await trace({ phase: "fixture-error", error: error.stack }); + console.error(error); + process.exit(1); +} + +async function trace(event) { + await appendFile( + process.env.PACKAGE_DEEP_TRACE, + `${JSON.stringify(event)}\n`, + ); +} + +async function run() { + const args = process.argv.slice(2); + if (args.includes("app-server")) { + await trace({ phase: "preflight", args }); + for await (const line of createInterface({ input: process.stdin })) { + const message = JSON.parse(line); + if (message.id === undefined) continue; + let result; + switch (message.method) { + case "initialize": + result = { userAgent: "package-fixture" }; + break; + case "config/read": + result = { + config: { + default_permissions: "codex_security_deep_scan_worker", + permissions: { + codex_security_deep_scan_worker: { + extends: ":read-only", + filesystem: { ":root": "read" }, + network: { enabled: false }, + }, + }, + }, + origins: {}, + layers: null, + }; + break; + case "permissionProfile/list": + result = { + data: [ + { + id: "codex_security_deep_scan_worker", + description: null, + allowed: true, + }, + ], + nextCursor: null, + }; + break; + case "account/read": + result = { account: null, requiresOpenaiAuth: true }; + break; + default: + throw new Error(`Unexpected preflight method: ${message.method}`); + } + console.log(JSON.stringify({ jsonrpc: "2.0", id: message.id, result })); + } + return; + } + let prompt = ""; + for await (const chunk of process.stdin) prompt += chunk; + const config = {}; + for (let index = 0; index < args.length; index++) { + if (args[index] !== "-c" && args[index] !== "--config") continue; + const setting = args[++index]; + const equals = setting.indexOf("="); + config[setting.slice(0, equals)] = setting.slice(equals + 1); + } + const prefix = "mcp_servers.cs_artifacts."; + const env = Object.fromEntries( + Object.entries(config) + .filter(([name]) => name.startsWith(`${prefix}env.`)) + .map(([name, value]) => [ + name.slice(`${prefix}env.`.length), + JSON.parse(value), + ]), + ); + const root = env.CODEX_SECURITY_ARTIFACT_ROOT; + assert.ok(root, "The real worker must supply its bound artifact root."); + assert.equal(config["mcp_servers.codex-security.enabled"], "false"); + const layout = env.CODEX_SECURITY_ARTIFACT_LAYOUT; + const threadId = `package-${layout}-${basename(root)}-${basename(join(root, ".."))}`; + console.log(JSON.stringify({ type: "thread.started", thread_id: threadId })); + if ( + layout === "worker" && + basename(join(root, "..")) === "discovery-0002" && + process.env.PACKAGE_DEEP_HOLD && + existsSync(process.env.PACKAGE_DEEP_HOLD) + ) { + await trace({ phase: "held", scanId: env.CODEX_SECURITY_SCAN_ID }); + await new Promise(() => setInterval(() => {}, 1_000)); + } + const server = await startRpc( + JSON.parse(config[`${prefix}command`]), + JSON.parse(config[`${prefix}args`]), + { cwd: root, env: { ...process.env, ...env } }, + ); + let complete = true; + try { + if (layout === "worker") { + const draft = { + scanId: env.CODEX_SECURITY_SCAN_ID, + findings: [], + coverage: { + completeness: "complete", + surfaces: [], + explicitExclusions: [], + deferred: [], + }, + }; + const marker = process.env.PACKAGE_DEEP_EMPTY_ONCE; + if (marker && !existsSync(marker)) { + await writeFile(marker, "process completed without a final artifact"); + complete = false; + } else { + await server.call("record_codex_security_scan_draft", { + ...draft, + complete: false, + }); + await server.call("record_codex_security_scan_draft", { + ...draft, + complete: true, + }); + } + } else { + assert.equal(layout, "reducer"); + const inputs = await server.call( + "get_codex_security_deep_reducer_inputs", + {}, + ); + assert.ok(inputs.discoveries.length > 0); + await server.call("record_codex_security_deep_reduction", { + scanId: env.CODEX_SECURITY_SCAN_ID, + findings: [], + }); + } + await trace({ + phase: layout, + complete, + resumed: args.includes("resume"), + scanId: env.CODEX_SECURITY_SCAN_ID, + home: process.env.CODEX_HOME, + hasApiKey: process.env.CODEX_API_KEY === "synthetic-package-deep-key", + root, + args, + }); + console.log( + JSON.stringify({ + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1, + }, + }), + ); + } finally { + await server.close(); + } +} diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs new file mode 100644 index 000000000..248d6031b --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -0,0 +1,489 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + chmod, + copyFile, + cp, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { startRpc } from "./package-rpc.mjs"; +import { packageSmokeTimeouts } from "../package-smoke-timeouts.mjs"; + +const installedRoot = await realpath(process.argv[2]); +const root = await realpath( + await mkdtemp(join(tmpdir(), "package deep % fixture-")), +); +const installedPlugin = join(installedRoot, "_bundled_plugin"); +try { + const detachedPlugin = join(root, "standalone plugin %", "codex-security"); + await cp(installedPlugin, detachedPlugin, { recursive: true }); + // Assert physical independence instead of merely changing the working directory. + for (let ancestor = detachedPlugin; ; ancestor = dirname(ancestor)) { + for (const dependency of ["node_modules", join("sdk", "typescript")]) { + await assert.rejects(stat(join(ancestor, dependency)), { + code: "ENOENT", + }); + } + if (ancestor === dirname(ancestor)) break; + } + for (const name of [ + "package-deep-codex.mjs", + "package-rpc.mjs", + "package-deep-spawn.mjs", + ]) { + await copyFile(new URL(name, import.meta.url), join(root, name)); + } + const executable = join( + root, + process.platform === "win32" + ? "package-codex.exe" + : "package-deep-codex.mjs", + ); + if (process.platform === "win32") + await copyFile(process.execPath, executable); + await chmod(executable, 0o700); + + await runInstalledSdk(installedPlugin, executable); + await runDetachedPlugin(detachedPlugin, executable); + console.log( + "Validated installed SDK and detached plugin: real Deep processes, bound artifact tools, checkpoints, reducer acceptance, restart before finalization, and sealed results.", + ); +} catch (error) { + for (const name of ["installed", "detached"]) { + try { + error.message += `\n${await readFile(join(root, name, "executions.jsonl"), "utf8")}`; + } catch (readError) { + if (readError.code !== "ENOENT") throw readError; + } + } + throw error; +} finally { + await rm(root, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); +} + +async function fixture(name, pluginRoot, executable) { + const directory = join(root, name); + const target = join(directory, "target with spaces %"); + const home = join(directory, "home"); + await mkdir(target, { recursive: true }); + await mkdir(join(home, "codex-security"), { recursive: true }); + await writeFile( + join(target, "fixture.py"), + "print('synthetic package fixture')\n", + ); + await writeFile( + join(home, "codex-security", "config.toml"), + "[deep_scan]\nworkers = 1\nsubagents = 0\nstop_after_no_new = 1\nmax_discovery_runs = 2\n", + ); + const env = Object.fromEntries( + [ + "PATH", + "Path", + "SystemRoot", + "WINDIR", + "ComSpec", + "PATHEXT", + "TMP", + "TEMP", + "TMPDIR", + ] + .filter((key) => process.env[key] !== undefined) + .map((key) => [key, process.env[key]]), + ); + Object.assign(env, { + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + CODEX_CLI_PATH: executable, + CODEX_SECURITY_PLUGIN_ROOT: pluginRoot, + CODEX_SECURITY_STATE_DIR: join(directory, "state"), + CODEX_SECURITY_SCAN_ROOT: join(directory, "scans"), + PYTHON: process.env.PYTHON || "python3", + OPENAI_API_KEY: "synthetic-package-deep-key", + ...(process.platform === "win32" + ? { + PACKAGE_DEEP_EXECUTABLE: executable, + NODE_OPTIONS: `--import=${pathToFileURL(join(root, "package-deep-spawn.mjs")).href}`, + } + : {}), + PACKAGE_DEEP_TRACE: join(directory, "executions.jsonl"), + }); + return { directory, target, home, env, pluginRoot }; +} + +function metadata(f, owner) { + return { + "openai/threadId": owner, + "codex/sandbox-state-meta": { + permissionProfile: { + type: "managed", + file_system: { + type: "restricted", + entries: [ + { + path: { type: "special", value: { kind: "root" } }, + access: "read", + }, + ], + }, + network: "restricted", + }, + sandboxCwd: pathToFileURL(f.target).href, + }, + "x-codex-turn-metadata": { model: "gpt-5.5", reasoning_effort: "high" }, + }; +} + +function server(f, env = f.env) { + return startRpc( + process.execPath, + [join(f.pluginRoot, "mcp", "server.mjs"), "--stdio"], + { + cwd: f.target, + env, + requestTimeoutMs: packageSmokeTimeouts().commandTimeoutMs, + }, + ); +} + +async function runDetachedPlugin(pluginRoot, executable) { + const f = await fixture("detached", pluginRoot, executable); + const owner = "package-detached-owner"; + f.env.PACKAGE_DEEP_HOLD = join(f.directory, "hold-second-worker"); + await writeFile(f.env.PACKAGE_DEEP_HOLD, "hold"); + let rpc = await server(f); + let scanId; + let scanDir; + let partial; + const handoffClaimToken = randomUUID(); + try { + const opened = await rpc.call( + "open_codex_security_workspace", + { + targetPath: f.target, + scope: ".", + mode: "deep", + }, + metadata(f, owner), + ); + const sessionId = opened.workspace.id; + await rpc.call( + "submit_codex_security_setup", + { + sessionId, + targetPath: f.target, + scope: ".", + mode: "deep", + }, + metadata(f, owner), + ); + const started = await rpc.call( + "start_codex_security_scan", + { sessionId }, + metadata(f, owner), + ); + ({ scanId, scanDir } = started.workspace.results); + await rpc.call( + "claim_codex_security_scan_handoff_delivery", + { + scanId, + claimToken: handoffClaimToken, + }, + metadata(f, owner), + ); + await rpc.call( + "attach_codex_security_scan_continuation_thread", + { + scanId, + claimToken: handoffClaimToken, + threadId: owner, + }, + metadata(f, owner), + ); + const pending = rpc + .call( + "start_codex_security_deep_scan", + { scanId, handoffClaimToken }, + metadata(f, owner), + ) + .catch((error) => error); + const deadline = Date.now() + 30_000; + while (!(await readExecutions(f)).some((entry) => entry.phase === "held")) { + assert.ok( + Date.now() < deadline, + "Second worker did not reach the interruption boundary.", + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + partial = ( + await workbench(f, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + owner, + ]) + ).deepScan; + assert.equal( + partial.workers.filter( + (worker) => + worker.kind === "discovery" && worker.status === "succeeded", + ).length, + 1, + ); + await rpc.close(); + await pending; + } finally { + await rpc.close(); + } + // Simulate an expired owner lease without waiting for wall-clock expiry. Keep + // the real stored workers/results and use the production recovery path. + await rm( + join( + scanDir, + "artifacts", + "deep_discovery", + `coordinator-heartbeat-${partial.coordinatorGeneration}.json`, + ), + { force: true }, + ); + await promisify(execFile)( + f.env.PYTHON, + [ + "-c", + "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('UPDATE deep_scan_runs SET updated_at = ? WHERE scan_id = ?', ('2000-01-01T00:00:00Z',sys.argv[2])); c.commit()", + join(f.env.CODEX_SECURITY_STATE_DIR, "workbench.sqlite3"), + scanId, + ], + { env: f.env }, + ); + await rm(f.env.PACKAGE_DEEP_HOLD); + rpc = await server(f); + try { + const result = await rpc.call( + "start_codex_security_deep_scan", + { scanId, handoffClaimToken }, + metadata(f, owner), + ); + await assertDraft(result.manifestPath); + const recovered = ( + await workbench(f, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + owner, + ]) + ).deepScan; + assert.equal( + recovered.coordinatorGeneration, + partial.coordinatorGeneration + 1, + ); + assert.equal(recovered.dispatchedCount, 2); + const retained = partial.workers.find( + (worker) => worker.status === "succeeded", + ); + assert.equal( + recovered.workers.find((worker) => worker.id === retained.id).status, + "succeeded", + ); + } finally { + await rpc.close(); + } + // Publication uses the saved aggregate after the recovered executor exits too. + rpc = await server(f); + try { + await rpc.call( + "complete_codex_security_scan", + { scanId, handoffClaimToken }, + metadata(f, owner), + ); + const completed = await rpc.call( + "get_codex_security_completed_scan", + { scanId, handoffClaimToken }, + metadata(f, owner), + ); + assert.equal(completed.manifest.scan.id, scanId); + assert.equal(completed.manifest.scan.status, "completed"); + assert.ok(completed.manifest.scan.sealedAt); + } finally { + await rpc.close(); + } + await assertExecutions(f, scanId, 4); +} + +async function workbench(f, args) { + const { stdout } = await promisify(execFile)( + f.env.PYTHON, + [join(f.pluginRoot, "scripts", "workbench_db.py"), ...args], + { + env: f.env, + cwd: f.target, + maxBuffer: 4 * 1024 * 1024, + }, + ); + return JSON.parse(stdout); +} + +async function runInstalledSdk(pluginRoot, executable) { + const f = await fixture("installed", pluginRoot, executable); + f.env.PACKAGE_DEEP_EMPTY_ONCE = join( + f.directory, + "missing-result-completion", + ); + const sdk = await import( + pathToFileURL(join(installedRoot, "dist", "index.js")).href + ); + const manifest = JSON.parse( + await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), + ); + const owner = "package-sdk-owner"; + let scanId; + const client = new sdk.CodexSecurity( + { pythonPath: f.env.PYTHON }, + { + environment: f.env, + prepareRuntime: async () => ({ + codexHome: f.home, + environment: f.env, + credentialsAvailable: true, + plugin: { + pluginRoot, + marketplaceRoot: pluginRoot, + installedRoot: pluginRoot, + marketplaceName: "codex-security-sdk", + name: manifest.name, + version: manifest.version, + }, + }), + // Replace only the parent model's tool choice. The installed SDK registers + // and finalizes the scan; the packaged MCP runs the real Deep lifecycle. + createCodex({ env, apiKey }) { + return { + startThread() { + return { + id: owner, + async runStreamed() { + return { + events: (async function* () { + yield { type: "thread.started", thread_id: owner }; + scanId = env.CODEX_SECURITY_SCAN_ID; + // The pinned SDK maps its apiKey option to this child variable. + const rpc = await server(f, { + ...env, + ...(apiKey ? { CODEX_API_KEY: apiKey } : {}), + }); + try { + const result = await rpc.call( + "start_codex_security_deep_scan", + { scanId }, + metadata(f, owner), + ); + await assertDraft(result.manifestPath); + } finally { + await rpc.close(); + } + yield { + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1, + }, + }; + })(), + }; + }, + }; + }, + }; + }, + }, + ); + try { + const result = await client.run(f.target, { + mode: "deep", + auth: "api-key", + workers: 1, + subagents: 0, + maxDiscoveryRuns: 2, + stopAfterNoNew: 1, + outputDir: join(f.directory, "output"), + }); + assert.equal(result.threadId, owner); + assert.equal(result.manifest.scan.status, "completed"); + assert.ok(result.manifest.scan.sealedAt); + assert.equal(result.manifest.scan.id, scanId); + assert.deepEqual(result.findings.findings, []); + assert.ok( + (await readFile(join(f.directory, "output", "report.md"), "utf8")) + .length > 0, + ); + } finally { + await client.close(); + } + await assertExecutions(f, scanId, 4); +} + +async function assertDraft(path) { + const document = JSON.parse(await readFile(path, "utf8")); + const findings = JSON.parse( + await readFile(join(dirname(path), "findings.json"), "utf8"), + ); + assert.deepEqual(findings.findings, []); + assert.ok(document.scan.target); +} + +async function readExecutions(f) { + try { + return (await readFile(f.env.PACKAGE_DEEP_TRACE, "utf8")) + .trim() + .split("\n") + .filter(Boolean) + .map(JSON.parse); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } +} + +async function assertExecutions(f, scanId, preflights = 3) { + const executions = await readExecutions(f); + const workers = executions.filter((entry) => entry.phase === "worker"); + const reducers = executions.filter((entry) => entry.phase === "reducer"); + const incomplete = f.env.PACKAGE_DEEP_EMPTY_ONCE ? 1 : 0; + assert.equal(workers.length, 2 + incomplete); + assert.equal(workers.filter((entry) => !entry.complete).length, incomplete); + assert.equal(workers.filter((entry) => entry.resumed).length, incomplete); + assert.equal(reducers.length, 1); + assert.equal( + executions.filter((entry) => entry.phase === "preflight").length, + preflights, + ); + for (const execution of [...workers, ...reducers]) { + assert.equal(execution.scanId, scanId); + assert.equal(execution.home, f.home); + assert.equal(execution.hasApiKey, true); + assert.equal( + execution.args[execution.args.indexOf("--model") + 1], + "gpt-5.5", + ); + assert.ok(execution.args.includes('approval_policy="never"')); + } +} diff --git a/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs b/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs new file mode 100644 index 000000000..e59440558 --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs @@ -0,0 +1,20 @@ +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { win32 } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Windows cannot execute the POSIX fixture's shebang. Preserve the selected +// native executable and its options, inserting only the deterministic protocol +// script, as in the worker launch tests. Every other child runs unchanged. +const executable = win32.toNamespacedPath(process.env.PACKAGE_DEEP_EXECUTABLE); +const script = fileURLToPath( + new URL("package-deep-codex.mjs", import.meta.url), +); +const spawn = childProcess.spawn; +childProcess.spawn = (command, args, options) => + spawn( + command, + win32.toNamespacedPath(command) === executable ? [script, ...args] : args, + options, + ); +syncBuiltinESMExports(); diff --git a/sdk/typescript/scripts/fixtures/package-rpc.mjs b/sdk/typescript/scripts/fixtures/package-rpc.mjs new file mode 100644 index 000000000..ceb6156f2 --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-rpc.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; + +// This client deliberately uses only Node builtins. Detached plugin tests must +// not resolve an MCP client or SDK from the checkout's node_modules. +export async function startRpc(command, args, options) { + const { requestTimeoutMs = 30_000, ...spawnOptions } = options; + const child = spawn(command, args, { + ...spawnOptions, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + let sequence = 0; + let stderr = ""; + const pending = new Map(); + child.stderr.setEncoding("utf8").on("data", (chunk) => { + stderr += chunk; + }); + createInterface({ input: child.stdout }).on("line", (line) => { + const response = JSON.parse(line); + const waiter = pending.get(response.id); + if (!waiter) return; + pending.delete(response.id); + clearTimeout(waiter.timer); + if (response.error) + waiter.reject(new Error(JSON.stringify(response.error))); + else waiter.resolve(response.result); + }); + const exited = once(child, "exit"); + child.on("exit", (code, signal) => { + for (const waiter of pending.values()) { + clearTimeout(waiter.timer); + waiter.reject( + new Error(`Fixture RPC exited (${code}, ${signal}): ${stderr}`), + ); + } + pending.clear(); + }); + const client = { + child, + request(method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++sequence; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Fixture RPC timed out: ${method}\n${stderr}`)); + }, requestTimeoutMs); + pending.set(id, { resolve, reject, timer }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, + ); + }); + }, + async call(name, args, meta) { + const result = await this.request("tools/call", { + name, + arguments: args, + ...(meta ? { _meta: meta } : {}), + }); + assert.notEqual(result.isError, true, JSON.stringify(result)); + return result.structuredContent ?? JSON.parse(result.content[0].text); + }, + async close() { + if (child.exitCode !== null || child.signalCode !== null) return; + child.stdin.end(); + const timeout = setTimeout(() => child.kill("SIGKILL"), 5_000); + try { + await exited; + } finally { + clearTimeout(timeout); + } + }, + }; + try { + await client.request("initialize", { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "installed-deep-fixture", version: "1.0.0" }, + }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`, + ); + return client; + } catch (error) { + await client.close(); + throw error; + } +} diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 60945f75d..39f834a5d 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -808,6 +808,15 @@ try { ); await smokeNestedDeepScanWorker(installedRoot, consumer); + run( + process.execPath, + [ + join(packageRoot, "scripts", "fixtures", "package-deep-scan.mjs"), + installedRoot, + ], + { cwd: consumer }, + ); + console.log( `Validated installed ${packageManifest.name}@${packageManifest.version}: public import, NodeNext types, CLI, SDK lifecycle, credential locking, ${expectedPluginFiles.length} bundled plugin files, MCP initialization, bundled Codex version, dashboard assets, and a nested worker without global codex.`, ); diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 425e486c4..db2b34c93 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -23,13 +23,15 @@ import { resolve, sep, } from "node:path"; -import { - Codex, - type CodexOptions, - type ThreadOptions, - type TurnOptions, -} from "@openai/codex-sdk"; +import { type CodexOptions, type ThreadOptions } from "@openai/codex-sdk"; import { z } from "incur"; +import { + createCodexClient, + readCodexSessionTurn, + type CodexSessionClient as CodexClientLike, + type CodexSessionThread as CodexThreadLike, + type CodexSessionEvent as ScanEvent, +} from "./codex-session.js"; import { CODEX_AUTH_CONFIG_KEYS, NO_CREDENTIALS_MESSAGE, @@ -216,24 +218,6 @@ import { validateMode, } from "./targets.js"; -interface CodexThreadLike { - readonly id: string | null; - runStreamed( - input: string, - options: TurnOptions, - ): Promise<{ events: AsyncGenerator }>; -} - -interface ScanEvent { - readonly type: string; - readonly [key: string]: unknown; -} - -interface CodexClientLike { - startThread(options: ThreadOptions): CodexThreadLike; - resumeThread?(threadId: string, options: ThreadOptions): CodexThreadLike; -} - interface PreparedRuntime { codexHome: string; persistentCredentialHome?: boolean; @@ -453,7 +437,7 @@ interface ClientDependencies { } const DEFAULT_DEPENDENCIES: ClientDependencies = { - createCodex: (options) => new Codex(options), + createCodex: createCodexClient, environment: process.env, }; @@ -3798,61 +3782,28 @@ async function readCodexTurn(options: { usage: unknown; lastStreamError: string | null; }> { - let threadId = options.thread.id; - let status: "in_progress" | "completed" = "in_progress"; - let finalResponse = ""; - let usage: unknown = null; - let lastStreamError: string | null = null; - for await (const event of eventsWithOptionalUsage(options.events)) { - await options.onEvent?.(event); - if ( - event.type === "thread.started" && - typeof event["thread_id"] === "string" - ) { - threadId = event["thread_id"]; - } else if ( - event.type === "item.completed" && - isRecord(event["item"]) && - event["item"]["type"] === "agent_message" && - typeof event["item"]["text"] === "string" - ) { - finalResponse = event["item"]["text"]; - } else if (event.type === "turn.completed") { - status = "completed"; - usage = event["usage"]; - } else if (event.type === "turn.failed") { - throw new CodexSecurityError(turnFailureMessage(event["error"])); - } else if (event.type === "error" && typeof event["message"] === "string") { - const message = event["message"]; - const classification = classifyConnectionFailure(message); - if (classification === "unauthorized" || classification === "forbidden") { - throw new CodexSecurityError(message); + return readCodexSessionTurn({ + ...options, + onEvent: async (event) => { + await options.onEvent?.(event); + if (event.type === "turn.failed") { + throw new CodexSecurityError(turnFailureMessage(event["error"])); } - const reconnect = reconnectAttempt(message); - if (reconnect === null) throw new CodexSecurityError(message); - lastStreamError = message; - options.onReconnect?.(message, reconnect); - } - } - return { threadId, status, finalResponse, usage, lastStreamError }; -} - -async function* eventsWithOptionalUsage( - events: AsyncGenerator, -): AsyncGenerator { - try { - yield* events; - } catch (error) { - if ( - error instanceof TypeError && - /\b(?:null|undefined)\b/u.test(error.message) && - /\bcache_write_input_tokens\b/u.test(error.message) - ) { - yield { type: "turn.completed", usage: null }; - return; - } - throw error; - } + if (event.type === "error" && typeof event["message"] === "string") { + const message = event["message"]; + const classification = classifyConnectionFailure(message); + if ( + classification === "unauthorized" || + classification === "forbidden" + ) { + throw new CodexSecurityError(message); + } + const reconnect = reconnectAttempt(message); + if (reconnect === null) throw new CodexSecurityError(message); + options.onReconnect?.(message, reconnect); + } + }, + }); } function trustedAccessStatusFromEvent( diff --git a/sdk/typescript/src/codex-session.ts b/sdk/typescript/src/codex-session.ts new file mode 100644 index 000000000..630df3625 --- /dev/null +++ b/sdk/typescript/src/codex-session.ts @@ -0,0 +1,94 @@ +import { + Codex, + type CodexOptions, + type ThreadOptions, + type TurnOptions, +} from "@openai/codex-sdk"; + +export interface CodexSessionEvent { + readonly type: string; + readonly [key: string]: unknown; +} + +export interface CodexSessionThread { + readonly id: string | null; + runStreamed( + input: string, + options: TurnOptions, + ): Promise<{ events: AsyncGenerator }>; +} + +export interface CodexSessionClient { + startThread(options: ThreadOptions): CodexSessionThread; + resumeThread?(threadId: string, options: ThreadOptions): CodexSessionThread; +} + +export const createCodexClient = (options: CodexOptions): CodexSessionClient => + new Codex(options); + +/** Reduce a single stream; callers retain error, retry and acceptance policy. */ +export async function readCodexSessionTurn(options: { + thread: CodexSessionThread; + events: AsyncGenerator; + onEvent: (event: CodexSessionEvent) => Promise | void; + stopOnCompletion?: boolean; +}): Promise<{ + threadId: string | null; + status: "in_progress" | "completed"; + finalResponse: string; + usage: unknown; + lastStreamError: string | null; +}> { + let threadId = options.thread.id; + let status: "in_progress" | "completed" = "in_progress"; + let finalResponse = ""; + let usage: unknown = null; + let lastStreamError: string | null = null; + for await (const event of eventsWithOptionalUsage(options.events)) { + await options.onEvent(event); + if ( + event.type === "thread.started" && + typeof event["thread_id"] === "string" + ) { + threadId = event["thread_id"]; + } else if ( + event.type === "item.completed" && + isRecord(event["item"]) && + event["item"]["type"] === "agent_message" && + typeof event["item"]["text"] === "string" + ) { + finalResponse = event["item"]["text"]; + } else if (event.type === "turn.completed") { + status = "completed"; + usage = event["usage"] ?? null; + if (options.stopOnCompletion) break; + } else if (event.type === "error" && typeof event["message"] === "string") { + lastStreamError = event["message"]; + } + } + return { threadId, status, finalResponse, usage, lastStreamError }; +} + +async function* eventsWithOptionalUsage( + events: AsyncGenerator, +): AsyncGenerator { + try { + yield* events; + } catch (error) { + // The pinned SDK accesses this field before yielding a completion with + // absent usage. Preserve completion without inventing a zero-token receipt. + if ( + error instanceof TypeError && + /\b(?:null|undefined)\b/u.test(error.message) && + /\bcache_write_input_tokens\b/u.test(error.message) + ) { + yield { type: "turn.completed", usage: null }; + return; + } + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 68fa92e1a..227d13709 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -7298,6 +7298,167 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(scanSignal?.aborted).toBe(false); }); + test.each(["standard", "deep"] as const)( + "isolates concurrent managed %s sessions at the Codex child boundary", + async (mode) => { + const clients: TestClient[] = []; + try { + const outcomes = await Promise.allSettled( + ["first", "second"].map(async (name) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + const preload = join(root, "fake-codex.mjs"); + const marker = join(root, "invocation.jsonl"); + await Promise.all([ + mkdir(repository), + mkdir(codexHome), + mkdir(scanDir, { mode: 0o700 }), + ]); + await writeFile( + preload, + [ + 'import { appendFileSync } from "node:fs";', + 'let prompt = ""; for await (const chunk of process.stdin) prompt += chunk;', + `appendFileSync(${JSON.stringify(marker)}, JSON.stringify({args:process.argv, executable:process.execPath, home:process.env.CODEX_HOME, key:process.env.CODEX_API_KEY, value:process.env.FIXTURE_SCAN_VALUE, prompt}) + "\\n");`, + `console.log(JSON.stringify({type:"thread.started",thread_id:${JSON.stringify(`fixture-${name}-thread`)}}));`, + 'console.log(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"scan complete"}}));', + 'console.log(JSON.stringify({type:"turn.completed",usage:null}));', + "process.exit(0);", + ].join("\n"), + ); + const fake = nodeCodex(preload); + const model = `fixture-${name}-model`; + const provider = `fixture-${name}-provider`; + const client = new TestClient( + { + codexOverrides: { + model, + model_provider: provider, + model_reasoning_effort: "ultra", + model_reasoning_summary: + name === "first" ? "none" : "concise", + features: { + multi_agent_v2: { max_concurrent_threads_per_session: 4 }, + }, + }, + }, + { + environment: { + OPENAI_API_KEY: `synthetic-${name}-key`, + CODEX_CLI_PATH: fake.command.command, + }, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment: { + ...fake.environment, + FIXTURE_SCAN_VALUE: name, + }, + }), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => { + const codex = new Codex(options); + return { + startThread: (threadOptions: ThreadOptions) => { + const thread = codex.startThread(threadOptions); + return { + get id() { + return thread.id; + }, + runStreamed: async ( + ...args: Parameters + ) => { + if (thread.id === null) { + await copyCompletedScan(root); + if (mode === "deep") { + const coveragePath = join( + scanDir, + "coverage.json", + ); + const coverage = JSON.parse( + await readFile(coveragePath, "utf8"), + ); + coverage.mode = "deep_repository"; + const coverageBytes = JSON.stringify(coverage); + await writeFile(coveragePath, coverageBytes); + const manifestPath = join( + scanDir, + "scan-manifest.json", + ); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ); + manifest.scan.artifacts.find( + (artifact: { path: string }) => + artifact.path === "coverage.json", + ).sha256 = createHash("sha256") + .update(coverageBytes) + .digest("hex"); + await writeFile( + manifestPath, + JSON.stringify(manifest), + ); + } + } + return thread.runStreamed(...args); + }, + }; + }, + }; + }, + }, + ); + clients.push(client); + const postScanPrompt = "Summarize the completed synthetic scan."; + const result = await client.run(repository, { + mode, + postScanPrompt, + }); + expect(result.threadId).toBe(`fixture-${name}-thread`); + expect(result.turnResult.usage).toBeNull(); + const children = (await readFile(marker, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(children).toHaveLength(2); + expect(children[1].prompt).toBe(postScanPrompt); + expect(children[1].args).toContain("resume"); + expect(children[1].args).toContain(`fixture-${name}-thread`); + for (const child of children) { + expect(child.executable).toBe(fake.command.command); + expect(child.home).toBe(codexHome); + expect(child.key).toBe(`synthetic-${name}-key`); + expect(child.value).toBe(name); + expect(child.args).toContain(`model=${JSON.stringify(model)}`); + expect(child.args).toContain( + `model_provider=${JSON.stringify(provider)}`, + ); + expect(child.args).toContain('model_reasoning_effort="ultra"'); + expect(child.args).toContain( + `model_reasoning_summary=${JSON.stringify(name === "first" ? "none" : "concise")}`, + ); + expect(child.args).toContain( + "features.multi_agent_v2.max_concurrent_threads_per_session=4", + ); + expect(child.args).toContain( + 'default_permissions="codex_security_scan"', + ); + expect(child.args).toContain('approval_policy="on-request"'); + } + }), + ); + for (const outcome of outcomes) { + if (outcome.status === "rejected") throw outcome.reason; + } + } finally { + await Promise.all(clients.map((client) => client.close())); + } + }, + ); + test("closes a real Codex subprocess cleanly after a streamed terminal failure", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts index c23adfb4d..a2a74d36d 100644 --- a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts @@ -31,6 +31,18 @@ async function bundledWorkerExecutor( if (source === undefined) { throw new Error("Bundled Deep Scan worker executor was not found."); } + const sessionSource = + /\n\/\/ [^\n]*\/codex-session\.ts\n([\s\S]*?)(?=\n\/\/)/u.exec( + runtime, + )?.[1]; + expect(sessionSource).toBeDefined(); + const recordFunction = /\b(isRecord\d*)\(/u.exec(source)?.[1]; + expect(recordFunction).toBeDefined(); + const recordSource = new RegExp( + `function ${recordFunction}\\([^\\n]*\\) \\{[\\s\\S]*?\\n\\}`, + "u", + ).exec(runtime)?.[0]; + expect(recordSource).toBeDefined(); const fileSystemImport = /\b(import_node_fs\d*)\.promises\.readFile\(/u.exec( source, )?.[1]; @@ -54,7 +66,8 @@ async function bundledWorkerExecutor( "workerPermissionProfile", "workerPermissionProfileConfigOverrides", "snapshotWorkerEnvironment", - "workerReasoningSummary", + "workerModelConfig", + "workerModelSelection", "environmentVariable", "preflightDeepScanWorkerPermissionProfile", "DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID", @@ -64,14 +77,15 @@ async function bundledWorkerExecutor( "workerSubagentConfig", "appendSafeItemDiagnostic", "classifyCodexWorkerError", - `${source}\nreturn CodexSdkWorkerExecutor;`, + `${sessionSource}\n${recordSource}\n${source}\nreturn CodexSdkWorkerExecutor;`, )( FakeCodex, { promises: { readFile: async () => "fixture worker prompt" } }, () => ({}), () => [], async () => ({}), - async () => undefined, + async () => ({}), + () => ({}), () => undefined, preflight, "codex_security_deep_scan_worker", From 036dfa4e29f259105fc721acca95f19c7a3ce822 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 08:39:23 +0000 Subject: [PATCH 02/18] test: preserve the Node runtime location in worker fixtures --- .../mcp-app/tests/test_deep_scan_executor.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index f989542a0..b04815bfa 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -761,7 +761,11 @@ async function testIsolatedReconstructedWorkers() { await writeFile(configPath, Object.entries(config).map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); - await copyFile(process.execPath, executable); + if (process.platform === "win32") { + await copyFile(process.execPath, executable); + } else { + await symlink(process.execPath, executable); + } const codexOptions = { codexPathOverride: executable, baseUrl: `https://${name}.example.invalid/v1`, @@ -785,6 +789,7 @@ async function testIsolatedReconstructedWorkers() { } childProcess.spawn = (command, args, options) => { const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); + if (scan) assert.equal(command, path.toNamespacedPath(scan.settings.codexOptions.codexPathOverride)); return originalSpawn(command, scan ? [scan.fixture.executablePath, ...args] : args, options); }; syncBuiltinESMExports(); @@ -817,7 +822,7 @@ async function testIsolatedReconstructedWorkers() { assert.equal(result.threadId, resumeThreadId ?? "fixture-thread-id"); const child = JSON.parse(await readFile(scan.fixture.markerPath, "utf8")); const preflight = JSON.parse(await readFile(scan.fixture.preflightMarkerPath, "utf8")); - assert.equal(child.executable, scan.settings.codexOptions.codexPathOverride); + assert.equal(await realpath(child.executable), await realpath(scan.settings.codexOptions.codexPathOverride)); assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); assert.equal(preflight.codexHome, child.codexHome); assert.equal(child.scanValue, `${scan.name}-${phase}`); From f6f99f997ee7e1ac9b0d200f9144a0a52b19dced Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:32:48 +0000 Subject: [PATCH 03/18] test: match Windows managed child executable paths --- sdk/typescript/tests-ts/api.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 227d13709..c946cb9e9 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -7428,7 +7428,11 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(children[1].args).toContain("resume"); expect(children[1].args).toContain(`fixture-${name}-thread`); for (const child of children) { - expect(child.executable).toBe(fake.command.command); + expect(child.executable).toBe( + process.platform === "win32" + ? win32.toNamespacedPath(fake.command.command) + : fake.command.command, + ); expect(child.home).toBe(codexHome); expect(child.key).toBe(`synthetic-${name}-key`); expect(child.value).toBe(name); From 1f16e853dcf6faf097fb73f01f67fc61d5ebb73e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:39:26 +0000 Subject: [PATCH 04/18] test: resolve shared SDK imports in MCP source fixtures --- plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs | 1 + .../mcp-app/tests/test_artifact_storage_regressions.mjs | 1 + .../mcp-app/tests/test_compact_artifact_server.mjs | 1 + plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs | 1 + .../mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs | 1 + .../mcp-app/tests/test_workbench_state_fallback.mjs | 1 + 6 files changed, 6 insertions(+) diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs index 0e5b33efd..3a3009955 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs @@ -21,6 +21,7 @@ try { await writeFile(path.join(repository, "example.py"), "value = 1\n"); await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": "__filename" }, entryPoints: [path.join(applicationRoot, "main.ts")], external: ["fsevents"], diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_storage_regressions.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_storage_regressions.mjs index 3ce725944..53059a459 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_storage_regressions.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_storage_regressions.mjs @@ -29,6 +29,7 @@ await fs.mkdir(repository); await fs.writeFile(path.join(repository, "example.py"), "value = 1\n"); await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": "__filename" }, entryPoints: [path.join(applicationRoot, "main.ts")], external: ["fsevents"], format: "cjs", loader: { ".md": "text" }, diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs index aa7937bd5..af2138c5b 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs @@ -1229,6 +1229,7 @@ async function testReducerWorkerToolList(bundle) { async function bundleEntrypoint(entrypoint, outfile) { await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": "__filename" diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index b04815bfa..035fd21d5 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -10,6 +10,7 @@ import { build } from "esbuild"; const executorSource = new URL("../src/deep-scan/executor.ts", import.meta.url); const bundle = await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { "import.meta.url": JSON.stringify(executorSource.href) }, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 7d8ca664a..61a90ce26 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -580,6 +580,7 @@ async function testDeepScanStdioLifecycle() { async function bundleServer(outfile) { await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { "import.meta.url": "__filename" }, entryPoints: [path.join(mcpAppRoot, "main.ts")], external: ["fsevents"], diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.mjs b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.mjs index e6cea4f54..960a30d0d 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.mjs +++ b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.mjs @@ -29,6 +29,7 @@ async function testWorkbenchStateFallback() { await writeFakePython(fakePythonPath); await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { "import.meta.url": "__filename" }, entryPoints: [path.join(mcpAppRoot, "main.ts")], external: ["fsevents"], From 2135d4c32372f5fd4d7acf1f9839926c450944c7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:29:32 +0000 Subject: [PATCH 05/18] fix: resolve shared Codex imports in standalone MCP builds --- .../mcp-app/scripts/build_mcp_app.mjs | 1 + plugins/codex-security/mcp-app/tsconfig.json | 3 + sdk/typescript/tests-ts/build-plugin.test.ts | 75 ++++++++++++++++--- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs index 94022da4f..ec49e0305 100644 --- a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs +++ b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs @@ -41,6 +41,7 @@ export async function buildMcpApp({ output }) { loader: { ".md": "text" }, logLevel: "info", logOverride: { "empty-import-meta": "silent" }, + nodePaths: [join(root, "node_modules")], outfile: bundle, platform: "node", target: "node20" diff --git a/plugins/codex-security/mcp-app/tsconfig.json b/plugins/codex-security/mcp-app/tsconfig.json index ef0922f6b..7873e0584 100644 --- a/plugins/codex-security/mcp-app/tsconfig.json +++ b/plugins/codex-security/mcp-app/tsconfig.json @@ -5,6 +5,9 @@ "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, + "paths": { + "@openai/codex-sdk": ["./node_modules/@openai/codex-sdk"] + }, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index ecd012ee1..617498d37 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -1,12 +1,15 @@ import { execFile } from "node:child_process"; import { chmod, + copyFile, + cp, mkdir, mkdtemp, readFile, readdir, rm, stat, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -69,8 +72,58 @@ afterEach(async () => { }); describe("bundled plugin build", () => { - test("builds the MCP runtime without invoking an npm launcher", async () => { + test("builds the MCP runtime with only MCP dependencies and no npm launcher", async () => { const root = await temporaryDirectory(); + const plugin = join(root, "plugins", "codex-security"); + const mcp = join(plugin, "mcp-app"); + const sdk = join(root, "sdk", "typescript"); + const source = new URL("../../../plugins/codex-security/", import.meta.url); + await mkdir(mcp, { recursive: true }); + await mkdir(sdk, { recursive: true }); + for (const name of [ + "package.json", + "tsconfig.json", + "main.ts", + "artifact-writer-main.ts", + "helpers-main.ts", + "server.ts", + "src", + "scripts", + "templates", + ]) { + await cp(new URL(`mcp-app/${name}`, source), join(mcp, name), { + recursive: true, + }); + } + for (const name of [ + "schemas", + "native/prebuilt", + "plugin-files.json", + "scripts/reserved_artifact_paths.json", + ]) { + await cp(new URL(name, source), join(plugin, name), { recursive: true }); + } + for (const name of await readdir(new URL("native/", source))) { + if (/\.(?:mjs|mts)$/.test(name)) { + await copyFile( + new URL(`native/${name}`, source), + join(plugin, "native", name), + ); + } + } + for (const name of ["src", "package.json", "tsconfig.json"]) { + await cp(new URL(`../${name}`, import.meta.url), join(sdk, name), { + recursive: true, + }); + } + await symlink( + fileURLToPath(new URL("mcp-app/node_modules", source)), + join(mcp, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + await expect(stat(join(sdk, "node_modules"))).rejects.toMatchObject({ + code: "ENOENT", + }); const bin = join(root, "bin"); const launcher = process.platform === "win32" ? "npm.cmd" : "npm"; await writeFixture( @@ -83,24 +136,24 @@ describe("bundled plugin build", () => { const destination = join(root, "mcp"); await execFileAsync( "node", - [ - fileURLToPath( - new URL( - "../../../plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs", - import.meta.url, - ), - ), - "--output", - destination, - ], + [join(mcp, "scripts", "build_mcp_app.mjs"), "--output", destination], { env: { ...process.env, + NODE_PATH: "", PATH: [bin, process.env["PATH"]].filter(Boolean).join(delimiter), }, }, ); + await execFileAsync("node", [ + "--eval", + "require('node:fs').unlinkSync(process.argv[1])", + join(mcp, "node_modules"), + ]); + await expect(stat(join(mcp, "node_modules"))).rejects.toMatchObject({ + code: "ENOENT", + }); const contract = JSON.parse( await readFile( new URL( From 6e425e4666a7adc193b68a332d3f6c7675ff84f2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 06:45:39 +0000 Subject: [PATCH 06/18] Use the Codex client directly at execution boundaries --- .../codex-security/mcp-app/src/deep-scan/executor.ts | 7 +++---- sdk/typescript/src/api.ts | 5 ++--- sdk/typescript/src/codex-session.ts | 10 +--------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 51db8dedd..c56f199d0 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -2,10 +2,9 @@ import { accessSync, constants as fsConstants, existsSync, promises as fs, readd import { createRequire } from "node:module"; import { delimiter, dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { - createCodexClient, readCodexSessionTurn } from "../../../../../sdk/typescript/src/codex-session.js"; -import type { CodexOptions } from "@openai/codex-sdk"; +import { Codex, type CodexOptions } from "@openai/codex-sdk"; import { parse as parseToml } from "smol-toml"; import { executablePathForSpawn } from "./executable-path.js"; import { @@ -104,7 +103,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { signal: request.signal }); const prompt = await fs.readFile(request.promptPath, "utf8"); - const codex = createCodexClient({ + const codex = new Codex({ ...resolved, codexPathOverride: executablePathForSpawn(codexPath), env: childEnv, @@ -134,7 +133,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { workingDirectory: request.workingDirectory } as const; const thread = request.resumeThreadId - ? codex.resumeThread!(request.resumeThreadId, threadOptions) + ? codex.resumeThread(request.resumeThreadId, threadOptions) : codex.startThread(threadOptions); const input = request.resumeThreadId ? request.continuationPrompt ?? prompt diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 3fc5de5fb..48895b86d 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -23,10 +23,9 @@ import { resolve, sep, } from "node:path"; -import { type CodexOptions, type ThreadOptions } from "@openai/codex-sdk"; +import { Codex, type CodexOptions, type ThreadOptions } from "@openai/codex-sdk"; import { z } from "incur"; import { - createCodexClient, readCodexSessionTurn, type CodexSessionClient as CodexClientLike, type CodexSessionThread as CodexThreadLike, @@ -437,7 +436,7 @@ interface ClientDependencies { } const DEFAULT_DEPENDENCIES: ClientDependencies = { - createCodex: createCodexClient, + createCodex: (options) => new Codex(options), environment: process.env, }; diff --git a/sdk/typescript/src/codex-session.ts b/sdk/typescript/src/codex-session.ts index 630df3625..9be3dc402 100644 --- a/sdk/typescript/src/codex-session.ts +++ b/sdk/typescript/src/codex-session.ts @@ -1,9 +1,4 @@ -import { - Codex, - type CodexOptions, - type ThreadOptions, - type TurnOptions, -} from "@openai/codex-sdk"; +import type { ThreadOptions, TurnOptions } from "@openai/codex-sdk"; export interface CodexSessionEvent { readonly type: string; @@ -23,9 +18,6 @@ export interface CodexSessionClient { resumeThread?(threadId: string, options: ThreadOptions): CodexSessionThread; } -export const createCodexClient = (options: CodexOptions): CodexSessionClient => - new Codex(options); - /** Reduce a single stream; callers retain error, retry and acceptance policy. */ export async function readCodexSessionTurn(options: { thread: CodexSessionThread; From 3bbd4bd485e1bcec86f22bd983fb9969bde7360c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 06:54:25 +0000 Subject: [PATCH 07/18] Format Codex SDK import --- sdk/typescript/src/api.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 48895b86d..0c048dea6 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -23,7 +23,11 @@ import { resolve, sep, } from "node:path"; -import { Codex, type CodexOptions, type ThreadOptions } from "@openai/codex-sdk"; +import { + Codex, + type CodexOptions, + type ThreadOptions, +} from "@openai/codex-sdk"; import { z } from "incur"; import { readCodexSessionTurn, From e230677ee17f079ff28b975c3c6603715c573c1e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 07:15:09 +0000 Subject: [PATCH 08/18] Test worker shutdown at the real child-process boundary --- .../mcp-app/tests/test_deep_scan_executor.mjs | 173 ++++++----- .../deep-scan-worker-shutdown.test.ts | 270 ++---------------- 2 files changed, 113 insertions(+), 330 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 035fd21d5..6c1b56cb5 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -77,10 +77,11 @@ try { await testWorkerReasoningSummaries(); await testWorkerProviderSelection(); await testIsolatedReconstructedWorkers(); + await testWorkerCancellation(); + await testDisallowedWorkerProfileFailsBeforeWorkerLaunch(); if (process.platform !== "win32") await testNullUsageCompletion(); if (process.platform !== "win32") { await testMissingParentSandboxFailsBeforeWorkerLaunch(); - await testDisallowedWorkerProfileFailsBeforeWorkerLaunch(); await testRuntimePermissionProfileFallbackStopsAndDiscards(); await testWorkerLaunchesWithoutGlobalCodex(); await testPreflightBindsExecutableAndHomeBeforeChangingCwd(); @@ -93,8 +94,6 @@ try { await testSandboxNamespaceDiagnosticIsSanitized(); await testOwnedArtifactToolFailureDiagnosticIsSanitized(); await testStreamTerminationWithoutTerminalEventFails(); - await testCompletedWorkerSettlesWithoutWaitingForProcessExit(); - await testAbortPropagation(); await testConfigurationFailureIsNonRetryable(); await testThreadStartConfigurationFailureIsNonRetryable(); await testPolicyFailuresAreNonRetryable(); @@ -1298,88 +1297,94 @@ async function testStreamTerminationWithoutTerminalEventFails() { } } -async function testAbortPropagation() { +async function testWorkerCancellation() { const fixture = await fakeCodexFixture(); const previousPath = process.env.CODEX_CLI_PATH; - process.env.CODEX_CLI_PATH = fixture.executablePath; - try { - const promptPath = path.join(fixture.root, "prompt.md"); - const workingDirectory = path.join(fixture.root, "artifacts"); - await mkdir(workingDirectory); - await writeFile(promptPath, "BLOCK_AFTER_START\n"); - const abortController = new AbortController(); - const execution = new CodexSdkWorkerExecutor({ - parentSandbox: trustedParentSandbox - }).run({ - kind: "discovery", - promptPath, - workingDirectory, - subagents: 0, - signal: abortController.signal, - onThreadStarted: () => abortController.abort("fixture cancellation") - }); - await assert.rejects(execution, (error) => error?.name === "AbortError" || /abort|SIGTERM/i.test(error?.message ?? "")); - } finally { - restoreEnv("CODEX_CLI_PATH", previousPath); - } -} - -async function testCompletedWorkerSettlesWithoutWaitingForProcessExit() { - const fixture = await fakeCodexFixture(); - const previousPath = process.env.CODEX_CLI_PATH; - process.env.CODEX_CLI_PATH = fixture.executablePath; - const controller = new AbortController(); - const unexpectedErrors = []; - const captureUnexpectedError = (error) => unexpectedErrors.push(error); - let execution; + const originalSpawn = childProcess.spawn; + let worker; + let workerSignal; + let cancelDuringCleanup; let timeout; - let childPid; - - process.on("uncaughtException", captureUnexpectedError); + childProcess.spawn = (command, args, options) => { + const child = originalSpawn(command, command === process.execPath || command === path.toNamespacedPath(process.execPath) + ? [fixture.executablePath, ...args] : args, options); + if (args[0] === "exec") { + assertFlagPair(args, "--thread-source", "security_scan"); + worker = child; + workerSignal = options.signal; + const kill = child.kill; + child.kill = function (...args) { + cancelDuringCleanup?.(); + return kill.apply(this, args); + }; + } + return child; + }; + syncBuiltinESMExports(); + process.env.CODEX_CLI_PATH = process.execPath; try { const promptPath = path.join(fixture.root, "prompt.md"); const workingDirectory = path.join(fixture.root, "artifacts"); await mkdir(workingDirectory); - await writeFile(promptPath, "COMPLETE_THEN_HANG\n"); - execution = new CodexSdkWorkerExecutor({ - parentSandbox: trustedParentSandbox - }).run({ - kind: "discovery", - promptPath, - workingDirectory, - subagents: 0, - signal: controller.signal + const executor = new CodexSdkWorkerExecutor({ parentSandbox: trustedParentSandbox }); + const run = (controller, onThreadStarted) => executor.run({ + kind: "discovery", promptPath, workingDirectory, subagents: 0, + signal: controller.signal, onThreadStarted }); - - const result = await Promise.race([ - execution, - new Promise((_, reject) => { - timeout = setTimeout(() => { - controller.abort("completed worker fixture timed out"); - reject(new Error("completed worker did not settle after turn.completed")); - }, 1_000); - }) - ]); - clearTimeout(timeout); - assert.equal(result.threadId, "fixture-thread-id"); - assert.equal(result.finalResponse, "fixture final response"); - childPid = JSON.parse(await readFile(fixture.markerPath, "utf8")).pid; - - controller.abort("coordinator immediately canceled its remaining workers"); - await new Promise((resolve) => setTimeout(resolve, 250)); - - assert.deepEqual(unexpectedErrors, []); - assert.throws(() => process.kill(childPid, 0), { code: "ESRCH" }); + const preAborted = new AbortController(); + const beforeStartup = new Error("coordinator canceled before worker startup"); + preAborted.abort(beforeStartup); + await assert.rejects(run(preAborted), (error) => error === beforeStartup); + assert.equal(worker, undefined); + await assert.rejects(readFile(fixture.preflightMarkerPath), { code: "ENOENT" }); + await assert.rejects(readFile(fixture.markerPath), { code: "ENOENT" }); + + for (const prompt of ["BLOCK_AFTER_START", "COMPLETE_THEN_HANG", "FAIL_THEN_HANG"]) { + await writeFile(promptPath, `${prompt}\n`); + const controller = new AbortController(); + const cancellation = new Error("coordinator canceled its remaining workers"); + // Exercise cancellation inside the real SDK's iterator cleanup, before kill returns. + cancelDuringCleanup = prompt === "COMPLETE_THEN_HANG" + ? () => controller.abort(cancellation) : undefined; + const deadline = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`${prompt} worker did not settle`)), 10_000); + }); + const execution = Promise.race([ + run(controller, () => { + if (prompt === "BLOCK_AFTER_START") controller.abort(cancellation); + }), + deadline + ]); + if (prompt === "BLOCK_AFTER_START") { + await assert.rejects(execution, (error) => error?.name === "AbortError" || /abort|SIGTERM/i.test(error?.message ?? "")); + assert.equal(workerSignal.aborted, true); + assert.equal(workerSignal.reason, cancellation); + } else { + if (prompt === "FAIL_THEN_HANG") { + await assert.rejects(execution, /fixture worker failed/); + controller.abort(cancellation); + } else { + const result = await execution; + assert.equal(result.threadId, "fixture-thread-id"); + assert.equal(result.finalResponse, "fixture final response"); + } + assert.equal(controller.signal.aborted, true); + assert.equal(workerSignal.aborted, false); + } + assert.notEqual(workerSignal, controller.signal); + assert.equal(worker.killed, true); + if (worker.exitCode === null && worker.signalCode === null) { + await Promise.race([new Promise((resolve) => worker.once("close", resolve)), deadline]); + } + clearTimeout(timeout); + cancelDuringCleanup = undefined; + } } finally { clearTimeout(timeout); - if (!controller.signal.aborted) controller.abort("completed worker fixture cleanup"); - await execution?.catch(() => {}); - if (childPid) { - try { - process.kill(childPid, "SIGKILL"); - } catch {} - } - process.removeListener("uncaughtException", captureUnexpectedError); + cancelDuringCleanup = undefined; + if (worker && worker.exitCode === null && worker.signalCode === null) worker.kill("SIGKILL"); + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); restoreEnv("CODEX_CLI_PATH", previousPath); } } @@ -1580,7 +1585,15 @@ async function testMissingParentSandboxFailsBeforeWorkerLaunch() { async function testDisallowedWorkerProfileFailsBeforeWorkerLaunch() { const fixture = await fakeCodexFixture(emptyWorkerPermissionProfile, false); const previousPath = process.env.CODEX_CLI_PATH; - process.env.CODEX_CLI_PATH = fixture.executablePath; + const originalSpawn = childProcess.spawn; + childProcess.spawn = (command, args, options) => originalSpawn( + command, + command === process.execPath || command === path.toNamespacedPath(process.execPath) + ? [fixture.executablePath, ...args] : args, + options + ); + syncBuiltinESMExports(); + process.env.CODEX_CLI_PATH = process.execPath; try { const promptPath = path.join(fixture.root, "prompt.md"); const workingDirectory = path.join(fixture.root, "artifacts"); @@ -1613,6 +1626,8 @@ async function testDisallowedWorkerProfileFailsBeforeWorkerLaunch() { (error) => error?.code === "ENOENT" ); } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); restoreEnv("CODEX_CLI_PATH", previousPath); } } @@ -1714,8 +1729,7 @@ async function fakeCodexFixture( "for await (const chunk of process.stdin) stdin += chunk;", "const openaiAuthentication = stdin.includes('CAPTURE_SYNTHETIC_OPENAI_AUTH') ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY } : undefined;", "const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;", - "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", - "if (stdin.includes('COMPLETE_THEN_HANG')) process.on('SIGTERM', () => setTimeout(() => process.exit(0), 100));", + "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", "if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); }", "if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); }", "if (stdin.includes('MCP_STARTUP_TIMEOUT') || stdin.includes('CATALOG_AUTH_ONLY') || stdin.includes('SYNC_AUTH_ONLY')) {", @@ -1738,7 +1752,8 @@ async function fakeCodexFixture( "const permissionProfileFallbackWarning = 'Configured value for `permission_profile` is disallowed by requirements; falling back from `codex_security_deep_scan_worker` to required value `:read-only`.';", "if (stdin.includes('PERMISSION_PROFILE_FALLBACK_ITEM')) console.log(JSON.stringify({ type: 'item.completed', item: { id: 'warning-1', type: 'error', message: permissionProfileFallbackWarning } }));", "if (stdin.includes('PERMISSION_PROFILE_FALLBACK_EVENT')) console.log(JSON.stringify({ type: 'error', message: permissionProfileFallbackWarning }));", - "if (stdin.includes('BLOCK_AFTER_START')) await new Promise(() => {});", + "if (stdin.includes('BLOCK_AFTER_START')) { setInterval(() => {}, 1_000); await new Promise(() => {}); }", + "if (stdin.includes('FAIL_THEN_HANG')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'fixture worker failed' } })); setInterval(() => {}, 1_000); await new Promise(() => {}); }", "if (stdin.includes('RATE_LIMIT_CYBER_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: '429 Too Many Requests: Request blocked by cyberPolicy.' } })); process.exit(0); }", "if (stdin.includes('CYBER_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'Request blocked by cyberPolicy.' } })); process.exit(0); }", "if (stdin.includes('SAFETY_POLICY_ERROR')) { console.log(JSON.stringify({ type: 'turn.failed', error: { message: 'Request blocked by a safety policy violation.' } })); process.exit(0); }", diff --git a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts index a2a74d36d..f2b72e401 100644 --- a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts @@ -1,252 +1,20 @@ -import { expect, test } from "bun:test"; -import { loadBundledRuntime } from "./plugin-root.js"; - -type WorkerEvent = - | { type: "thread.started"; thread_id: string } - | { type: "item.completed"; item: { type: "agent_message"; text: string } } - | { type: "turn.completed" } - | { type: "turn.failed"; error: { message: string } }; - -type WorkerExecutorConstructor = new (settings: { - parentSandbox: { filesystemDenies: string[] }; -}) => { - run(request: { - kind: "discovery"; - promptPath: string; - workingDirectory: string; - subagents: number; - signal: AbortSignal; - onThreadStarted?: () => void; - }): Promise<{ finalResponse: string; threadId?: string }>; -}; - -async function bundledWorkerExecutor( - events: (signal: AbortSignal) => AsyncGenerator, - preflight = async () => ({ useOpenAiApiKey: false }), -): Promise { - const runtime = await loadBundledRuntime(); - const source = /var CodexSdkWorkerExecutor = class \{[\s\S]*?\n\};/u.exec( - runtime, - )?.[0]; - if (source === undefined) { - throw new Error("Bundled Deep Scan worker executor was not found."); - } - const sessionSource = - /\n\/\/ [^\n]*\/codex-session\.ts\n([\s\S]*?)(?=\n\/\/)/u.exec( - runtime, - )?.[1]; - expect(sessionSource).toBeDefined(); - const recordFunction = /\b(isRecord\d*)\(/u.exec(source)?.[1]; - expect(recordFunction).toBeDefined(); - const recordSource = new RegExp( - `function ${recordFunction}\\([^\\n]*\\) \\{[\\s\\S]*?\\n\\}`, - "u", - ).exec(runtime)?.[0]; - expect(recordSource).toBeDefined(); - const fileSystemImport = /\b(import_node_fs\d*)\.promises\.readFile\(/u.exec( - source, - )?.[1]; - expect(fileSystemImport).toBeDefined(); - - class FakeCodex { - startThread(options: { threadSource: string }) { - expect(options.threadSource).toBe("security_scan"); - return { - id: "fixture-worker-thread", - async runStreamed(_input: string, options: { signal: AbortSignal }) { - return { events: events(options.signal) }; - }, - }; - } - } - - return new Function( - "Codex", - fileSystemImport!, - "workerPermissionProfile", - "workerPermissionProfileConfigOverrides", - "snapshotWorkerEnvironment", - "workerModelConfig", - "workerModelSelection", - "environmentVariable", - "preflightDeepScanWorkerPermissionProfile", - "DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID", - "deepScanPermissionProfileFallbackError", - "resolveCodexPath", - "executablePathForSpawn", - "workerSubagentConfig", - "appendSafeItemDiagnostic", - "classifyCodexWorkerError", - `${sessionSource}\n${recordSource}\n${source}\nreturn CodexSdkWorkerExecutor;`, - )( - FakeCodex, - { promises: { readFile: async () => "fixture worker prompt" } }, - () => ({}), - () => [], - async () => ({}), - async () => ({}), - () => ({}), - () => undefined, - preflight, - "codex_security_deep_scan_worker", - () => undefined, - () => "/fixture/codex", - (path: string) => path, - () => ({}), - () => {}, - (error: unknown) => error, - ) as WorkerExecutorConstructor; -} - -function runWorker( - WorkerExecutor: WorkerExecutorConstructor, - signal: AbortSignal, - onThreadStarted?: () => void, -) { - return new WorkerExecutor({ - parentSandbox: { filesystemDenies: [] }, - }).run({ - kind: "discovery", - promptPath: "/fixture/prompt.md", - workingDirectory: "/fixture/artifacts", - subagents: 0, - signal, - ...(onThreadStarted ? { onThreadStarted } : {}), - }); -} - -test("does not start a bundled worker when its permission profile check fails", async () => { - let started = false; - const WorkerExecutor = await bundledWorkerExecutor( - async function* () { - started = true; - yield { type: "turn.completed" }; - }, - async () => { - throw new Error("worker permission profile rejected"); - }, +import { execFile } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { test } from "bun:test"; + +test("Deep worker cancellation at the child-process boundary", async () => { + // Keep the portable child fixture in the SDK's macOS and Windows test shards. + await promisify(execFile)( + "node", + [ + fileURLToPath( + new URL( + "../../../plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs", + import.meta.url, + ), + ), + ], + { timeout: 110_000 }, ); - await expect( - runWorker(WorkerExecutor, new AbortController().signal), - ).rejects.toThrow("worker permission profile rejected"); - expect(started).toBe(false); -}); - -test("settles completed bundled Deep Scan workers during coordinator cancellation", async () => { - const parentController = new AbortController(); - let workerSignal: AbortSignal | undefined; - let iteratorClosed = false; - const WorkerExecutor = await bundledWorkerExecutor(async function* ( - signal: AbortSignal, - ) { - workerSignal = signal; - try { - yield { type: "thread.started", thread_id: "fixture-worker-thread" }; - yield { - type: "item.completed", - item: { type: "agent_message", text: "worker completed" }, - }; - yield { type: "turn.completed" }; - await new Promise(() => {}); - } finally { - iteratorClosed = true; - parentController.abort( - "coordinator canceled its remaining workers during cleanup", - ); - } - }); - const timeout = setTimeout(() => { - parentController.abort("completed bundled worker remained pending"); - }, 1_000); - - try { - const result = await runWorker(WorkerExecutor, parentController.signal); - - expect(result).toEqual({ - finalResponse: "worker completed", - threadId: "fixture-worker-thread", - }); - expect(iteratorClosed).toBe(true); - expect(parentController.signal.aborted).toBe(true); - expect(workerSignal).not.toBe(parentController.signal); - expect(workerSignal?.aborted).toBe(false); - } finally { - clearTimeout(timeout); - } -}); - -test("forwards coordinator cancellation to active bundled Deep Scan workers", async () => { - const parentController = new AbortController(); - const cancellation = new Error("coordinator canceled an active worker"); - let workerSignal: AbortSignal | undefined; - let iteratorClosed = false; - const WorkerExecutor = await bundledWorkerExecutor(async function* ( - signal: AbortSignal, - ) { - workerSignal = signal; - try { - yield { type: "thread.started", thread_id: "fixture-worker-thread" }; - signal.throwIfAborted(); - yield { type: "turn.completed" }; - } finally { - iteratorClosed = true; - } - }); - - await expect( - runWorker(WorkerExecutor, parentController.signal, () => { - parentController.abort(cancellation); - }), - ).rejects.toThrow(cancellation.message); - expect(iteratorClosed).toBe(true); - expect(workerSignal).not.toBe(parentController.signal); - expect(workerSignal?.aborted).toBe(true); - expect(workerSignal?.reason).toBe(cancellation); -}); - -test("preserves cancellation when a bundled Deep Scan worker starts aborted", async () => { - const cancellation = new Error("coordinator canceled before worker startup"); - const parentController = new AbortController(); - parentController.abort(cancellation); - let workerSignal: AbortSignal | undefined; - const WorkerExecutor = await bundledWorkerExecutor(async function* ( - signal: AbortSignal, - ) { - workerSignal = signal; - signal.throwIfAborted(); - yield { type: "turn.completed" }; - }); - - await expect( - runWorker(WorkerExecutor, parentController.signal), - ).rejects.toThrow(cancellation.message); - expect(workerSignal).not.toBe(parentController.signal); - expect(workerSignal?.aborted).toBe(true); - expect(workerSignal?.reason).toBe(cancellation); -}); - -test("detaches bundled Deep Scan worker cancellation after terminal failure", async () => { - const parentController = new AbortController(); - let workerSignal: AbortSignal | undefined; - let iteratorClosed = false; - const WorkerExecutor = await bundledWorkerExecutor(async function* ( - signal: AbortSignal, - ) { - workerSignal = signal; - try { - yield { - type: "turn.failed", - error: { message: "fixture worker failed" }, - }; - } finally { - iteratorClosed = true; - } - }); - - await expect( - runWorker(WorkerExecutor, parentController.signal), - ).rejects.toThrow("fixture worker failed"); - expect(iteratorClosed).toBe(true); - parentController.abort("coordinator canceled after terminal failure"); - expect(workerSignal?.aborted).toBe(false); -}); +}, 120_000); From 38083118d242e5054a6b9051c345a3abde1a44bc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 09:19:21 +0000 Subject: [PATCH 09/18] Preserve resolved provider settings in scan workers --- .../mcp-app/src/deep-scan/executor.ts | 38 ++++---- .../mcp-app/tests/test_deep_scan_executor.mjs | 89 +++++++++++++++++- sdk/typescript/src/api.ts | 6 ++ sdk/typescript/src/config.ts | 23 +++++ sdk/typescript/tests-ts/api.test.ts | 92 +++++++++++++++++++ 5 files changed, 227 insertions(+), 21 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index c56f199d0..1254eab19 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -5,6 +5,10 @@ import { readCodexSessionTurn } from "../../../../../sdk/typescript/src/codex-session.js"; import { Codex, type CodexOptions } from "@openai/codex-sdk"; +import { + codexWorkerConfig, codexWorkerConfigPath, inlineToml, modelProviderConfigOverride, + type JsonObject +} from "../../../../../sdk/typescript/src/config.js"; import { parse as parseToml } from "smol-toml"; import { executablePathForSpawn } from "./executable-path.js"; import { @@ -73,7 +77,9 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { ? { model_reasoning_effort: this.modelSettings.reasoningEffort } : {}) }; + const { model_providers: _providers, ...sdkModelConfig } = modelConfig; const configOverrides = [ + ...modelProviderConfigOverride(modelConfig as JsonObject), ...(resolved?.configOverrides ?? []), ...workerPermissionProfileConfigOverrides(workerProfile) ]; @@ -92,8 +98,9 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { cwd: request.workingDirectory, profileId: DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID, configOverrides: [ - ...Object.entries(workerModelSelection(modelConfig)) - .map(([key, value]) => `${key}=${tomlInlineValue(value)}`), + ...Object.entries(codexWorkerConfig(modelConfig as JsonObject)) + .filter(([key]) => key !== "model_providers") + .map(([key, value]) => `${key}=${inlineToml(value)}`), ...configOverrides, ...(resolved?.baseUrl ? [`openai_base_url=${tomlString(resolved.baseUrl)}`] : []) ], @@ -111,7 +118,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { // Keep native credentials unless the worker has no configured account. ...(useOpenAiApiKey ? { apiKey: openAiApiKey } : {}), config: { - ...modelConfig, + ...sdkModelConfig, mcp_servers: { ...(isRecord(modelConfig.mcp_servers) ? modelConfig.mcp_servers : {}), // Discovery workers use the bundled skills and artifacts, not the parent workbench MCP. @@ -403,26 +410,17 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -// These are the existing non-secret selections written by the SDK preflight -// adapter. Reading only summary left provider selection in a shared home. -function workerModelSelection(config: NonNullable): TomlObject { - const result: TomlObject = {}; - for (const key of ["model", "model_provider", "model_reasoning_effort", "model_reasoning_summary", "service_tier", "model_providers"]) { - const value = config[key]; - if (value !== undefined) result[key] = value; - } - return result; -} - async function workerModelConfig(environment: Record): Promise> { const configPath = environmentVariable(environment, "CODEX_SECURITY_CONFIG_PATH", process.platform); if (!configPath) return {}; - const config = parseToml(await fs.readFile(configPath, "utf8")); - const profiles = config.profiles; - const profile = typeof config.profile === "string" && isRecord(profiles) - ? profiles[config.profile] - : undefined; - return workerModelSelection({ ...config, ...(isRecord(profile) ? profile : {}) } as NonNullable); + try { + return codexWorkerConfig(parseToml(await fs.readFile(codexWorkerConfigPath(configPath), "utf8")) as JsonObject) as NonNullable; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + // Older SDKs only provide preflight input. Keep their home-selected provider. + const config = codexWorkerConfig(parseToml(await fs.readFile(configPath, "utf8")) as JsonObject) as NonNullable; + return config.model_reasoning_summary === undefined ? {} : { model_reasoning_summary: config.model_reasoning_summary }; } async function snapshotWorkerEnvironment(source: NodeJS.ProcessEnv = process.env): Promise> { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 6c1b56cb5..8b3b0c9ca 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; const executorSource = new URL("../src/deep-scan/executor.ts", import.meta.url); const bundle = await build({ @@ -77,6 +78,7 @@ try { await testWorkerReasoningSummaries(); await testWorkerProviderSelection(); await testIsolatedReconstructedWorkers(); + await testRuntimeProviderSnapshots(); await testWorkerCancellation(); await testDisallowedWorkerProfileFailsBeforeWorkerLaunch(); if (process.platform !== "win32") await testNullUsageCompletion(); @@ -758,7 +760,8 @@ async function testIsolatedReconstructedWorkers() { model_reasoning_summary: name === "first" ? "none" : "concise", service_tier: name === "first" ? "flex" : "fast" }; - await writeFile(configPath, Object.entries(config).map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); + await writeFile(configPath, stringifyToml(config)); + await writeFile(`${configPath}.workers.toml`, stringifyToml(config)); await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); if (process.platform === "win32") { @@ -860,6 +863,89 @@ async function testIsolatedReconstructedWorkers() { } } +async function testRuntimeProviderSnapshots() { + const originalSpawn = childProcess.spawn; + const previousMarker = process.env.FAKE_CODEX_MARKER; + const scans = []; + try { + for (const name of ["openrouter", "fireworks", "command-auth", "cloud.production", "cloud production"]) { + const fixture = await fakeCodexFixture(deniedWorkerPermissionProfile); + const configPath = path.join(fixture.root, "config-preflight.toml"); + const promptPath = path.join(fixture.root, "prompt.md"); + const provider = name.startsWith("cloud") ? "amazon-bedrock" : name === "command-auth" ? "openrouter" : name; + const definition = provider === "amazon-bedrock" + ? { aws: { region: "us-west-2", profile: "synthetic" } } + : { + name: "Synthetic provider", base_url: `https://${provider}.example.test/v1`, wire_api: "responses", + ...(name === "command-auth" + ? { auth: { command: "synthetic-auth-helper", args: [], cwd: fixture.root } } + : { env_key: `${provider.toUpperCase()}_API_KEY` }) + }; + const config = { model: "inherited-model", model_provider: provider, model_providers: { [provider]: definition }, model_reasoning_summary: "concise", service_tier: "flex" }; + // These preflight projections intentionally differ from the runtime snapshot. + const preflight = name.startsWith("cloud") + ? { model_provider: "openai", model_reasoning_summary: "concise" } + : { model_provider: provider, model_providers: { [provider]: { base_url: "https://default.example.test/v1", env_key: `${provider.toUpperCase()}_API_KEY` } } }; + await writeFile(configPath, stringifyToml(preflight)); + await writeFile(`${configPath}.workers.toml`, stringifyToml(config)); + await writeFile(promptPath, "NULL_USAGE"); + const codexHome = path.join(fixture.root, "home"); + await mkdir(codexHome); + const settings = { + codexOptions: { codexPathOverride: process.execPath, env: { CODEX_HOME: codexHome, CODEX_SECURITY_CONFIG_PATH: configPath, FAKE_CODEX_MARKER: fixture.markerPath } }, + model: "worker-model", reasoningEffort: "ultra", parentSandbox: trustedParentSandboxWithDenials + }; + scans.push({ name, fixture, configPath, promptPath, config, settings, executor: new CodexSdkWorkerExecutor(settings) }); + } + childProcess.spawn = (command, args, options) => { + const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); + return originalSpawn(command, scan ? [scan.fixture.executablePath, ...args] : args, options); + }; + syncBuiltinESMExports(); + for (const phase of ["fresh", "resume", "reconstructed"]) { + if (phase === "reconstructed") { + for (const scan of scans) { + await writeFile(`${scan.configPath}.workers.toml`, stringifyToml(scan.config)); + scan.executor = new CodexSdkWorkerExecutor(scan.settings); + } + } + for (const kind of ["discovery", "dedup"]) { + const outcomes = await Promise.allSettled(scans.map(async (scan) => { + const resumeThreadId = phase === "fresh" ? undefined : "resumed-worker"; + await scan.executor.run({ kind, promptPath: scan.promptPath, workingDirectory: scan.fixture.root, subagents: 0, resumeThreadId, signal: new AbortController().signal }); + for (const file of [scan.fixture.markerPath, scan.fixture.preflightMarkerPath]) { + const child = JSON.parse(await readFile(file, "utf8")); + const overrides = {}; + for (let i = 0; i < child.argv.length; i++) { + if (["-c", "--config"].includes(child.argv[i])) Object.assign(overrides, parseToml(child.argv[++i])); + } + assert.equal(overrides.model_provider, scan.config.model_provider, `${scan.name} ${phase} ${kind}`); + assert.deepEqual(overrides.model_providers, scan.config.model_providers, `${scan.name} ${phase} ${kind}`); + assert.equal(overrides.model_reasoning_effort, "ultra"); + assert.equal(overrides.model_reasoning_summary, "concise"); + assert.equal(overrides.service_tier, "flex"); + assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); + } + const child = JSON.parse(await readFile(scan.fixture.markerPath, "utf8")); + assertFlagPair(child.argv, "--model", "worker-model"); + assertReadOnlyWorkerPolicy(child.argv); + assertWorkerSubagentPolicy(child.argv, 0); + assert.equal(child.argv.includes("resume"), resumeThreadId !== undefined); + })); + const failures = outcomes.filter((outcome) => outcome.status === "rejected"); + if (failures.length) throw new AggregateError(failures.map((outcome) => outcome.reason), "Worker runtime provider controls failed"); + } + if (phase === "fresh") { + for (const scan of scans) await writeFile(`${scan.configPath}.workers.toml`, 'model_provider = "changed-after-launch"\n'); + } + } + } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); + restoreEnv("FAKE_CODEX_MARKER", previousMarker); + } +} + async function testWorkerProviderSelection() { const fixture = await fakeCodexFixture(); const saved = Object.fromEntries( @@ -872,6 +958,7 @@ async function testWorkerProviderSelection() { const configPath = path.join(fixture.root, "scan config.toml"); const promptPath = path.join(fixture.root, "prompt.md"); await writeFile(configPath, 'model_provider = "fixture-provider"\n'); + await writeFile(`${configPath}.workers.toml`, 'model_provider = "fixture-provider"\n'); await writeFile(promptPath, "fixture provider selection"); process.env.CODEX_CLI_PATH = process.execPath; process.env.CODEX_SECURITY_CONFIG_PATH = configPath; diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 0c048dea6..c61631dc9 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -52,6 +52,8 @@ import { } from "./codex-prompt.js"; import { DEFAULT_CODEX_CONFIG, + codexWorkerConfig, + codexWorkerConfigPath, EXTERNAL_CODEX_PROVIDERS, inlineToml, isExternalModelProvider, @@ -2794,6 +2796,10 @@ export class CodexSecurity { const preflightConfig = scanPreflightCodexConfig(effectiveConfig); if (runtime.configPath !== undefined) { await writeCodexConfig(runtime.configPath, preflightConfig); + await writeCodexConfig( + codexWorkerConfigPath(runtime.configPath), + codexWorkerConfig(effectiveConfig), + ); } const runtimeHome = await realpath(runtime.codexHome); requireOutputOutsideRepositories(protectedRoots, runtimeHome, "runtime"); diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 9d28e61df..5dbc4d5fd 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -202,6 +202,29 @@ export function resolveCodexProfile(config: JsonObject): JsonObject { return resolved; } +/** @internal Per-session runtime selections are separate from preflight input. */ +export function codexWorkerConfigPath(preflightPath: string): string { + return `${preflightPath}.workers.toml`; +} + +/** @internal Preserve the selected profile and provider definition for workers. */ +export function codexWorkerConfig(config: JsonObject): JsonObject { + const resolved = resolveCodexProfile(config); + const result: JsonObject = {}; + for (const key of [ + "model", + "model_provider", + "model_reasoning_effort", + "model_reasoning_summary", + "service_tier", + "model_providers", + ]) { + const value = resolved[key]; + if (value !== undefined) result[key] = value; + } + return result; +} + export async function mergedCodexConfig( config: CodexSecurityConfig, ): Promise { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 0e7d290c8..598febd98 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -83,6 +83,98 @@ const { cleanup, copyCompletedScan, temporaryDirectory } = createApiTestFixtures(); afterEach(cleanup); +test.each([ + "openrouter", + "fireworks", + "command-auth", + "cloud.production", + "cloud production", +])( + "writes isolated runtime worker settings for %s without changing preflight input", + async (name) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const home = join(root, "home"); + const configPath = join(root, "config-preflight.toml"); + await mkdir(repository); + await mkdir(home); + const provider = name.startsWith("cloud") + ? "amazon-bedrock" + : name === "command-auth" + ? "openrouter" + : name; + const definition: JsonObject = + provider === "amazon-bedrock" + ? { aws: { region: "us-west-2", profile: "synthetic" } } + : { + name: "Synthetic provider", + base_url: `https://${provider}.example.test/v1`, + wire_api: "responses", + ...(name === "command-auth" + ? { + auth: { + command: "synthetic-auth-helper", + args: [], + cwd: home, + }, + } + : { env_key: `${provider.toUpperCase()}_API_KEY` }), + }; + const config = { + model_provider: name.startsWith("cloud") ? "openai" : provider, + model_providers: { [provider]: definition }, + ...(name.startsWith("cloud") + ? { profile: name, profiles: { [name]: { model_provider: provider } } } + : {}), + }; + let captured = false; + const client = new TestClient( + { codexOverrides: config }, + { + environment: { + OPENROUTER_API_KEY: "synthetic-router", + FIREWORKS_API_KEY: "synthetic-fireworks", + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }, + prepareRuntime: async () => ({ ...preparedRuntime(home), configPath }), + resolvePluginPython: async () => "/managed/python", + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + const runtime = parseToml( + await readFile(`${configPath}.workers.toml`, "utf8"), + ) as JsonObject; + expect(runtime["model_provider"]).toBe(provider); + expect(runtime["model_providers"]).toEqual({ + [provider]: definition, + }); + expect(runtime["profile"]).toBeUndefined(); + const preflight = parseToml(await readFile(configPath, "utf8")); + if (name.startsWith("cloud")) + expect(preflight["model_provider"]).toBe("openai"); + else + expect(preflight["model_providers"]).not.toEqual( + runtime["model_providers"], + ); + captured = true; + throw new Error("runtime snapshot captured"); + }, + }), + }), + }, + ); + try { + await expect( + client.run(repository, { mode: "deep", outputDir: join(root, "scan") }), + ).rejects.toThrow("runtime snapshot captured"); + expect(captured).toBe(true); + } finally { + await client.close(); + } + }, +); + test.each(["completed", "receipt-lost", "scan-interrupted", "prompt-files"])( "durable scan workflow resumes after %s without rerunning completed work", async (scenario) => { From 4ff8103c723babfa678ad92eb34a9f5b9bfd690b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 09:38:01 +0000 Subject: [PATCH 10/18] Pin worker provider and authentication selections per session --- .../mcp-app/src/deep-scan/executor.ts | 3 +- .../mcp-app/tests/test_deep_scan_executor.mjs | 38 ++++++++++++----- sdk/typescript/src/auth.ts | 6 +-- sdk/typescript/src/config.ts | 11 ++++- sdk/typescript/tests-ts/api.test.ts | 42 ++++++++++++++----- 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 1254eab19..6bdd7040f 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -99,7 +99,8 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { profileId: DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID, configOverrides: [ ...Object.entries(codexWorkerConfig(modelConfig as JsonObject)) - .filter(([key]) => key !== "model_providers") + // Older SDK/direct-plugin workers keep their home-selected provider. + .filter(([key]) => key !== "model_providers" && modelConfig[key] !== undefined) .map(([key, value]) => `${key}=${inlineToml(value)}`), ...configOverrides, ...(resolved?.baseUrl ? [`openai_base_url=${tomlString(resolved.baseUrl)}`] : []) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 8b3b0c9ca..6d4bb3a33 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -17,7 +17,7 @@ const bundle = await build({ }, stdin: { // Test the environment snapshot without adding a production export. - contents: `${await readFile(executorSource, "utf8")}\nexport { snapshotWorkerEnvironment };`, + contents: `${await readFile(executorSource, "utf8")}\nexport { snapshotWorkerEnvironment, codexWorkerConfig };`, loader: "ts", resolveDir: path.dirname(fileURLToPath(executorSource)), sourcefile: fileURLToPath(executorSource) @@ -26,7 +26,7 @@ const bundle = await build({ platform: "node", write: false }); -const { CodexSdkWorkerExecutor, resolveCodexPath, snapshotWorkerEnvironment } = await import( +const { CodexSdkWorkerExecutor, resolveCodexPath, snapshotWorkerEnvironment, codexWorkerConfig } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); const errorsBundle = await build({ @@ -864,15 +864,17 @@ async function testIsolatedReconstructedWorkers() { } async function testRuntimeProviderSnapshots() { + const sharedHome = await mkdtemp(path.join(tmpdir(), "shared-worker-home-")); + temporaryRoots.push(sharedHome); const originalSpawn = childProcess.spawn; const previousMarker = process.env.FAKE_CODEX_MARKER; const scans = []; try { - for (const name of ["openrouter", "fireworks", "command-auth", "cloud.production", "cloud production"]) { + for (const name of ["openrouter", "fireworks", "command-auth", "cloud.production", "cloud production", "shared-default", "shared-external"]) { const fixture = await fakeCodexFixture(deniedWorkerPermissionProfile); const configPath = path.join(fixture.root, "config-preflight.toml"); const promptPath = path.join(fixture.root, "prompt.md"); - const provider = name.startsWith("cloud") ? "amazon-bedrock" : name === "command-auth" ? "openrouter" : name; + const provider = name === "shared-default" ? "openai" : name === "shared-external" ? "openrouter" : name.startsWith("cloud") ? "amazon-bedrock" : name === "command-auth" ? "openrouter" : name; const definition = provider === "amazon-bedrock" ? { aws: { region: "us-west-2", profile: "synthetic" } } : { @@ -881,21 +883,30 @@ async function testRuntimeProviderSnapshots() { ? { auth: { command: "synthetic-auth-helper", args: [], cwd: fixture.root } } : { env_key: `${provider.toUpperCase()}_API_KEY` }) }; - const config = { model: "inherited-model", model_provider: provider, model_providers: { [provider]: definition }, model_reasoning_summary: "concise", service_tier: "flex" }; + const auth = name.startsWith("shared-") ? { + cli_auth_credentials_store: name === "shared-default" ? "file" : "keyring", + forced_login_method: name === "shared-default" ? "chatgpt" : "api", + forced_chatgpt_workspace_id: `synthetic-${name}` + } : {}; + const config = { model: "inherited-model", model_provider: provider, ...(name === "shared-default" ? {} : { model_providers: { [provider]: definition } }), model_reasoning_summary: "concise", service_tier: "flex", ...auth }; + const input = { ...config }; + if (name === "shared-default") delete input.model_provider; // These preflight projections intentionally differ from the runtime snapshot. const preflight = name.startsWith("cloud") ? { model_provider: "openai", model_reasoning_summary: "concise" } : { model_provider: provider, model_providers: { [provider]: { base_url: "https://default.example.test/v1", env_key: `${provider.toUpperCase()}_API_KEY` } } }; await writeFile(configPath, stringifyToml(preflight)); - await writeFile(`${configPath}.workers.toml`, stringifyToml(config)); + await writeFile(`${configPath}.workers.toml`, stringifyToml(codexWorkerConfig(input))); await writeFile(promptPath, "NULL_USAGE"); - const codexHome = path.join(fixture.root, "home"); - await mkdir(codexHome); + const codexHome = name.startsWith("shared-") ? sharedHome : path.join(fixture.root, "home"); + await mkdir(codexHome, { recursive: true }); + // Simulate a later session replacing the shared home configuration. + await writeFile(path.join(codexHome, "config.toml"), stringifyToml(config)); const settings = { codexOptions: { codexPathOverride: process.execPath, env: { CODEX_HOME: codexHome, CODEX_SECURITY_CONFIG_PATH: configPath, FAKE_CODEX_MARKER: fixture.markerPath } }, model: "worker-model", reasoningEffort: "ultra", parentSandbox: trustedParentSandboxWithDenials }; - scans.push({ name, fixture, configPath, promptPath, config, settings, executor: new CodexSdkWorkerExecutor(settings) }); + scans.push({ name, fixture, configPath, promptPath, config, input, auth, settings, executor: new CodexSdkWorkerExecutor(settings) }); } childProcess.spawn = (command, args, options) => { const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); @@ -905,7 +916,7 @@ async function testRuntimeProviderSnapshots() { for (const phase of ["fresh", "resume", "reconstructed"]) { if (phase === "reconstructed") { for (const scan of scans) { - await writeFile(`${scan.configPath}.workers.toml`, stringifyToml(scan.config)); + await writeFile(`${scan.configPath}.workers.toml`, stringifyToml(codexWorkerConfig(scan.input))); scan.executor = new CodexSdkWorkerExecutor(scan.settings); } } @@ -921,6 +932,9 @@ async function testRuntimeProviderSnapshots() { } assert.equal(overrides.model_provider, scan.config.model_provider, `${scan.name} ${phase} ${kind}`); assert.deepEqual(overrides.model_providers, scan.config.model_providers, `${scan.name} ${phase} ${kind}`); + for (const [key, value] of Object.entries(scan.auth)) { + assert.equal(overrides[key], value, `${scan.name} ${phase} ${kind} ${key}`); + } assert.equal(overrides.model_reasoning_effort, "ultra"); assert.equal(overrides.model_reasoning_summary, "concise"); assert.equal(overrides.service_tier, "flex"); @@ -1063,6 +1077,10 @@ async function testWorkerReasoningSummaries() { assert.equal(invocation.argv.includes('model_reasoning_effort="xhigh"'), true); assert.equal(invocation.configPath, configPath); assert.equal(invocation.deepConfigPath, process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH); + for (const file of [fixture.markerPath, fixture.preflightMarkerPath]) { + const child = JSON.parse(await readFile(file, "utf8")); + assert.equal(child.argv.some((arg) => arg.startsWith("model_provider=")), false); + } assertReadOnlyWorkerPolicy(invocation.argv); assertWorkerSubagentPolicy(invocation.argv, 0); await writeFile(configPath, 'model_reasoning_summary = "detailed"\n'); diff --git a/sdk/typescript/src/auth.ts b/sdk/typescript/src/auth.ts index 933ba35fb..4aeaf1814 100644 --- a/sdk/typescript/src/auth.ts +++ b/sdk/typescript/src/auth.ts @@ -290,11 +290,7 @@ export async function logout( } /** @internal Authentication settings shared by login and model commands. */ -export const CODEX_AUTH_CONFIG_KEYS = [ - "cli_auth_credentials_store", - "forced_login_method", - "forced_chatgpt_workspace_id", -] as const; +export { CODEX_AUTH_CONFIG_KEYS } from "./config.js"; /** @internal Shared login recovery guidance for model commands. */ export const NO_CREDENTIALS_MESSAGE = diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 5dbc4d5fd..bf0f10f65 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -10,6 +10,13 @@ export interface JsonObject { [key: string]: JsonValue; } +/** @internal Authentication settings shared by login and model commands. */ +export const CODEX_AUTH_CONFIG_KEYS = [ + "cli_auth_credentials_store", + "forced_login_method", + "forced_chatgpt_workspace_id", +] as const; + export interface CodexSecurityConfig { pluginPath?: string; codexOverrides?: JsonObject; @@ -210,7 +217,8 @@ export function codexWorkerConfigPath(preflightPath: string): string { /** @internal Preserve the selected profile and provider definition for workers. */ export function codexWorkerConfig(config: JsonObject): JsonObject { const resolved = resolveCodexProfile(config); - const result: JsonObject = {}; + // Pin Codex's default before another session can change the shared home. + const result: JsonObject = { model_provider: "openai" }; for (const key of [ "model", "model_provider", @@ -218,6 +226,7 @@ export function codexWorkerConfig(config: JsonObject): JsonObject { "model_reasoning_summary", "service_tier", "model_providers", + ...CODEX_AUTH_CONFIG_KEYS, ]) { const value = resolved[key]; if (value !== undefined) result[key] = value; diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 598febd98..01ba515f2 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -84,6 +84,7 @@ const { cleanup, copyCompletedScan, temporaryDirectory } = afterEach(cleanup); test.each([ + "default", "openrouter", "fireworks", "command-auth", @@ -98,11 +99,14 @@ test.each([ const configPath = join(root, "config-preflight.toml"); await mkdir(repository); await mkdir(home); - const provider = name.startsWith("cloud") - ? "amazon-bedrock" - : name === "command-auth" - ? "openrouter" - : name; + const provider = + name === "default" + ? "openai" + : name.startsWith("cloud") + ? "amazon-bedrock" + : name === "command-auth" + ? "openrouter" + : name; const definition: JsonObject = provider === "amazon-bedrock" ? { aws: { region: "us-west-2", profile: "synthetic" } } @@ -120,9 +124,19 @@ test.each([ } : { env_key: `${provider.toUpperCase()}_API_KEY` }), }; + const auth = { + cli_auth_credentials_store: "file", + forced_login_method: "chatgpt", + forced_chatgpt_workspace_id: "synthetic-workspace", + }; const config = { - model_provider: name.startsWith("cloud") ? "openai" : provider, - model_providers: { [provider]: definition }, + ...auth, + ...(name === "default" + ? {} + : { + model_provider: name.startsWith("cloud") ? "openai" : provider, + model_providers: { [provider]: definition }, + }), ...(name.startsWith("cloud") ? { profile: name, profiles: { [name]: { model_provider: provider } } } : {}), @@ -146,14 +160,20 @@ test.each([ await readFile(`${configPath}.workers.toml`, "utf8"), ) as JsonObject; expect(runtime["model_provider"]).toBe(provider); - expect(runtime["model_providers"]).toEqual({ - [provider]: definition, - }); + expect(runtime["model_providers"]).toEqual( + name === "default" + ? undefined + : { + [provider]: definition, + }, + ); + for (const [key, value] of Object.entries(auth)) + expect(runtime[key]).toBe(value); expect(runtime["profile"]).toBeUndefined(); const preflight = parseToml(await readFile(configPath, "utf8")); if (name.startsWith("cloud")) expect(preflight["model_provider"]).toBe("openai"); - else + else if (name !== "default") expect(preflight["model_providers"]).not.toEqual( runtime["model_providers"], ); From 0b43c3f4741609f1d5ac27a9db5e1b700538ce36 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 09:53:32 +0000 Subject: [PATCH 11/18] Resolve shared config dependency in standalone MCP builds --- plugins/codex-security/mcp-app/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/tsconfig.json b/plugins/codex-security/mcp-app/tsconfig.json index 7873e0584..5f455476e 100644 --- a/plugins/codex-security/mcp-app/tsconfig.json +++ b/plugins/codex-security/mcp-app/tsconfig.json @@ -6,7 +6,8 @@ "moduleResolution": "Bundler", "noEmit": true, "paths": { - "@openai/codex-sdk": ["./node_modules/@openai/codex-sdk"] + "@openai/codex-sdk": ["./node_modules/@openai/codex-sdk"], + "smol-toml": ["./node_modules/smol-toml/dist/index"] }, "resolveJsonModule": true, "skipLibCheck": true, From 8d1d8f900490fd1f9133730797008032cb1fd069 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 13:03:51 +0000 Subject: [PATCH 12/18] Keep only the selected provider in worker configuration --- .../mcp-app/tests/test_deep_scan_executor.mjs | 42 ++++++++-- sdk/typescript/src/config.ts | 10 ++- sdk/typescript/tests-ts/api.test.ts | 56 +++++++------ sdk/typescript/tests-ts/config.test.ts | 81 +++++++++++++++++++ 4 files changed, 156 insertions(+), 33 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 6d4bb3a33..4f3180680 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -870,15 +870,19 @@ async function testRuntimeProviderSnapshots() { const previousMarker = process.env.FAKE_CODEX_MARKER; const scans = []; try { - for (const name of ["openrouter", "fireworks", "command-auth", "cloud.production", "cloud production", "shared-default", "shared-external"]) { + for (const name of ["openrouter", "fireworks", "command-auth", "cloud.production", "cloud production", "shared-default", "shared-default-configured", "openai", "shared-external"]) { const fixture = await fakeCodexFixture(deniedWorkerPermissionProfile); const configPath = path.join(fixture.root, "config-preflight.toml"); const promptPath = path.join(fixture.root, "prompt.md"); - const provider = name === "shared-default" ? "openai" : name === "shared-external" ? "openrouter" : name.startsWith("cloud") ? "amazon-bedrock" : name === "command-auth" ? "openrouter" : name; + const provider = name.startsWith("shared-default") ? "openai" : name === "shared-external" ? "openrouter" : name.startsWith("cloud") ? "amazon-bedrock" : name === "command-auth" ? "openrouter" : name; const definition = provider === "amazon-bedrock" ? { aws: { region: "us-west-2", profile: "synthetic" } } : { name: "Synthetic provider", base_url: `https://${provider}.example.test/v1`, wire_api: "responses", + experimental_bearer_token: `synthetic-${name}-selected-token`, + http_headers: { Authorization: `synthetic-${name}-selected-header` }, + env_http_headers: { "X-Synthetic-Auth": "SYNTHETIC_PROVIDER_HEADER" }, + query_params: { "api-version": "synthetic-version" }, ...(name === "command-auth" ? { auth: { command: "synthetic-auth-helper", args: [], cwd: fixture.root } } : { env_key: `${provider.toUpperCase()}_API_KEY` }) @@ -889,8 +893,22 @@ async function testRuntimeProviderSnapshots() { forced_chatgpt_workspace_id: `synthetic-${name}` } : {}; const config = { model: "inherited-model", model_provider: provider, ...(name === "shared-default" ? {} : { model_providers: { [provider]: definition } }), model_reasoning_summary: "concise", service_tier: "flex", ...auth }; - const input = { ...config }; - if (name === "shared-default") delete input.model_provider; + const input = { + ...config, + model_providers: { + ...config.model_providers, + unrelated: { experimental_bearer_token: `synthetic-${name}-unrelated-token` } + } + }; + if (name.startsWith("shared-default")) delete input.model_provider; + if (name.startsWith("cloud")) { + input.model_provider = "openai"; + input.profile = name; + input.profiles = { + [name]: { model_provider: provider }, + inactive: { model_provider: "unrelated" } + }; + } // These preflight projections intentionally differ from the runtime snapshot. const preflight = name.startsWith("cloud") ? { model_provider: "openai", model_reasoning_summary: "concise" } @@ -903,7 +921,7 @@ async function testRuntimeProviderSnapshots() { // Simulate a later session replacing the shared home configuration. await writeFile(path.join(codexHome, "config.toml"), stringifyToml(config)); const settings = { - codexOptions: { codexPathOverride: process.execPath, env: { CODEX_HOME: codexHome, CODEX_SECURITY_CONFIG_PATH: configPath, FAKE_CODEX_MARKER: fixture.markerPath } }, + codexOptions: { codexPathOverride: process.execPath, env: { CODEX_HOME: codexHome, CODEX_SECURITY_CONFIG_PATH: configPath, FAKE_CODEX_MARKER: fixture.markerPath, FAKE_CODEX_WORKER_CONFIG: `${configPath}.workers.toml`, SYNTHETIC_PROVIDER_HEADER: `synthetic-${name}-header-env`, ...(definition.env_key ? { [definition.env_key]: `synthetic-${name}-env-key` } : {}), FAKE_CODEX_PROVIDER_ENV_KEYS: JSON.stringify(["SYNTHETIC_PROVIDER_HEADER", ...(definition.env_key ? [definition.env_key] : [])]) } }, model: "worker-model", reasoningEffort: "ultra", parentSandbox: trustedParentSandboxWithDenials }; scans.push({ name, fixture, configPath, promptPath, config, input, auth, settings, executor: new CodexSdkWorkerExecutor(settings) }); @@ -931,6 +949,12 @@ async function testRuntimeProviderSnapshots() { if (["-c", "--config"].includes(child.argv[i])) Object.assign(overrides, parseToml(child.argv[++i])); } assert.equal(overrides.model_provider, scan.config.model_provider, `${scan.name} ${phase} ${kind}`); + assert.equal(JSON.stringify(child).includes(`synthetic-${scan.name}-unrelated-token`), false); + assert.deepEqual(parseToml(child.workerConfig), phase === "resume" ? { model_provider: "changed-after-launch" } : scan.config); + assert.deepEqual(child.providerAuthentication, Object.fromEntries( + JSON.parse(scan.settings.codexOptions.env.FAKE_CODEX_PROVIDER_ENV_KEYS) + .map((key) => [key, scan.settings.codexOptions.env[key]]) + )); assert.deepEqual(overrides.model_providers, scan.config.model_providers, `${scan.name} ${phase} ${kind}`); for (const [key, value] of Object.entries(scan.auth)) { assert.equal(overrides[key], value, `${scan.name} ${phase} ${kind} ${key}`); @@ -1786,13 +1810,15 @@ async function fakeCodexFixture( const scriptPath = path.join(root, "fake-codex.mjs"); await writeFile(scriptPath, [ "#!/usr/bin/env node", - 'import { writeFileSync } from "node:fs";', + 'import { readFileSync, writeFileSync } from "node:fs";', `const preflightProfile = ${JSON.stringify(preflightProfile)};`, `const preflightAllowed = ${JSON.stringify(preflightAllowed)};`, `const accountResult = ${JSON.stringify(accountResult)};`, `const preflightMarkerPath = ${JSON.stringify(preflightMarkerPath)};`, + "const workerConfig = process.env.FAKE_CODEX_WORKER_CONFIG ? readFileSync(process.env.FAKE_CODEX_WORKER_CONFIG, 'utf8') : undefined;", + "const providerAuthentication = process.env.FAKE_CODEX_PROVIDER_ENV_KEYS ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_PROVIDER_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;", "if (process.argv.includes('app-server')) {", - " const preflight = { argv: process.argv.slice(2), cwd: process.cwd(), codexHome: process.env.CODEX_HOME, requests: [] };", + " const preflight = { workerConfig, providerAuthentication, argv: process.argv.slice(2), cwd: process.cwd(), codexHome: process.env.CODEX_HOME, requests: [] };", " writeFileSync(preflightMarkerPath, JSON.stringify(preflight));", " let buffer = '';", " process.stdin.setEncoding('utf8');", @@ -1834,7 +1860,7 @@ async function fakeCodexFixture( "for await (const chunk of process.stdin) stdin += chunk;", "const openaiAuthentication = stdin.includes('CAPTURE_SYNTHETIC_OPENAI_AUTH') ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY } : undefined;", "const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;", - "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", + "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ workerConfig, providerAuthentication, executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", "if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); }", "if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); }", "if (stdin.includes('MCP_STARTUP_TIMEOUT') || stdin.includes('CATALOG_AUTH_ONLY') || stdin.includes('SYNC_AUTH_ONLY')) {", diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index bf0f10f65..626effcd9 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -225,12 +225,20 @@ export function codexWorkerConfig(config: JsonObject): JsonObject { "model_reasoning_effort", "model_reasoning_summary", "service_tier", - "model_providers", ...CODEX_AUTH_CONFIG_KEYS, ]) { const value = resolved[key]; if (value !== undefined) result[key] = value; } + const selected = result["model_provider"]; + const providers = resolved["model_providers"]; + if ( + typeof selected === "string" && + isObject(providers) && + Object.hasOwn(providers, selected) + ) { + result["model_providers"] = { [selected]: providers[selected]! }; + } return result; } diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 01ba515f2..70c8652f7 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -83,30 +83,35 @@ const { cleanup, copyCompletedScan, temporaryDirectory } = createApiTestFixtures(); afterEach(cleanup); -test.each([ - "default", - "openrouter", - "fireworks", - "command-auth", - "cloud.production", - "cloud production", -])( - "writes isolated runtime worker settings for %s without changing preflight input", - async (name) => { +test.each( + [ + "default", + "default-configured", + "openai", + "openrouter", + "fireworks", + "command-auth", + "cloud.production", + "cloud production", + ].flatMap((name) => + (["standard", "deep"] as const).map((mode) => [name, mode] as const), + ), +)( + "writes isolated runtime worker settings for %s in %s without changing preflight input", + async (name, mode) => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const home = join(root, "home"); const configPath = join(root, "config-preflight.toml"); await mkdir(repository); await mkdir(home); - const provider = - name === "default" - ? "openai" - : name.startsWith("cloud") - ? "amazon-bedrock" - : name === "command-auth" - ? "openrouter" - : name; + const provider = name.startsWith("default") + ? "openai" + : name.startsWith("cloud") + ? "amazon-bedrock" + : name === "command-auth" + ? "openrouter" + : name; const definition: JsonObject = provider === "amazon-bedrock" ? { aws: { region: "us-west-2", profile: "synthetic" } } @@ -131,12 +136,15 @@ test.each([ }; const config = { ...auth, - ...(name === "default" + ...(name.startsWith("default") ? {} - : { - model_provider: name.startsWith("cloud") ? "openai" : provider, - model_providers: { [provider]: definition }, - }), + : { model_provider: name.startsWith("cloud") ? "openai" : provider }), + model_providers: { + ...(name === "default" ? {} : { [provider]: definition }), + unrelated: { + experimental_bearer_token: "synthetic-unrelated-provider", + }, + }, ...(name.startsWith("cloud") ? { profile: name, profiles: { [name]: { model_provider: provider } } } : {}), @@ -186,7 +194,7 @@ test.each([ ); try { await expect( - client.run(repository, { mode: "deep", outputDir: join(root, "scan") }), + client.run(repository, { mode, outputDir: join(root, "scan") }), ).rejects.toThrow("runtime snapshot captured"); expect(captured).toBe(true); } finally { diff --git a/sdk/typescript/tests-ts/config.test.ts b/sdk/typescript/tests-ts/config.test.ts index b8194c636..ed002319f 100644 --- a/sdk/typescript/tests-ts/config.test.ts +++ b/sdk/typescript/tests-ts/config.test.ts @@ -13,6 +13,7 @@ import { parse } from "smol-toml"; import { scanRuntimeCodexConfig } from "../src/api.js"; import { type JsonObject, + codexWorkerConfig, resolveCodexProfile, scanModelConfiguration, scanModelProvider, @@ -934,3 +935,83 @@ describe("Codex configuration", () => { expect(parse(await readFile(path, "utf8"))).toEqual({ hooks }); }); }); + +describe("codexWorkerConfig", () => { + const cases: { name: string; selection: JsonObject; selected: string }[] = [ + { name: "implicit OpenAI", selection: {}, selected: "openai" }, + { + name: "explicit OpenAI", + selection: { model_provider: "openai" }, + selected: "openai", + }, + { + name: "custom provider", + selection: { model_provider: "custom.provider" }, + selected: "custom.provider", + }, + { + name: "selected profile", + selection: { model_provider: "openai", profile: "scan" }, + selected: "custom.provider", + }, + ]; + test.each(cases)( + "preserves the full effective $name definition only", + async ({ selection, selected }) => { + const definition = { + name: "Synthetic selected provider", + base_url: "https://provider.example.invalid/v1", + env_key: "SYNTHETIC_PROVIDER_KEY", + http_headers: { Authorization: "synthetic-selected-header" }, + env_http_headers: { "X-Token": "SYNTHETIC_HEADER_KEY" }, + experimental_bearer_token: "synthetic-selected-token", + auth: { command: "synthetic-helper", args: ["synthetic-argument"] }, + query_params: { "api-version": "synthetic-version" }, + request_max_retries: 2, + }; + const input = await mergedCodexConfig({ + codexOverrides: { + ...selection, + model_providers: { + [selected]: definition, + unrelated: { + experimental_bearer_token: "synthetic-unrelated-token", + }, + }, + profiles: { + scan: { + model_provider: "custom.provider", + model_providers: { + "custom.provider": { stream_idle_timeout_ms: 1000 }, + }, + }, + inactive: { model_provider: "unrelated" }, + }, + }, + }); + const before = structuredClone(input); + expect(codexWorkerConfig(input)).toMatchObject({ + model_provider: selected, + model_providers: { + [selected]: { + ...definition, + ...(selection["profile"] ? { stream_idle_timeout_ms: 1000 } : {}), + }, + }, + }); + expect( + Object.keys(codexWorkerConfig(input)["model_providers"] as JsonObject), + ).toEqual([selected]); + expect(codexWorkerConfig(input)).not.toHaveProperty("profiles"); + expect(input).toEqual(before); + }, + ); + + test("keeps built-in provider selection without inventing a definition", () => { + expect( + codexWorkerConfig({ + model_providers: { unrelated: { env_key: "SYNTHETIC_KEY" } }, + }), + ).toEqual({ model_provider: "openai" }); + }); +}); From f34a76707c078a564e13b1045b3870d2c071dd9b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 14:29:49 +0000 Subject: [PATCH 13/18] Preserve Python discovery in installed Deep Scan fixtures --- .../scripts/fixtures/package-deep-codex.mjs | 2 +- .../scripts/fixtures/package-deep-scan.mjs | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs index 4d645dfb9..39549c8b6 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs @@ -18,7 +18,7 @@ try { async function trace(event) { await appendFile( process.env.PACKAGE_DEEP_TRACE, - `${JSON.stringify(event)}\n`, + `${JSON.stringify({ ...event, python: process.env.PYTHON })}\n`, ); } diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index 248d6031b..8ec3f66a1 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -21,6 +21,9 @@ import { startRpc } from "./package-rpc.mjs"; import { packageSmokeTimeouts } from "../package-smoke-timeouts.mjs"; const installedRoot = await realpath(process.argv[2]); +const { resolvePluginPython } = await import( + pathToFileURL(join(installedRoot, "dist", "runtime.js")).href +); const root = await realpath( await mkdtemp(join(tmpdir(), "package deep % fixture-")), ); @@ -102,6 +105,7 @@ async function fixture(name, pluginRoot, executable) { "TMP", "TEMP", "TMPDIR", + "PYTHON", ] .filter((key) => process.env[key] !== undefined) .map((key) => [key, process.env[key]]), @@ -114,7 +118,6 @@ async function fixture(name, pluginRoot, executable) { CODEX_SECURITY_PLUGIN_ROOT: pluginRoot, CODEX_SECURITY_STATE_DIR: join(directory, "state"), CODEX_SECURITY_SCAN_ROOT: join(directory, "scans"), - PYTHON: process.env.PYTHON || "python3", OPENAI_API_KEY: "synthetic-package-deep-key", ...(process.platform === "win32" ? { @@ -124,6 +127,11 @@ async function fixture(name, pluginRoot, executable) { : {}), PACKAGE_DEEP_TRACE: join(directory, "executions.jsonl"), }); + env.PYTHON = await resolvePluginPython({ + environment: env, + protectedRoot: target, + homeDirectory: home, + }); return { directory, target, home, env, pluginRoot }; } @@ -465,6 +473,9 @@ async function readExecutions(f) { async function assertExecutions(f, scanId, preflights = 3) { const executions = await readExecutions(f); + for (const execution of executions) { + assert.equal(execution.python, f.env.PYTHON); + } const workers = executions.filter((entry) => entry.phase === "worker"); const reducers = executions.filter((entry) => entry.phase === "reducer"); const incomplete = f.env.PACKAGE_DEEP_EMPTY_ONCE ? 1 : 0; From 2877c444770f1aec8cc0c342fdfbbd898604b0f4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 16 Sep 2026 15:16:07 +0000 Subject: [PATCH 14/18] Compare selected fixture Python by executable identity --- sdk/typescript/scripts/fixtures/package-deep-scan.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index 8ec3f66a1..982b2dfff 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -474,7 +474,10 @@ async function readExecutions(f) { async function assertExecutions(f, scanId, preflights = 3) { const executions = await readExecutions(f); for (const execution of executions) { - assert.equal(execution.python, f.env.PYTHON); + assert.equal( + await realpath(execution.python), + await realpath(f.env.PYTHON), + ); } const workers = executions.filter((entry) => entry.phase === "worker"); const reducers = executions.filter((entry) => entry.phase === "reducer"); From d4a007b4615a7f4e69c963cf749fd6c822fab742 Mon Sep 17 00:00:00 2001 From: Codex Security Maintenance Date: Thu, 17 Sep 2026 00:30:15 +0000 Subject: [PATCH 15/18] Protect per-session worker configuration from model tools --- .../mcp-app/src/deep-scan/executor.ts | 3 +- .../mcp-app/tests/test_deep_scan_executor.mjs | 13 ++++++--- sdk/typescript/src/api.ts | 28 ++++++++++++++----- sdk/typescript/tests-ts/api.test.ts | 21 +++++++++++++- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 6bdd7040f..462c673e4 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -295,7 +295,7 @@ function workerSubagentConfig(subagents: number, config: NonNullable `${tomlKey(key)}=${tomlInlineValue(entry)}`) .join(",")}}`; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 4f3180680..5988e9769 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -871,8 +871,12 @@ async function testRuntimeProviderSnapshots() { const scans = []; try { for (const name of ["openrouter", "fireworks", "command-auth", "cloud.production", "cloud production", "shared-default", "shared-default-configured", "openai", "shared-external"]) { - const fixture = await fakeCodexFixture(deniedWorkerPermissionProfile); - const configPath = path.join(fixture.root, "config-preflight.toml"); + const configPath = path.join(sharedHome, `${name}.toml`); + const permissionProfile = { + ...deniedWorkerPermissionProfile, + filesystem: { ...deniedWorkerPermissionProfile.filesystem, [`${configPath}.workers.toml`]: "deny" } + }; + const fixture = await fakeCodexFixture(permissionProfile); const promptPath = path.join(fixture.root, "prompt.md"); const provider = name.startsWith("shared-default") ? "openai" : name === "shared-external" ? "openrouter" : name.startsWith("cloud") ? "amazon-bedrock" : name === "command-auth" ? "openrouter" : name; const definition = provider === "amazon-bedrock" @@ -922,9 +926,9 @@ async function testRuntimeProviderSnapshots() { await writeFile(path.join(codexHome, "config.toml"), stringifyToml(config)); const settings = { codexOptions: { codexPathOverride: process.execPath, env: { CODEX_HOME: codexHome, CODEX_SECURITY_CONFIG_PATH: configPath, FAKE_CODEX_MARKER: fixture.markerPath, FAKE_CODEX_WORKER_CONFIG: `${configPath}.workers.toml`, SYNTHETIC_PROVIDER_HEADER: `synthetic-${name}-header-env`, ...(definition.env_key ? { [definition.env_key]: `synthetic-${name}-env-key` } : {}), FAKE_CODEX_PROVIDER_ENV_KEYS: JSON.stringify(["SYNTHETIC_PROVIDER_HEADER", ...(definition.env_key ? [definition.env_key] : [])]) } }, - model: "worker-model", reasoningEffort: "ultra", parentSandbox: trustedParentSandboxWithDenials + model: "worker-model", reasoningEffort: "ultra", parentSandbox: { ...trustedParentSandboxWithDenials, filesystemDenies: [...trustedParentSandboxWithDenials.filesystemDenies, `${configPath}.workers.toml`] } }; - scans.push({ name, fixture, configPath, promptPath, config, input, auth, settings, executor: new CodexSdkWorkerExecutor(settings) }); + scans.push({ name, permissionProfile, fixture, configPath, promptPath, config, input, auth, settings, executor: new CodexSdkWorkerExecutor(settings) }); } childProcess.spawn = (command, args, options) => { const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); @@ -948,6 +952,7 @@ async function testRuntimeProviderSnapshots() { for (let i = 0; i < child.argv.length; i++) { if (["-c", "--config"].includes(child.argv[i])) Object.assign(overrides, parseToml(child.argv[++i])); } + assert.deepEqual(overrides.permissions.codex_security_deep_scan_worker, scan.permissionProfile); assert.equal(overrides.model_provider, scan.config.model_provider, `${scan.name} ${phase} ${kind}`); assert.equal(JSON.stringify(child).includes(`synthetic-${scan.name}-unrelated-token`), false); assert.deepEqual(parseToml(child.workerConfig), phase === "resume" ? { model_provider: "changed-after-launch" } : scan.config); diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index c61631dc9..749b4556a 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2650,6 +2650,12 @@ export class CodexSecurity { if (session.safetyIdentifier !== undefined) { environment[SAFETY_IDENTIFIER_ENV] = session.safetyIdentifier; } + if (runtime.configPath !== undefined) { + configOverrides = [ + `permissions.${SCAN_PERMISSION_PROFILE}.filesystem=${inlineToml(scanFilesystemPermissions(session.runtimeHome, codexWorkerConfigPath(runtime.configPath)))}`, + ...configOverrides, + ]; + } const sdkCodexConfig = { ...(config ?? sessionConfig) }; // Projects and permissions already live in generated TOML files; the SDK // cannot safely encode their path and selector keys as dotted overrides. @@ -4473,13 +4479,7 @@ export function scanRuntimeCodexConfig( permissions: { ...configuredPermissions, [SCAN_PERMISSION_PROFILE]: { - filesystem: { - ":root": "read", - ":workspace_roots": "write", - ...(protectedCredentialHome === undefined - ? {} - : { [protectedCredentialHome]: "read" }), - }, + filesystem: scanFilesystemPermissions(protectedCredentialHome), }, [POLICY_PERMISSION_PROFILE]: { filesystem: policyFilesystemPermissions(), @@ -4489,6 +4489,20 @@ export function scanRuntimeCodexConfig( }; } +function scanFilesystemPermissions( + credentialHome?: string, + workerConfigPath?: string, +): JsonObject { + return { + ":root": "read", + ":workspace_roots": "write", + ...(credentialHome === undefined ? {} : { [credentialHome]: "read" }), + ...(workerConfigPath === undefined + ? {} + : { [workerConfigPath]: { ".": "deny" } }), + }; +} + function policyFilesystemPermissions( gitMetadataPaths: readonly string[] = [], ): JsonObject { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 70c8652f7..771344866 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -119,6 +119,8 @@ test.each( name: "Synthetic provider", base_url: `https://${provider}.example.test/v1`, wire_api: "responses", + experimental_bearer_token: `synthetic-${name}-token`, + http_headers: { Authorization: `synthetic-${name}-header` }, ...(name === "command-auth" ? { auth: { @@ -160,13 +162,30 @@ test.each( }, prepareRuntime: async () => ({ ...preparedRuntime(home), configPath }), resolvePluginPython: async () => "/managed/python", - createCodex: () => ({ + createCodex: (options) => ({ startThread: () => ({ id: null, async runStreamed() { const runtime = parseToml( await readFile(`${configPath}.workers.toml`, "utf8"), ) as JsonObject; + const permissionOverride = options.configOverrides?.find( + (value) => + value.startsWith( + "permissions.codex_security_scan.filesystem=", + ), + ); + expect(permissionOverride).toBeDefined(); + expect(parseToml(permissionOverride!)["permissions"]).toEqual({ + codex_security_scan: { + filesystem: { + ":root": "read", + ":workspace_roots": "write", + [home]: "read", + [`${configPath}.workers.toml`]: { ".": "deny" }, + }, + }, + }); expect(runtime["model_provider"]).toBe(provider); expect(runtime["model_providers"]).toEqual( name === "default" From 5783953ab52d8dcc42b1f538881cc89730664905 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 17 Sep 2026 02:58:45 +0000 Subject: [PATCH 16/18] Preserve literal worker denials and use runtime executor settings --- .../mcp-app/src/deep-scan/executor.ts | 34 ++--- .../mcp-app/src/deep-scan/parent-sandbox.ts | 14 +-- .../mcp-app/tests/test_deep_scan_executor.mjs | 116 +++++++++++------- .../tests/test_deep_scan_parent_sandbox.mjs | 11 +- .../tests-ts/deep-scan-parent-denials.test.ts | 25 +++- 5 files changed, 119 insertions(+), 81 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 462c673e4..6e6a0aa59 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -29,8 +29,6 @@ import type { } from "./types.js"; export interface CodexSdkWorkerModelSettings { - /** Resolved by the execution owner, including when reconstructing a scan. */ - codexOptions?: CodexOptions; model?: string; reasoningEffort?: string; artifactContext?: CodexSdkWorkerArtifactContext; @@ -61,16 +59,12 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { ); } const workerProfile = workerPermissionProfile(parentSandbox); - const resolved = this.modelSettings.codexOptions; const originalCwd = process.cwd(); - const childEnv = await snapshotWorkerEnvironment(resolved?.env); - if (resolved?.apiKey !== undefined) childEnv.CODEX_API_KEY = resolved.apiKey; - // Snapshot per-scan selections once; a reconstructed owner can supply them. + const childEnv = await snapshotWorkerEnvironment(); + // Cache per-scan selections; reconstructed workers reload the same file. // Native account credentials continue to refresh in the selected home. const modelConfig: NonNullable = { - ...await (this.runtimeModelConfig ??= resolved?.config - ? Promise.resolve(resolved.config) - : workerModelConfig(childEnv)), + ...await (this.runtimeModelConfig ??= workerModelConfig(childEnv)), ...(this.modelSettings.model ? { model: this.modelSettings.model } : {}), // The CLI can add effort levels before the pinned SDK widens ThreadOptions. ...(this.modelSettings.reasoningEffort @@ -80,15 +74,12 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { const { model_providers: _providers, ...sdkModelConfig } = modelConfig; const configOverrides = [ ...modelProviderConfigOverride(modelConfig as JsonObject), - ...(resolved?.configOverrides ?? []), ...workerPermissionProfileConfigOverrides(workerProfile) ]; const openAiApiKey = environmentVariable(childEnv, "OPENAI_API_KEY", process.platform)?.trim(); const codexApiKey = environmentVariable(childEnv, "CODEX_API_KEY", process.platform)?.trim(); const codexPath = resolveCodexPath( - resolved?.codexPathOverride === undefined - ? childEnv - : { ...childEnv, CODEX_CLI_PATH: resolved.codexPathOverride }, + childEnv, process.platform, process.arch, originalCwd @@ -102,8 +93,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { // Older SDK/direct-plugin workers keep their home-selected provider. .filter(([key]) => key !== "model_providers" && modelConfig[key] !== undefined) .map(([key, value]) => `${key}=${inlineToml(value)}`), - ...configOverrides, - ...(resolved?.baseUrl ? [`openai_base_url=${tomlString(resolved.baseUrl)}`] : []) + ...configOverrides ], expectedProfile: workerProfile, env: childEnv, @@ -112,7 +102,6 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { }); const prompt = await fs.readFile(request.promptPath, "utf8"); const codex = new Codex({ - ...resolved, codexPathOverride: executablePathForSpawn(codexPath), env: childEnv, // Codex exec reads CODEX_API_KEY; the SDK maps apiKey to that variable. @@ -121,13 +110,12 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { config: { ...sdkModelConfig, mcp_servers: { - ...(isRecord(modelConfig.mcp_servers) ? modelConfig.mcp_servers : {}), // Discovery workers use the bundled skills and artifacts, not the parent workbench MCP. // A disabled server still needs a valid transport while Codex resolves plugin configuration. "codex-security": { command: "node", enabled: false }, ...this.compactArtifactServer(request) }, - ...workerSubagentConfig(request.subagents, modelConfig) + ...workerSubagentConfig(request.subagents) }, // Structured SDK config cannot preserve literal filesystem keys such as // ":root" or "/repo/.env"; raw overrides keep this inline TOML intact. @@ -270,16 +258,15 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { } } -function workerSubagentConfig(subagents: number, config: NonNullable) { +function workerSubagentConfig(subagents: number) { return { // V1 counts children; V2 counts the root plus its children. Keeping its // feature disabled lets the model choose either runtime without rejecting // inherited agents.max_threads configuration. ...(subagents > 0 - ? { agents: { ...(isRecord(config.agents) ? config.agents : {}), max_threads: subagents } } + ? { agents: { max_threads: subagents } } : {}), features: { - ...(isRecord(config.features) ? config.features : {}), multi_agent_v2: { enabled: false, max_concurrent_threads_per_session: subagents + 1 @@ -304,10 +291,11 @@ function workerPermissionProfile( const filesystemEntries: Array<[string, TomlValue]> = [[":root", "read"]]; const seenFilesystemKeys = new Set(); - for (const key of sandbox.filesystemDenies) { + for (const denial of sandbox.filesystemDenies) { + const key = typeof denial === "string" ? denial : denial.path; if (seenFilesystemKeys.has(key)) continue; seenFilesystemKeys.add(key); - filesystemEntries.push([key, "deny"]); + filesystemEntries.push([key, typeof denial === "string" ? "deny" : { ".": "deny" }]); } if (sandbox.globScanMaxDepth !== undefined) { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/parent-sandbox.ts b/plugins/codex-security/mcp-app/src/deep-scan/parent-sandbox.ts index 3a250283e..671e37287 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/parent-sandbox.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/parent-sandbox.ts @@ -8,10 +8,11 @@ export const CODEX_SANDBOX_STATE_META_CAPABILITY = "codex/sandbox-state-meta"; export type DeepWorkerParentSandbox = { /** * Validated path and glob keys copied into the worker's stricter root-read - * profile. Grants are intentionally not transported because Deep Scan + * profile. Objects retain literal paths containing glob characters. + * Grants are intentionally not transported because Deep Scan * workers never inherit parent write access. */ - readonly filesystemDenies: readonly string[]; + readonly filesystemDenies: readonly (string | { readonly path: string })[]; readonly globScanMaxDepth?: number; }; @@ -47,7 +48,7 @@ export function resolveDeepWorkerParentSandbox(extra: unknown): DeepWorkerParent const globScanMaxDepth = resolveGlobScanMaxDepth(filesystem); let hasRootRead = false; - const filesystemDenies: string[] = []; + const filesystemDenies: Array = []; for (const value of filesystem.entries) { const entry = record(value); if (!entry || !isKnownFilesystemAccess(entry.access)) { @@ -89,12 +90,7 @@ export function resolveDeepWorkerParentSandbox(extra: unknown): DeepWorkerParent "a parent filesystem denial path cannot be preserved" ); } - if (hasGlobMetacharacters(path.path)) { - throw unsupportedParentSandbox( - "a parent filesystem denial path with glob characters cannot be preserved" - ); - } - filesystemDenies.push(path.path); + filesystemDenies.push(hasGlobMetacharacters(path.path) ? { path: path.path } : path.path); } } else if (path.type === "glob_pattern") { if (!isNonEmptyString(path.pattern)) { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 5988e9769..7b226090f 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -5,6 +5,7 @@ import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { build } from "esbuild"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; @@ -17,7 +18,7 @@ const bundle = await build({ }, stdin: { // Test the environment snapshot without adding a production export. - contents: `${await readFile(executorSource, "utf8")}\nexport { snapshotWorkerEnvironment, codexWorkerConfig };`, + contents: `${await readFile(executorSource, "utf8")}\nexport { snapshotWorkerEnvironment, codexWorkerConfig, codexWorkerConfigPath };`, loader: "ts", resolveDir: path.dirname(fileURLToPath(executorSource)), sourcefile: fileURLToPath(executorSource) @@ -26,7 +27,7 @@ const bundle = await build({ platform: "node", write: false }); -const { CodexSdkWorkerExecutor, resolveCodexPath, snapshotWorkerEnvironment, codexWorkerConfig } = await import( +const { CodexSdkWorkerExecutor, resolveCodexPath, snapshotWorkerEnvironment, codexWorkerConfig, codexWorkerConfigPath } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); const errorsBundle = await build({ @@ -74,6 +75,19 @@ const deniedWorkerPermissionProfile = { }; try { + await runTests(); +} finally { + restoreEnv("FAKE_CODEX_MARKER", previousMarker); + await Promise.all(temporaryRoots.map((root) => rm(root, { recursive: true, force: true }))); +} + +async function runTests() { + if (process.argv[2] === "reconstructed-worker-fixture") { + return await testIsolatedReconstructedWorkers(process.argv[3]); + } + if (process.argv[2] === "provider-snapshot-fixture") { + return await testRuntimeProviderSnapshots(process.argv[3], process.argv[4]); + } await testOpenAiCredentialsReachWorker(); await testWorkerReasoningSummaries(); await testWorkerProviderSelection(); @@ -143,9 +157,6 @@ try { await testWindowsWorkerEnvironmentPreservesMixedCaseKeys(); await testWindowsLauncherSkipsExtensionlessNpmShim(); } -} finally { - restoreEnv("FAKE_CODEX_MARKER", previousMarker); - await Promise.all(temporaryRoots.map((root) => rm(root, { recursive: true, force: true }))); } function testSpawnPermissionErrorsAreNonRetryable() { @@ -742,12 +753,18 @@ async function testOpenAiCredentialsReachWorker() { } } -async function testIsolatedReconstructedWorkers() { +async function testIsolatedReconstructedWorkers(selectedName) { + if (!selectedName) { + await Promise.all(["first", "second"].map((name) => + promisify(childProcess.execFile)(process.execPath, [fileURLToPath(import.meta.url), "reconstructed-worker-fixture", name]) + )); + return; + } const previousMarker = process.env.FAKE_CODEX_MARKER; const originalSpawn = childProcess.spawn; const scans = []; try { - for (const name of ["first", "second"]) { + for (const name of [selectedName]) { const fixture = await fakeCodexFixture(deniedWorkerPermissionProfile); const codexHome = path.join(fixture.root, "home"); const configPath = path.join(fixture.root, "scan config.toml"); @@ -761,7 +778,9 @@ async function testIsolatedReconstructedWorkers() { service_tier: name === "first" ? "flex" : "fast" }; await writeFile(configPath, stringifyToml(config)); - await writeFile(`${configPath}.workers.toml`, stringifyToml(config)); + const workerConfigPath = codexWorkerConfigPath(configPath); + await mkdir(path.dirname(workerConfigPath), { recursive: true }); + await writeFile(workerConfigPath, stringifyToml(config)); await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); if (process.platform === "win32") { @@ -769,10 +788,8 @@ async function testIsolatedReconstructedWorkers() { } else { await symlink(process.execPath, executable); } - const codexOptions = { - codexPathOverride: executable, - baseUrl: `https://${name}.example.invalid/v1`, - env: { + const environment = { + CODEX_CLI_PATH: executable, PATH: path.dirname(process.execPath), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), CODEX_HOME: codexHome, @@ -780,37 +797,31 @@ async function testIsolatedReconstructedWorkers() { CODEX_API_KEY: `synthetic-${name}-credential`, FAKE_CODEX_MARKER: fixture.markerPath, FAKE_CODEX_SCAN_VALUE: name - } }; + Object.assign(process.env, environment); const settings = { - codexOptions, model: `fixture-${name}-override`, reasoningEffort: "ultra", parentSandbox: trustedParentSandboxWithDenials }; - scans.push({ name, fixture, config, configPath, promptPath, settings, executor: new CodexSdkWorkerExecutor(settings) }); + scans.push({ name, fixture, config, configPath, promptPath, settings, environment, executor: new CodexSdkWorkerExecutor(settings) }); } childProcess.spawn = (command, args, options) => { const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); - if (scan) assert.equal(command, path.toNamespacedPath(scan.settings.codexOptions.codexPathOverride)); + if (scan) assert.equal(command, path.toNamespacedPath(scan.environment.CODEX_CLI_PATH)); return originalSpawn(command, scan ? [scan.fixture.executablePath, ...args] : args, options); }; syncBuiltinESMExports(); for (const phase of ["fresh", "resume", "reconstructed"]) { for (const scan of scans) { - scan.settings.codexOptions.env.CODEX_API_KEY = `synthetic-${scan.name}-${phase}-credential`; - scan.settings.codexOptions.env.FAKE_CODEX_SCAN_VALUE = `${scan.name}-${phase}`; + scan.environment.CODEX_API_KEY = `synthetic-${scan.name}-${phase}-credential`; + scan.environment.FAKE_CODEX_SCAN_VALUE = `${scan.name}-${phase}`; + Object.assign(process.env, scan.environment); } if (phase === "reconstructed") { for (const scan of scans) { - // The caller restores recorded selections. Its old config file need - // not exist; current credentials still come from the selected home/env. - await rm(scan.configPath); - scan.executor = new CodexSdkWorkerExecutor({ - ...scan.settings, - codexOptions: { ...scan.settings.codexOptions, config: scan.config } - }); + scan.executor = new CodexSdkWorkerExecutor(scan.settings); } } for (const kind of ["discovery", "dedup"]) { @@ -825,8 +836,8 @@ async function testIsolatedReconstructedWorkers() { assert.equal(result.threadId, resumeThreadId ?? "fixture-thread-id"); const child = JSON.parse(await readFile(scan.fixture.markerPath, "utf8")); const preflight = JSON.parse(await readFile(scan.fixture.preflightMarkerPath, "utf8")); - assert.equal(await realpath(child.executable), await realpath(scan.settings.codexOptions.codexPathOverride)); - assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); + assert.equal(await realpath(child.executable), await realpath(scan.environment.CODEX_CLI_PATH)); + assert.equal(child.codexHome, scan.environment.CODEX_HOME); assert.equal(preflight.codexHome, child.codexHome); assert.equal(child.scanValue, `${scan.name}-${phase}`); assert.equal(child.configPath, scan.configPath); @@ -840,9 +851,6 @@ async function testIsolatedReconstructedWorkers() { assert.equal(child.argv.includes('model_reasoning_effort="ultra"'), true); assert.equal(preflight.argv.includes('model_reasoning_effort="ultra"'), true); assert.equal(preflight.argv.includes(`model=${JSON.stringify(scan.settings.model)}`), true); - const baseUrl = `openai_base_url=${JSON.stringify(scan.settings.codexOptions.baseUrl)}`; - assert.equal(child.argv.includes(baseUrl), true); - assert.equal(preflight.argv.includes(baseUrl), true); assertReadOnlyWorkerPolicy(child.argv); assertWorkerSubagentPolicy(child.argv, scan.name === "first" ? 0 : 2); assert.equal(workerPermissionProfileOverride(child.argv).includes('"/repo/.env"="deny"'), true); @@ -863,18 +871,25 @@ async function testIsolatedReconstructedWorkers() { } } -async function testRuntimeProviderSnapshots() { - const sharedHome = await mkdtemp(path.join(tmpdir(), "shared-worker-home-")); - temporaryRoots.push(sharedHome); +async function testRuntimeProviderSnapshots(selectedName, sharedHome) { + if (!selectedName) { + sharedHome = await mkdtemp(path.join(tmpdir(), "shared-worker-home-")); + temporaryRoots.push(sharedHome); + await Promise.all(["openrouter", "fireworks", "command-auth", "cloud.production", "cloud production", "shared-default", "shared-default-configured", "openai", "shared-external"].map((name) => + promisify(childProcess.execFile)(process.execPath, [fileURLToPath(import.meta.url), "provider-snapshot-fixture", name, sharedHome]) + )); + return; + } const originalSpawn = childProcess.spawn; const previousMarker = process.env.FAKE_CODEX_MARKER; const scans = []; try { - for (const name of ["openrouter", "fireworks", "command-auth", "cloud.production", "cloud production", "shared-default", "shared-default-configured", "openai", "shared-external"]) { + for (const name of [selectedName]) { const configPath = path.join(sharedHome, `${name}.toml`); + const workerConfigPath = codexWorkerConfigPath(configPath); const permissionProfile = { ...deniedWorkerPermissionProfile, - filesystem: { ...deniedWorkerPermissionProfile.filesystem, [`${configPath}.workers.toml`]: "deny" } + filesystem: { ...deniedWorkerPermissionProfile.filesystem, [workerConfigPath]: "deny" } }; const fixture = await fakeCodexFixture(permissionProfile); const promptPath = path.join(fixture.root, "prompt.md"); @@ -918,17 +933,21 @@ async function testRuntimeProviderSnapshots() { ? { model_provider: "openai", model_reasoning_summary: "concise" } : { model_provider: provider, model_providers: { [provider]: { base_url: "https://default.example.test/v1", env_key: `${provider.toUpperCase()}_API_KEY` } } }; await writeFile(configPath, stringifyToml(preflight)); - await writeFile(`${configPath}.workers.toml`, stringifyToml(codexWorkerConfig(input))); await writeFile(promptPath, "NULL_USAGE"); const codexHome = name.startsWith("shared-") ? sharedHome : path.join(fixture.root, "home"); await mkdir(codexHome, { recursive: true }); + await writeFile(workerConfigPath, stringifyToml(codexWorkerConfig(input))); // Simulate a later session replacing the shared home configuration. - await writeFile(path.join(codexHome, "config.toml"), stringifyToml(config)); + await writeFile(path.join(codexHome, "config.toml"), 'model_provider = "changed-home-provider"\n'); + const environment = { CODEX_CLI_PATH: process.execPath, CODEX_HOME: codexHome, CODEX_SECURITY_CONFIG_PATH: configPath, FAKE_CODEX_MARKER: fixture.markerPath, FAKE_CODEX_WORKER_CONFIG: workerConfigPath, SYNTHETIC_PROVIDER_HEADER: `synthetic-${name}-header-env`, ...(definition.env_key ? { [definition.env_key]: `synthetic-${name}-env-key` } : {}), FAKE_CODEX_PROVIDER_ENV_KEYS: JSON.stringify(["SYNTHETIC_PROVIDER_HEADER", ...(definition.env_key ? [definition.env_key] : [])]) }; + Object.assign(process.env, environment); const settings = { - codexOptions: { codexPathOverride: process.execPath, env: { CODEX_HOME: codexHome, CODEX_SECURITY_CONFIG_PATH: configPath, FAKE_CODEX_MARKER: fixture.markerPath, FAKE_CODEX_WORKER_CONFIG: `${configPath}.workers.toml`, SYNTHETIC_PROVIDER_HEADER: `synthetic-${name}-header-env`, ...(definition.env_key ? { [definition.env_key]: `synthetic-${name}-env-key` } : {}), FAKE_CODEX_PROVIDER_ENV_KEYS: JSON.stringify(["SYNTHETIC_PROVIDER_HEADER", ...(definition.env_key ? [definition.env_key] : [])]) } }, - model: "worker-model", reasoningEffort: "ultra", parentSandbox: { ...trustedParentSandboxWithDenials, filesystemDenies: [...trustedParentSandboxWithDenials.filesystemDenies, `${configPath}.workers.toml`] } + model: "worker-model", reasoningEffort: "ultra", parentSandbox: { + ...trustedParentSandboxWithDenials, + filesystemDenies: [...trustedParentSandboxWithDenials.filesystemDenies, workerConfigPath] + } }; - scans.push({ name, permissionProfile, fixture, configPath, promptPath, config, input, auth, settings, executor: new CodexSdkWorkerExecutor(settings) }); + scans.push({ name, permissionProfile, fixture, configPath, promptPath, config, input, auth, settings, environment, workerConfigPath, executor: new CodexSdkWorkerExecutor(settings) }); } childProcess.spawn = (command, args, options) => { const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); @@ -938,7 +957,7 @@ async function testRuntimeProviderSnapshots() { for (const phase of ["fresh", "resume", "reconstructed"]) { if (phase === "reconstructed") { for (const scan of scans) { - await writeFile(`${scan.configPath}.workers.toml`, stringifyToml(codexWorkerConfig(scan.input))); + await writeFile(scan.workerConfigPath, stringifyToml(codexWorkerConfig(scan.input))); scan.executor = new CodexSdkWorkerExecutor(scan.settings); } } @@ -957,8 +976,8 @@ async function testRuntimeProviderSnapshots() { assert.equal(JSON.stringify(child).includes(`synthetic-${scan.name}-unrelated-token`), false); assert.deepEqual(parseToml(child.workerConfig), phase === "resume" ? { model_provider: "changed-after-launch" } : scan.config); assert.deepEqual(child.providerAuthentication, Object.fromEntries( - JSON.parse(scan.settings.codexOptions.env.FAKE_CODEX_PROVIDER_ENV_KEYS) - .map((key) => [key, scan.settings.codexOptions.env[key]]) + JSON.parse(scan.environment.FAKE_CODEX_PROVIDER_ENV_KEYS) + .map((key) => [key, scan.environment[key]]) )); assert.deepEqual(overrides.model_providers, scan.config.model_providers, `${scan.name} ${phase} ${kind}`); for (const [key, value] of Object.entries(scan.auth)) { @@ -967,7 +986,7 @@ async function testRuntimeProviderSnapshots() { assert.equal(overrides.model_reasoning_effort, "ultra"); assert.equal(overrides.model_reasoning_summary, "concise"); assert.equal(overrides.service_tier, "flex"); - assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); + assert.equal(child.codexHome, scan.environment.CODEX_HOME); } const child = JSON.parse(await readFile(scan.fixture.markerPath, "utf8")); assertFlagPair(child.argv, "--model", "worker-model"); @@ -979,7 +998,7 @@ async function testRuntimeProviderSnapshots() { if (failures.length) throw new AggregateError(failures.map((outcome) => outcome.reason), "Worker runtime provider controls failed"); } if (phase === "fresh") { - for (const scan of scans) await writeFile(`${scan.configPath}.workers.toml`, 'model_provider = "changed-after-launch"\n'); + for (const scan of scans) await writeFile(scan.workerConfigPath, 'model_provider = "changed-after-launch"\n'); } } } finally { @@ -992,7 +1011,7 @@ async function testRuntimeProviderSnapshots() { async function testWorkerProviderSelection() { const fixture = await fakeCodexFixture(); const saved = Object.fromEntries( - ["CODEX_CLI_PATH", "CODEX_SECURITY_CONFIG_PATH", "OPENAI_API_KEY", "CODEX_API_KEY"].map((name) => [name, process.env[name]]) + ["CODEX_CLI_PATH", "CODEX_HOME", "CODEX_SECURITY_CONFIG_PATH", "OPENAI_API_KEY", "CODEX_API_KEY"].map((name) => [name, process.env[name]]) ); const originalSpawn = childProcess.spawn; try { @@ -1001,7 +1020,10 @@ async function testWorkerProviderSelection() { const configPath = path.join(fixture.root, "scan config.toml"); const promptPath = path.join(fixture.root, "prompt.md"); await writeFile(configPath, 'model_provider = "fixture-provider"\n'); - await writeFile(`${configPath}.workers.toml`, 'model_provider = "fixture-provider"\n'); + const workerConfigPath = codexWorkerConfigPath(configPath); + await mkdir(path.dirname(workerConfigPath), { recursive: true }); + await writeFile(workerConfigPath, 'model_provider = "fixture-provider"\n'); + process.env.CODEX_HOME = fixture.root; await writeFile(promptPath, "fixture provider selection"); process.env.CODEX_CLI_PATH = process.execPath; process.env.CODEX_SECURITY_CONFIG_PATH = configPath; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_parent_sandbox.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_parent_sandbox.mjs index 547b6002a..a73870bb8 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_parent_sandbox.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_parent_sandbox.mjs @@ -97,6 +97,15 @@ assert.deepEqual(resolveDeepWorkerParentSandbox(extra({ globScanMaxDepth: 3 }); +for (const deniedPath of ["/repo/*.env", "/repo/?.env", "/repo/temp[1]"]) { + assert.deepEqual(resolveDeepWorkerParentSandbox(extra({ + ...pinnedReadOnly, + file_system: { type: "restricted", entries: [rootRead, { + path: { type: "path", path: deniedPath }, access: "deny" + }] } + })), { filesystemDenies: [{ path: deniedPath }] }); +} + assert.throws( () => resolveDeepWorkerParentSandbox(extra({ ...pinnedReadOnly, @@ -214,7 +223,7 @@ for (const invalid of [ ] } }), - ...["", "relative/private", "/repo/*.env", "/repo/?.env", "/repo/[literal]"].map((deniedPath) => extra({ + ...["", "relative/private"].map((deniedPath) => extra({ ...pinnedReadOnly, file_system: { type: "restricted", diff --git a/sdk/typescript/tests-ts/deep-scan-parent-denials.test.ts b/sdk/typescript/tests-ts/deep-scan-parent-denials.test.ts index 7799a42ed..16faf29e3 100644 --- a/sdk/typescript/tests-ts/deep-scan-parent-denials.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-parent-denials.test.ts @@ -5,7 +5,10 @@ import { expect, test } from "bun:test"; import { parse } from "smol-toml"; import { loadBundledRuntime } from "./plugin-root.js"; -type Sandbox = { filesystemDenies: string[]; globScanMaxDepth?: number }; +type Sandbox = { + filesystemDenies: Array; + globScanMaxDepth?: number; +}; async function bundledPolicy() { const runtime = await loadBundledRuntime(); @@ -129,3 +132,23 @@ test("rejects parent denials that cannot be preserved", async () => { } expect(() => policy.resolve({})).toThrow("trusted parent sandbox metadata"); }); + +test("preserves bracket paths as literal denials at the bundled worker boundary", async () => { + const policy = await bundledPolicy(); + const denied = path.resolve("synthetic", "temp[1]", "credential-home"); + const sandbox = policy.resolve( + metadata([{ access: "deny", path: { type: "path", path: denied } }]), + ); + const config = parse(policy.overrides(sandbox).join("\n")); + expect(config["permissions"]).toEqual({ + codex_security_deep_scan_worker: { + extends: ":read-only", + filesystem: { + ":root": "read", + [denied]: { ".": "deny" }, + glob_scan_max_depth: 8, + }, + network: { enabled: false }, + }, + }); +}); From b80972ffb36223d1d13a18d057135340da5f1ef0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 17 Sep 2026 05:36:52 +0000 Subject: [PATCH 17/18] Preserve literal and glob worker denials with identical paths --- .../mcp-app/src/deep-scan/executor.ts | 29 ++++++++++++++----- .../mcp-app/tests/test_deep_scan_executor.mjs | 20 ++++++++++--- .../tests/test_deep_scan_parent_sandbox.mjs | 8 +++++ ...deep_scan_permission_profile_preflight.mjs | 24 +++++++++++++-- .../tests/test_deep_scan_stdio_lifecycle.mjs | 27 ++++++++++++++--- 5 files changed, 91 insertions(+), 17 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 6e6a0aa59..adfcd7b94 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -1,6 +1,6 @@ import { accessSync, constants as fsConstants, existsSync, promises as fs, readdirSync, statSync } from "node:fs"; import { createRequire } from "node:module"; -import { delimiter, dirname, isAbsolute, join, resolve, win32 } from "node:path"; +import { delimiter, dirname, isAbsolute, join, parse, resolve, win32 } from "node:path"; import { readCodexSessionTurn } from "../../../../../sdk/typescript/src/codex-session.js"; @@ -288,18 +288,33 @@ type TomlObject = { [key: string]: TomlValue }; function workerPermissionProfile( sandbox: DeepWorkerParentSandbox ): TomlObject { - const filesystemEntries: Array<[string, TomlValue]> = [[":root", "read"]]; - const seenFilesystemKeys = new Set(); + const filesystemEntries = new Map([[":root", "read"]]); + const literalPaths = new Set(sandbox.filesystemDenies.flatMap( + (denial) => typeof denial === "string" ? [] : [denial.path] + )); + const collidingGlobs = new Set(); for (const denial of sandbox.filesystemDenies) { const key = typeof denial === "string" ? denial : denial.path; - if (seenFilesystemKeys.has(key)) continue; - seenFilesystemKeys.add(key); - filesystemEntries.push([key, typeof denial === "string" ? "deny" : { ".": "deny" }]); + if (typeof denial === "string" && literalPaths.has(key)) { + collidingGlobs.add(key); + } else { + filesystemEntries.set(key, typeof denial === "string" ? "deny" : { ".": "deny" }); + } + } + + // Scoped glob keys keep both meanings without duplicate filesystem TOML keys. + for (const pattern of collidingGlobs) { + const root = parse(pattern).root; + const scope = filesystemEntries.get(root); + filesystemEntries.set(root, { + ...(scope === undefined ? {} : typeof scope === "object" ? scope : { ".": scope }), + [pattern.slice(root.length)]: "deny" + }); } if (sandbox.globScanMaxDepth !== undefined) { - filesystemEntries.push(["glob_scan_max_depth", sandbox.globScanMaxDepth]); + filesystemEntries.set("glob_scan_max_depth", sandbox.globScanMaxDepth); } return { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 7b226090f..49aadd3ab 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -53,6 +53,10 @@ const trustedParentSandboxWithDenials = Object.freeze({ "/repo/.env", "/repo/**/.secret", "/repo/**/*.pem", + "/repo/temp[1]", + { path: "/repo/temp[1]" }, + "/repo/secret[1]", + { path: "/repo/secret[1]" }, "/repo/.env" ], globScanMaxDepth: 3 @@ -69,6 +73,9 @@ const deniedWorkerPermissionProfile = { "/repo/.env": "deny", "/repo/**/.secret": "deny", "/repo/**/*.pem": "deny", + "/repo/temp[1]": { ".": "deny" }, + "/repo/secret[1]": { ".": "deny" }, + "/": { "repo/temp[1]": "deny", "repo/secret[1]": "deny" }, glob_scan_max_depth: 3 }, network: { enabled: false } @@ -675,9 +682,9 @@ async function testSdkInvocationAndThreadCapture() { assertFlagPair(invocation.argv, "--model", "gpt-5.6-luna"); assert.equal(invocation.argv.includes('model_reasoning_effort="xhigh"'), true); assertReadOnlyWorkerPolicy(invocation.argv); - assert.equal( - workerPermissionProfileOverride(invocation.argv), - 'permissions.codex_security_deep_scan_worker={extends=":read-only",filesystem={":root"="read","/repo/.env"="deny","/repo/**/.secret"="deny","/repo/**/*.pem"="deny",glob_scan_max_depth=3},network={enabled=false}}' + assert.deepEqual( + parseToml(workerPermissionProfileOverride(invocation.argv)).permissions.codex_security_deep_scan_worker, + deniedWorkerPermissionProfile ); assertWorkerSubagentPolicy(invocation.argv, 3); assertFlagPair(invocation.argv, "--cd", workingDirectory); @@ -853,7 +860,12 @@ async function testIsolatedReconstructedWorkers(selectedName) { assert.equal(preflight.argv.includes(`model=${JSON.stringify(scan.settings.model)}`), true); assertReadOnlyWorkerPolicy(child.argv); assertWorkerSubagentPolicy(child.argv, scan.name === "first" ? 0 : 2); - assert.equal(workerPermissionProfileOverride(child.argv).includes('"/repo/.env"="deny"'), true); + for (const invocation of [child, preflight]) { + assert.deepEqual( + parseToml(workerPermissionProfileOverride(invocation.argv)).permissions.codex_security_deep_scan_worker, + deniedWorkerPermissionProfile + ); + } assert.equal(child.argv.includes("resume"), resumeThreadId !== undefined); assert.equal(child.stdin.includes("continuation"), resumeThreadId !== undefined); })); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_parent_sandbox.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_parent_sandbox.mjs index a73870bb8..8451e1e81 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_parent_sandbox.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_parent_sandbox.mjs @@ -104,6 +104,14 @@ for (const deniedPath of ["/repo/*.env", "/repo/?.env", "/repo/temp[1]"]) { path: { type: "path", path: deniedPath }, access: "deny" }] } })), { filesystemDenies: [{ path: deniedPath }] }); + assert.deepEqual(resolveDeepWorkerParentSandbox(extra({ + ...pinnedReadOnly, + file_system: { type: "restricted", entries: [rootRead, { + path: { type: "glob_pattern", pattern: deniedPath }, access: "deny" + }, { + path: { type: "path", path: deniedPath }, access: "deny" + }] } + })), { filesystemDenies: [deniedPath, { path: deniedPath }] }); } assert.throws( diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_permission_profile_preflight.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_permission_profile_preflight.mjs index 36060566a..b55c5b451 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_permission_profile_preflight.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_permission_profile_preflight.mjs @@ -27,13 +27,15 @@ const expectedProfile = { description: "Generated Deep Scan worker profile.", filesystem: { ":root": "read", - "/repo/.env": "deny" + "/repo/.env": "deny", + "/repo/temp[1]": { ".": "deny" }, + "/": { "repo/temp[1]": "deny" } }, network: { enabled: false } }; const rawOverrides = [ `default_permissions="${profileId}"`, - `permissions.${profileId}={filesystem={":root"="read","/repo/.env"="deny"},network={enabled=false}}` + `permissions.${profileId}={filesystem={":root"="read","/repo/.env"="deny","/repo/temp[1]"={"."="deny"},"/"={"repo/temp[1]"="deny"}},network={enabled=false}}` ]; await testAllowedProfileAndRawArgv(); @@ -46,6 +48,7 @@ await testRepeatedCatalogCursorFailsClosed(); await testDisallowedProfileGivesAdminGuidance(); await testOtherManagedPolicyRejectionIsGeneric(); await testMergedProfileCollisionFailsClosed(); +await testDroppedLiteralOrGlobFailsClosed(); await testLiteralProtoKeyCollisionFailsClosed(); await testMalformedAndUnsupportedResponsesFailClosed(); await testEarlyExecutableExitIsNotVersionError(); @@ -314,6 +317,23 @@ async function testMergedProfileCollisionFailsClosed() { }); } +async function testDroppedLiteralOrGlobFailsClosed() { + for (const key of ["/repo/temp[1]", "/"]) { + const weakened = structuredClone(expectedProfile); + delete weakened.filesystem[key]; + await withFakeCodex({ + configResult: configReadResult(weakened), + catalogResults: [catalogResult(true)] + }, async ({ codexPath, cwd }) => { + await assert.rejects( + preflight(codexPath, cwd), + (error) => error?.name === "DeepScanNonRetryableError" + && error.message.includes("existing Codex configuration changes") + ); + }); + } +} + async function testLiteralProtoKeyCollisionFailsClosed() { const profileWithLiteralProtoKey = Object.fromEntries([ ...Object.entries(expectedProfile), diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 61a90ce26..d5a07a682 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -7,6 +7,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath, pathToFileURL } from "node:url"; import { build } from "esbuild"; +import { parse as parseToml } from "smol-toml"; const execFileAsync = promisify(execFile); const mcpAppRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -23,6 +24,12 @@ const parentSandboxState = { entries: [{ path: { type: "special", value: { kind: "root" } }, access: "read" + }, { + path: { type: "glob_pattern", pattern: "/repo/temp[1]" }, + access: "deny" + }, { + path: { type: "path", path: "/repo/temp[1]" }, + access: "deny" }] }, network: "restricted" @@ -30,6 +37,16 @@ const parentSandboxState = { sandboxCwd: pathToFileURL(pluginRoot).href }; +const workerPermissionProfile = { + extends: ":read-only", + filesystem: { + ":root": "read", + "/repo/temp[1]": { ".": "deny" }, + "/": { "repo/temp[1]": "deny" } + }, + network: { enabled: false } +}; + if (process.platform === "win32") { console.log("deep scan stdio lifecycle test skipped on Windows (POSIX fake Codex executable)"); } else { @@ -706,9 +723,11 @@ function assertReadOnlyWorkerInvocation(args) { const overrides = args.filter((arg) => arg.startsWith("permissions.codex_security_deep_scan_worker=") ); - assert.deepEqual(overrides, [ - 'permissions.codex_security_deep_scan_worker={extends=":read-only",filesystem={":root"="read"},network={enabled=false}}' - ]); + assert.equal(overrides.length, 1); + assert.deepEqual( + parseToml(overrides[0]).permissions.codex_security_deep_scan_worker, + workerPermissionProfile + ); } async function waitForScanId({ @@ -794,7 +813,7 @@ async function writeFakeCodex(executablePath) { " if (message.method === 'initialize') {", " result = { userAgent: 'fixture', codexHome: '/fixture', platformFamily: 'unix', platformOs: 'macos' };", " } else if (message.method === 'config/read') {", - " result = { config: { default_permissions: 'codex_security_deep_scan_worker', permissions: { codex_security_deep_scan_worker: { extends: ':read-only', filesystem: { ':root': 'read' }, network: { enabled: false } } } }, origins: {}, layers: null };", + ` result = { config: { default_permissions: 'codex_security_deep_scan_worker', permissions: { codex_security_deep_scan_worker: ${JSON.stringify(workerPermissionProfile)} } }, origins: {}, layers: null };`, " } else if (message.method === 'permissionProfile/list') {", " result = { data: [{ id: 'codex_security_deep_scan_worker', description: null, allowed: true }], nextCursor: null };", " } else if (message.method === 'account/read') {", From a82da863a51fad589736bfa461e54dd417de1631 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 17 Sep 2026 17:54:58 +0000 Subject: [PATCH 18/18] Wait for reconstructed worker fixtures to close before cleanup --- .../mcp-app/tests/test_deep_scan_executor.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 49aadd3ab..18c917f60 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -769,6 +769,7 @@ async function testIsolatedReconstructedWorkers(selectedName) { } const previousMarker = process.env.FAKE_CODEX_MARKER; const originalSpawn = childProcess.spawn; + const children = []; const scans = []; try { for (const name of [selectedName]) { @@ -816,7 +817,9 @@ async function testIsolatedReconstructedWorkers(selectedName) { childProcess.spawn = (command, args, options) => { const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); if (scan) assert.equal(command, path.toNamespacedPath(scan.environment.CODEX_CLI_PATH)); - return originalSpawn(command, scan ? [scan.fixture.executablePath, ...args] : args, options); + const child = originalSpawn(command, scan ? [scan.fixture.executablePath, ...args] : args, options); + if (scan) children.push(child); + return child; }; syncBuiltinESMExports(); @@ -880,6 +883,15 @@ async function testIsolatedReconstructedWorkers(selectedName) { childProcess.spawn = originalSpawn; syncBuiltinESMExports(); restoreEnv("FAKE_CODEX_MARKER", previousMarker); + // The SDK removes child listeners before the copied executable is safe to delete on Windows. + await Promise.all(children.map((child) => { + if (child.stdout.closed && child.stderr.closed + && (child.exitCode !== null || child.signalCode !== null)) return; + return new Promise((resolve) => { + child.once("close", resolve); + if (child.exitCode === null && child.signalCode === null) child.kill(); + }); + })); } }