diff --git a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts index af19bb3708..02e350f030 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/permission-profile-preflight.ts @@ -47,26 +47,55 @@ type PendingRequest = { type RuntimeConfigReadOptions = Pick< DeepScanPermissionProfilePreflightOptions, - "codexPath" | "cwd" | "configOverrides" | "env" | "signal" | "context" + "codexPath" | "cwd" | "configOverrides" | "env" | "signal" > & { + readonly context?: PreflightContext; readonly commandArgs?: readonly string[]; + readonly configCwd?: string; /** Full app-server invocation for callers that already constructed its arguments. */ readonly appServerArgs?: readonly string[]; readonly providerConfigOverrides?: readonly string[]; }; +type PreflightContext = "helper" | "parent-config"; + /** Read native effective settings, including managed provider requirements, without a turn. */ export async function readDeepScanRuntimeConfig( - options: RuntimeConfigReadOptions, -): Promise { + options: RuntimeConfigReadOptions & { + readonly plugin?: { marketplacePath: string; pluginName: string }; + }, +): Promise { return withPreflightClient(options, async (client) => { const response = await client.request("config/read", { - cwd: options.cwd, + cwd: options.configCwd ?? options.cwd, includeLayers: false, }); const config = record(response.config); if (!config) throw malformedPreflightError(options.context); - return config; + if (!options.plugin) return config; + // Portable plugins expose their admitted server names through native discovery. + // Preserve native routing if the selected Codex cannot provide that metadata. + const detail = await client + .request("plugin/read", options.plugin) + .catch(() => undefined); + options.signal.throwIfAborted(); + const plugin = record(detail?.plugin); + const summary = record(plugin?.summary); + const servers = plugin?.mcpServers; + if ( + summary?.installed !== true || + summary.enabled !== true || + !Array.isArray(servers) || + !servers.every((name) => typeof name === "string") + ) + return undefined; + return { + ...config, + mcp_servers: { + ...Object.fromEntries(servers.map((name) => [name, {}])), + ...record(config.mcp_servers), + }, + }; }); } @@ -266,7 +295,8 @@ async function withPreflightClient( options: RuntimeConfigReadOptions, operation: (client: AppServerPreflightClient) => Promise, ): Promise { - if (options.signal.aborted) throw abortError(options.signal.reason); + if (options.signal.aborted) + throw abortError(options.signal.reason, options.context); const client = new AppServerPreflightClient(options); try { await client.initialize(); @@ -320,6 +350,7 @@ class AppServerPreflightClient { args.push("--config", override); } args.push("app-server", "--stdio"); + if (options.configCwd !== undefined) args.push("--cd", options.configCwd); } this.child = spawn(executablePathForSpawn(options.codexPath), args, { @@ -361,7 +392,7 @@ class AppServerPreflightClient { }); const onAbort = () => { - this.fail(abortError(options.signal.reason)); + this.fail(abortError(options.signal.reason, options.context)); this.stopChild(); }; options.signal.addEventListener("abort", onAbort, { once: true }); @@ -667,21 +698,28 @@ function profileCollisionError(context?: "helper"): DeepScanNonRetryableError { ); } -function malformedPreflightError(context?: "helper"): Error { +function failedOperation(context?: PreflightContext): string { + return context === "parent-config" + ? "Could not read the Deep Scan parent configuration" + : `${subject(context)} cannot safely verify its read-only worker permission profile`; +} + +function malformedPreflightError(context?: PreflightContext): Error { return new Error( - `${subject(context)} cannot safely verify its read-only worker permission profile with this Codex configuration. ${subject(context)} did not run.`, + context === "parent-config" + ? `${failedOperation(context)}. Codex returned an invalid app-server response.` + : `${failedOperation(context)} with this Codex configuration. ${subject(context)} did not run.`, ); } function unsupportedCodexApiError( codexPath: string, api: string, - context?: "helper", + context?: PreflightContext, ): DeepScanNonRetryableError { return new DeepScanNonRetryableError( - subject(context) + - " cannot safely verify its read-only worker permission profile because " + - "the selected Codex executable " + + failedOperation(context) + + " because the selected Codex executable " + JSON.stringify(codexPath) + " does not support the required " + JSON.stringify(api) + @@ -698,7 +736,7 @@ function jsonRpcPreflightError( codexPath: string, method: string, value: unknown, - context?: "helper", + context?: PreflightContext, ): Error { const error = record(value); const code = @@ -712,9 +750,8 @@ function jsonRpcPreflightError( return failure; } return new Error( - subject(context) + - " cannot safely verify its read-only worker permission profile because " + - "the selected Codex executable " + + failedOperation(context) + + " because the selected Codex executable " + JSON.stringify(codexPath) + " returned an error for " + JSON.stringify(method) + @@ -730,7 +767,7 @@ function jsonRpcPreflightError( function codexExecutableStartError( codexPath: string, error: Error, - context?: "helper", + context?: PreflightContext, ): Error { const value = "code" in error ? error.code : undefined; const code = @@ -754,22 +791,24 @@ function codexExecutableExitError( codexPath: string, code: number | null, signal: NodeJS.Signals | null, - context?: "helper", + context?: PreflightContext, ): Error { + const operation = + context === "parent-config" + ? "the configuration read" + : "permission-profile verification"; const detail = code !== null - ? "exited before permission-profile verification completed with code " + - code + ? `exited before ${operation} completed with code ${code}` : signal !== null - ? "was terminated before permission-profile verification completed by signal " + - signal - : "exited before permission-profile verification completed"; + ? `was terminated before ${operation} completed by signal ${signal}` + : `exited before ${operation} completed`; return new Error(codexExecutableFailureMessage(codexPath, detail, context)); } function codexExecutableStdioError( codexPath: string, - context?: "helper", + context?: PreflightContext, ): Error { return new Error( codexExecutableFailureMessage( @@ -783,8 +822,10 @@ function codexExecutableStdioError( function codexExecutableFailureMessage( codexPath: string, detail: string, - context?: "helper", + context?: PreflightContext, ): string { + if (context === "parent-config") + return `${failedOperation(context)}. Codex executable ${JSON.stringify(codexPath)} ${detail}.`; return ( subject(context) + " cannot safely verify its read-only worker permission profile because " + @@ -822,7 +863,7 @@ export function deepScanPermissionProfileFallbackError( ); } -function subject(context?: "helper"): string { +function subject(context?: PreflightContext): string { return context === "helper" ? "Read-only Codex helper" : "Deep Scan"; } @@ -830,10 +871,12 @@ function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } -function abortError(reason: unknown): Error { +function abortError(reason: unknown, context?: PreflightContext): Error { if (reason instanceof Error) return reason; return new DOMException( - "Deep Scan worker permission profile preflight aborted.", + context === "parent-config" + ? "Deep Scan parent configuration read aborted." + : "Deep Scan worker permission profile preflight aborted.", "AbortError", ); } diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 32b4b9cad8..ea67044c0e 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -30,6 +30,7 @@ import { dirname, isAbsolute, join, + parse as parsePath, posix, relative, resolve, @@ -104,7 +105,12 @@ import { import type { ScanTokenUsage } from "./cost-model.js"; import { ScanProgressTracker, type DeepScanProgress } from "./scan-progress.js"; import { findScanSession } from "./scan-logs.js"; -import { DIRECT_DEEP_SCAN_CONTROL_USAGE, runDeepScan } from "./deep-scan.js"; +import { + DIRECT_DEEP_SCAN_CONTROL_USAGE, + deepScanParentToolOverrides, + readDeepScanParentConfig, + runDeepScan, +} from "./deep-scan.js"; import { deepScanOptions, resolveDeepScanConfig, @@ -122,7 +128,7 @@ import { type ScanSettings, type ScanPromptSettings, } from "./scan-settings.js"; -import { resolveScanPrompts } from "./prompt-files.js"; +import { readRegularInputFile, resolveScanPrompts } from "./prompt-files.js"; import { readScanTimeBudgetPolicy, renderScanTimeBudgetPolicy, @@ -675,6 +681,7 @@ interface ClientDependencies { runWorkbench?: typeof runWorkbench; matchFindings?: typeof matchScanFindingsInternal; runDeepScan?: typeof runDeepScan; + readDeepScanParentConfig?: typeof readDeepScanParentConfig; canRunDirectDeepScan?: () => boolean; } @@ -2856,9 +2863,6 @@ export class CodexSecurity { runtimePaths, options.auth, git, - mode === "deep" && !directDeepScan - ? deepScanParentConfig(session.sessionConfig) - : undefined, ); const threadOptions: ThreadOptions = { threadSource: CODEX_SECURITY_THREAD_SOURCES.scan, @@ -2866,40 +2870,112 @@ export class CodexSecurity { skipGitRepoCheck: true, approvalPolicy, }; - let thread: CodexThreadLike; - if (typeof resumeThreadId === "string") { - if (codex.resumeThread === undefined) { + const turnOptions: TurnOptions = { + signal, + cyberAccessProgram: options.cyberAccessProgram, + }; + const postScanPrompt = options.postScanPrompt; + const openThread = (client: CodexClientLike, id: unknown) => { + if (typeof id !== "string") return client.startThread(threadOptions); + if (!client.resumeThread) throw new CodexSecurityError( "The configured Codex client does not support resuming sessions.", ); - } - thread = codex.resumeThread(resumeThreadId, threadOptions); + return client.resumeThread(id, threadOptions); + }; + if (typeof resumeThreadId === "string") { tracker.start(resumeThreadId); if (budgetRecovery !== null) budgetRecovery.threadId = resumeThreadId; await tracker.refresh().catch(reportTrackingError); - checkOpen(); - } else { - thread = codex.startThread(threadOptions); } - const serializedPaths = - normalized.kind === "paths" ? jsonForPrompt(normalized.paths) : null; checkOpen(); - if (serializedPaths !== null && targetPathsFile !== null) { - await writeFile(targetPathsFile, `${serializedPaths}\n`, { - flag: "wx", - mode: 0o400, + if (normalized.kind === "paths" && targetPathsFile !== null) { + await writeFile( + targetPathsFile, + `${jsonForPrompt(normalized.paths)}\n`, + { + flag: "wx", + mode: 0o400, + signal, + }, + ); + await chmod(targetPathsFile, 0o400); + checkOpen(); + } + let parentCodex = codex; + const pluginServers = + mode === "deep" && !directDeepScan && !this.#dependencies.hostedScan + ? await readInstalledDeepScanPluginServers( + runtime.plugin.installedRoot, + ) + : undefined; + if (pluginServers !== undefined) { + // The scan is already registered when native routing is inspected. + if (postScanPrompt?.trim()) { + runPostScan = () => + openThread(codex, resumeThreadId).runStreamed( + postScanPrompt, + turnOptions, + ); + } + const parentConfig = await ( + this.#dependencies.readDeepScanParentConfig ?? + readDeepScanParentConfig + )({ + codexOptions, + preflightCommand: + await this.#providerPreflightCommand(effectiveConfig), + scanDir, signal, + ...(pluginServers === "native" && { + plugin: { + marketplacePath: join( + runtime.plugin.marketplaceRoot, + ".agents", + "plugins", + "marketplace.json", + ), + pluginName: runtime.plugin.name, + }, + }), }); - await chmod(targetPathsFile, 0o400); + const overrides = + parentConfig === undefined + ? [] + : deepScanParentToolOverrides( + parentConfig, + pluginServers === "native" ? {} : pluginServers, + ); + if (overrides.length > 0) { + // Only the coordinator turn needs to keep its MCP call outside a yielding cell. + parentCodex = await this.#dependencies.createCodex({ + ...codexOptions, + configOverrides: [ + ...(codexOptions.configOverrides ?? []), + ...overrides, + ], + }); + } + runPostScan = null; } checkOpen(); - const turnOptions: TurnOptions = { - signal, - cyberAccessProgram: options.cyberAccessProgram, - }; - const postScanPrompt = options.postScanPrompt; + const thread = openThread(parentCodex, resumeThreadId); + let scanThreadId = thread.id; if (postScanPrompt?.trim()) { - runPostScan = () => thread.runStreamed(postScanPrompt, turnOptions); + runPostScan = () => { + if ( + (parentCodex !== codex || directDeepScan) && + scanThreadId !== null + ) { + return openThread(codex, scanThreadId).runStreamed( + postScanPrompt, + turnOptions, + ); + } + const followUp = + parentCodex === codex ? thread : codex.startThread(threadOptions); + return followUp.runStreamed(postScanPrompt, turnOptions); + }; } if (mode === "standard" && options.maxTimeMinutes !== undefined) { const timePolicy = await readScanTimeBudgetPolicy( @@ -2953,17 +3029,7 @@ export class CodexSecurity { "Codex did not resume the original scan session.", ); } - if (directDeepScan && postScanPrompt?.trim()) { - runPostScan = () => { - if (codex.resumeThread === undefined) - throw new CodexSecurityError( - "The configured Codex client does not support resuming sessions.", - ); - return codex - .resumeThread(threadId, threadOptions) - .runStreamed(postScanPrompt, turnOptions); - }; - } + scanThreadId = threadId; if (resumeThreadId !== undefined) { return; } @@ -5813,32 +5879,6 @@ export function classifyConnectionFailure( return "unknown"; } -function deepScanParentConfig(config: JsonObject): JsonObject { - const resolved = resolveCodexProfile(config); - const features = resolved["features"] ?? {}; - if (!isRecord(features)) return resolved; - const mode = features["code_mode"]; - const settings = typeof mode === "boolean" ? { enabled: mode } : (mode ?? {}); - // Leave malformed user settings for the native config parser to reject. - if (!isRecord(settings)) return resolved; - const direct = settings["direct_only_tool_namespaces"] ?? []; - if (!Array.isArray(direct)) return resolved; - // The native parent waits for direct tool calls before it can end its turn. - // A code-mode cell can yield the still-running coordinator to the model. - return { - ...resolved, - features: { - ...features, - code_mode: { - ...settings, - direct_only_tool_namespaces: [ - ...new Set([...direct, "mcp__codex_security", "codex_security"]), - ], - }, - }, - }; -} - export function scanRuntimeCodexConfig( config: JsonObject, protectedCredentialHome?: string, @@ -6157,14 +6197,12 @@ async function requireCurrentDeepScanPlugin( "utf8", ), ); - const capabilities = isRecord(manifest) - ? manifest["codexSecurity"] - : undefined; if ( runtime.deepScanConfigPath === undefined || - !isRecord(capabilities) || - capabilities["directDeepScanEngine"] !== 1 || - capabilities["workerProviderSnapshot"] !== 4 + !isRecord(manifest) || + !isRecord(manifest["codexSecurity"]) || + manifest["codexSecurity"]["directDeepScanEngine"] !== 1 || + manifest["codexSecurity"]["workerProviderSnapshot"] !== 4 ) { throw new CodexSecurityError( "Deep Scan requires the current plugin's direct engine and isolated worker runtime support. Update the custom plugin or use the bundled plugin.", @@ -6230,23 +6268,68 @@ function selectedWorkerRuntimeConfig( }; } -async function pluginSupportsIsolatedDeepScanConfig( +/** Read the declaration selected by the installed Codex plugin manifest. */ +async function readInstalledDeepScanPluginServers( + installedRoot: string, +): Promise | "native" | undefined> { + const read = async (path: string): Promise => + JSON.parse( + await readRegularInputFile(join(installedRoot, path), installedRoot), + ); + try { + const portable = await read("plugin.json").catch(() => undefined); + // A portable manifest owns MCP. Its legacy overlay only forwards environment variables. + if ( + isRecord(portable) && + typeof portable["$schema"] === "string" && + portable["$schema"].startsWith("https://agent-plugins.org/schemas/") + ) + return "native"; + const manifest = await read(".codex-plugin/plugin.json"); + if (!isRecord(manifest)) return; + let source = manifest["mcpServers"]; + if (!isRecord(source)) { + const path = + typeof source === "string" && source.startsWith("./") + ? source.slice(2) + : ""; + // Native Codex ignores invalid selectors and uses the installed default. + const valid = + path && + !parsePath(path).root && + !path.split(sep === "\\" ? /[/\\]/u : "/").includes(".."); + source = await read(valid ? path : ".mcp.json"); + } + if (!isRecord(source)) return; + return isRecord(source["mcpServers"]) ? source["mcpServers"] : source; + } catch { + return undefined; + } +} + +async function readDeepScanPluginServers( pluginRoot: string, -): Promise { - let configuration: unknown; +): Promise | undefined> { try { - configuration = JSON.parse( + const configuration: unknown = JSON.parse( await readFile(join(pluginRoot, ".mcp.json"), "utf8"), ); + const servers = isRecord(configuration) + ? configuration["mcpServers"] + : undefined; + return isRecord(servers) ? servers : undefined; } catch { - return false; + return undefined; } - if (!isRecord(configuration)) return false; - const servers = configuration["mcpServers"]; - if (!isRecord(servers)) return false; - const server = servers["codex-security"]; - if (!isRecord(server)) return false; - const environment = server["env_vars"]; +} + +async function pluginSupportsIsolatedDeepScanConfig( + pluginRoot: string, +): Promise { + const server = (await readDeepScanPluginServers(pluginRoot))?.[ + "codex-security" + ]; + const environment = isRecord(server) ? server["env_vars"] : undefined; return ( Array.isArray(environment) && environment.includes(DEEP_SCAN_CONFIG_PATH_ENVIRONMENT) diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 167fb54ade..3f0a5845a4 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -178,6 +178,8 @@ export function inlineToml(value: JsonValue): string { throw new ConfigurationError( "Codex TOML overrides cannot contain null values.", ); + // Match the Codex SDK's numeric spelling, including integers above 2**53 - 1. + if (typeof value === "number" && Number.isFinite(value)) return String(value); if (Array.isArray(value)) return `[${value.map(inlineToml).join(",")}]`; if (isObject(value)) { return `{${Object.entries(value) diff --git a/sdk/typescript/src/deep-scan.ts b/sdk/typescript/src/deep-scan.ts index e81f1079a9..37a60ba42f 100644 --- a/sdk/typescript/src/deep-scan.ts +++ b/sdk/typescript/src/deep-scan.ts @@ -2,11 +2,23 @@ import { spawn } from "node:child_process"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import type { CodexOptions } from "@openai/codex-sdk"; +import { parse } from "smol-toml"; import { bundledCodexSdkEnvironment } from "./codex-sdk-environment.js"; import { CodexSecurityError } from "./errors.js"; -import { executablePathForSpawn, type CodexCommand } from "./runtime.js"; +import { inlineToml, type JsonObject } from "./config.js"; +import { isRecord } from "./record.js"; +import { + executablePathForSpawn, + PLUGIN_NAME, + runCodexCommand, + type CodexCommand, +} from "./runtime.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; -import { withCodexPreflightLock } from "./provider-profile.js"; +import { + nativePermissionPreflight, + nativeProfileClient, + withCodexPreflightLock, +} from "./provider-profile.js"; export interface DirectDeepScanOptions { codexOptions: CodexOptions & { nativeProfile?: string }; @@ -19,6 +31,198 @@ export interface DirectDeepScanOptions { signal: AbortSignal; } +// The stock SDK emits one flag per leaf, including literal dotted keys in order. +function sdkConfigFlags(config: JsonObject, prefix = ""): string[] { + return Object.entries(config).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return isRecord(value) && Object.keys(value).length + ? sdkConfigFlags(value, path) + : [`${path}=${inlineToml(value)}`]; + }); +} + +// Older app-servers cannot read the scan's native configuration without first +// loading the caller's project. Keep their coordinator routing scoped to the +// settings supplied to exec; native policy still applies to the actual turn. +function suppliedParentRouting( + overrides: readonly string[], +): JsonObject | undefined { + const routing = new Map(); + for (const override of overrides) { + const key = override.slice(0, override.indexOf("=")); + if (/^(?:features|mcp_servers)(?:\.|$)/u.test(key)) + routing.set(key, override); + } + try { + const config = parse([...routing.values()].join("\n")) as JsonObject; + const servers = config["mcp_servers"]; + const owner = isRecord(servers) ? servers[PLUGIN_NAME] : undefined; + // Even a supplied transport can inherit a machine-level direct opt-out. + if (isRecord(owner) && !Array.isArray(owner["omit_tools_from"])) return; + return config; + } catch { + // Leave overlapping or malformed TOML for exec's own parser. + return undefined; + } +} + +/** Read the same native layers and CLI overrides as the parent before adding its tool. */ +export async function readDeepScanParentConfig( + options: Pick< + DirectDeepScanOptions, + "codexOptions" | "preflightCommand" | "scanDir" | "signal" + > & { plugin?: { marketplacePath: string; pluginName: string } }, +): Promise { + const { codexOptions, preflightCommand: command } = options; + const config = codexOptions.config ?? {}; + // The provider-profile launcher emits whole top-level tables instead. + const overrides = + codexOptions.nativeProfile === undefined + ? sdkConfigFlags(config) + : (await nativeProfileClient()).profileConfigOverrides(config); + const environment = bundledCodexSdkEnvironment(command.command, { + ...codexOptions.env, + ...(codexOptions.apiKey === undefined + ? {} + : { CODEX_API_KEY: codexOptions.apiKey }), + }); + const configOverrides = [ + ...overrides, + ...(codexOptions.configOverrides ?? []), + ]; + // Subcommand help does not load a project. A global --cd is ignored by + // older app-servers, so require support on the subcommand itself. + const help = await runCodexCommand( + command, + ["app-server", "--cd", options.scanDir, "--help"], + environment, + undefined, + options.signal, + ); + if (!help.success || !/^\s*(?:-C,\s*)?--cd\s+<[^>\n]+>/mu.test(help.stdout)) + return options.plugin ? undefined : suppliedParentRouting(configOverrides); + + const native = await nativePermissionPreflight(); + return withCodexPreflightLock(environment, options.signal, () => + native.readDeepScanRuntimeConfig({ + codexPath: command.command, + commandArgs: command.args, + // The parent launcher inherits this directory and passes the scan via --cd. + cwd: process.cwd(), + configCwd: options.scanDir, + configOverrides, + env: environment, + signal: options.signal, + context: "parent-config", + plugin: options.plugin, + }), + ); +} + +/** Keep direct dispatch scoped to the original SDK coordinator turn and MCP server. */ +export function deepScanParentToolOverrides( + config: JsonObject, + pluginServers: Record, +): string[] { + const features = config["features"] ?? {}; + if (!isRecord(features)) return []; + const mode = features["code_mode"]; + if (mode !== undefined && typeof mode !== "boolean" && !isRecord(mode)) + return []; + const direct = + (isRecord(mode) ? mode["direct_only_tool_namespaces"] : undefined) ?? []; + if (!Array.isArray(direct)) return []; + + const owner = PLUGIN_NAME; + const bare = "codex_security"; + const prefixed = "mcp__codex_security"; + let required = [prefixed, bare]; + const overrides: string[] = []; + const configured = config["mcp_servers"]; + // Native registrations take precedence over the selected plugin's entries. + const servers: Record = { + ...pluginServers, + ...(isRecord(configured) ? configured : {}), + }; + const ownerConfig = servers[owner]; + if (!isRecord(ownerConfig) || ownerConfig["enabled"] === false) return []; + const omitted = ownerConfig["omit_tools_from"]; + if (Array.isArray(omitted) && omitted.includes("direct")) return []; + // A raw transport or an existing omission leaf can be updated in place. + // Adding a new registration would shadow a plugin's transport and policy. + if ( + isRecord(configured) && + Object.hasOwn(configured, owner) && + (Array.isArray(omitted) || + typeof ownerConfig["command"] === "string" || + typeof ownerConfig["url"] === "string") + ) { + if (omitted != null && !Array.isArray(omitted)) return []; + const surfaces = omitted ?? []; + if (surfaces.includes("code_mode") && surfaces.includes("deferred")) + return []; + return [ + `mcp_servers.${owner}.omit_tools_from=${inlineToml([...new Set([...surfaces, "code_mode", "deferred"])])}`, + ]; + } + const option = features["non_prefixed_mcp_tool_names"]; + const enabled = + option === true || (isRecord(option) && option["enabled"] === true); + const selected = isRecord(option) ? option["server_names"] : undefined; + function namespace(server: string): string { + const name = server.replace(/[-.:@/]/gu, "_"); + const omitPrefix = + enabled && (!Array.isArray(selected) || selected.includes(server)); + return omitPrefix || name.startsWith("mcp__") ? name : `mcp__${name}`; + } + const occupied = new Set( + Object.entries(servers) + .filter( + ([server, settings]) => + server !== owner && + isRecord(settings) && + settings["enabled"] !== false && + (!Array.isArray(settings["enabled_tools"]) || + settings["enabled_tools"].length > 0), + ) + .map(([server]) => namespace(server)), + ); + const current = namespace(owner); + if (occupied.has(current)) { + const excluded = isRecord(mode) ? mode["excluded_tool_namespaces"] : null; + const routed = Array.isArray(excluded) ? [...direct, ...excluded] : direct; + // Moving the owner also renames its peers. Keep names that the caller + // explicitly routed or excluded from Code Mode. + if ( + current === bare || + occupied.has(bare) || + routed.some( + (name) => + name === current || + (typeof name === "string" && + /^mcp__codex_security_[0-9a-f]{12}$/u.test(name)), + ) || + (option != null && !enabled) + ) + return []; + overrides.push( + `features.non_prefixed_mcp_tool_names=${inlineToml({ + enabled: true, + server_names: [...(Array.isArray(selected) ? selected : []), owner], + })}`, + ); + required = [bare]; + } else if (occupied.has(prefixed) || occupied.has(bare)) { + required = [current]; + } + if (!required.every((name) => direct.includes(name))) { + overrides.push( + `features.code_mode.direct_only_tool_namespaces=${inlineToml([...new Set([...direct, ...required])])}`, + ); + } + return overrides; +} + // The direct coordinator records context but never calls a model itself. export const DIRECT_DEEP_SCAN_CONTROL_USAGE = { input_tokens: 0, diff --git a/sdk/typescript/src/provider-profile.ts b/sdk/typescript/src/provider-profile.ts index def3dd0b67..5f2dd9fd1e 100644 --- a/sdk/typescript/src/provider-profile.ts +++ b/sdk/typescript/src/provider-profile.ts @@ -179,7 +179,7 @@ export async function withCodexPreflightLock( } } -async function nativePermissionPreflight() { +export async function nativePermissionPreflight() { return await import( pathToFileURL( join( @@ -191,7 +191,7 @@ async function nativePermissionPreflight() { ); } -async function nativeProfileClient() { +export async function nativeProfileClient() { return await import( pathToFileURL( join(await bundledPluginRoot(), "scripts", "codex_profile.mjs"), diff --git a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts index fb95d691b9..9d45d7eb58 100644 --- a/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts +++ b/sdk/typescript/tests-ts/api-deep-scan-runtime-routing.test.ts @@ -1,8 +1,16 @@ -import { mkdir } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, mock, test } from "bun:test"; import type { CodexOptions } from "@openai/codex-sdk"; -import type { DirectDeepScanOptions } from "../src/deep-scan.js"; +import { parse } from "smol-toml"; +import type { JsonObject, JsonValue } from "../src/config.js"; +import { + deepScanParentToolOverrides, + readDeepScanParentConfig, + type DirectDeepScanOptions, +} from "../src/deep-scan.js"; +import { resolveCodexCommand } from "../src/runtime.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; import { TestClient } from "./support/api-client.js"; import { preparedRuntime, @@ -13,6 +21,165 @@ import { createApiTestFixtures } from "./support/temporary-directories.js"; const { temporaryDirectory, cleanup } = createApiTestFixtures(); afterEach(cleanup); +test("a colliding MCP server leaves only the original coordinator's namespace direct", () => { + const owner = "codex-security"; + const alias = "codex.security"; + const bare = "codex_security"; + const prefixed = "mcp__codex_security"; + const servers = { [owner]: { enabled: true }, [alias]: { enabled: true } }; + const selected = (...names: string[]) => ({ + enabled: true, + server_names: names, + }); + const direct = (...names: string[]) => ({ + direct_only_tool_namespaces: names, + }); + const overrides = ( + prefix?: JsonValue, + existing: string[] = ["audit"], + registrations: JsonObject = servers, + ) => { + const config = { + mcp_servers: registrations, + features: { + ...(prefix === undefined + ? {} + : { non_prefixed_mcp_tool_names: prefix }), + code_mode: direct(...existing), + }, + }; + return parse(deepScanParentToolOverrides(config, {}).join("\n"))[ + "features" + ]; + }; + + expect(overrides()).toEqual({ + non_prefixed_mcp_tool_names: selected(owner), + code_mode: direct("audit", bare), + }); + expect(overrides(selected("retained-tools"))).toEqual({ + non_prefixed_mcp_tool_names: selected("retained-tools", owner), + code_mode: direct("audit", bare), + }); + expect(overrides(selected(alias))).toEqual({ + code_mode: direct("audit", prefixed), + }); + expect(overrides(selected(owner))).toEqual({ + code_mode: direct("audit", bare), + }); + expect( + overrides(undefined, ["audit"], { + ...servers, + [alias]: { enabled: false }, + }), + ).toEqual({ + code_mode: direct("audit", prefixed, bare), + }); + + expect( + overrides(false, ["audit"], { ...servers, [alias]: { enabled_tools: [] } }), + ).toEqual({ code_mode: direct("audit", prefixed, bare) }); + expect( + overrides(false, ["audit"], { + ...servers, + [alias]: { enabled_tools: ["peer_ping"] }, + }), + ).toBeUndefined(); + expect(overrides(false)).toBeUndefined(); + expect(overrides(true)).toBeUndefined(); + expect(overrides(undefined, [prefixed])).toBeUndefined(); + const guardedPeer = `${prefixed}_9721dc6aa9d2`; + expect(overrides(undefined, [guardedPeer])).toBeUndefined(); + expect( + deepScanParentToolOverrides( + { features: { code_mode: { excluded_tool_namespaces: [guardedPeer] } } }, + servers, + ), + ).toEqual([]); + expect( + overrides(undefined, [], { ...servers, [owner]: { enabled: false } }), + ).toBeUndefined(); + expect( + overrides(selected(bare), [], { ...servers, [bare]: { enabled: true } }), + ).toBeUndefined(); +}); + +test("a configured owner stays direct across native tool filtering without changing a sibling's routing", () => { + const owner = "codex-security"; + const peer = "codex_security"; + const guarded = "mcp__codex_security_9721dc6aa9d2"; + const overrides = (transport: JsonObject, selected: string[]) => + parse( + deepScanParentToolOverrides( + { + mcp_servers: { + [owner]: transport, + [peer]: { command: "peer", enabled_tools: selected }, + }, + features: { + non_prefixed_mcp_tool_names: false, + code_mode: { direct_only_tool_namespaces: [guarded] }, + }, + }, + {}, + ).join("\n"), + ); + const transports: JsonObject[] = [ + { command: "coordinator", omit_tools_from: ["code_mode"] }, + { url: "https://coordinator.example/mcp" }, + ]; + for (const transport of transports) { + for (const selected of [["peer_ping"], ["absent"]]) { + expect(overrides(transport, selected)).toEqual({ + mcp_servers: { + [owner]: { omit_tools_from: ["code_mode", "deferred"] }, + }, + }); + } + } + expect( + overrides({ command: "coordinator", omit_tools_from: ["direct"] }, [ + "absent", + ]), + ).toEqual({}); +}); + +test("native MCP registrations take precedence over the selected plugin's names and opt-outs", () => { + const owner = "codex-security"; + const alias = "codex.security"; + const plugin = { [owner]: {}, [alias]: {} }; + const noDirect = { omit_tools_from: ["direct"] }; + const overrides = (servers: JsonObject, selected: JsonObject) => + parse( + deepScanParentToolOverrides({ mcp_servers: servers }, selected).join( + "\n", + ), + )["features"]; + const isolated = { + non_prefixed_mcp_tool_names: { enabled: true, server_names: [owner] }, + code_mode: { direct_only_tool_namespaces: ["codex_security"] }, + }; + + expect(overrides({}, plugin)).toEqual(isolated); + expect(overrides({ [alias]: { enabled: false } }, plugin)).toEqual({ + code_mode: { + direct_only_tool_namespaces: ["mcp__codex_security", "codex_security"], + }, + }); + expect(overrides({}, { [alias]: {} })).toBeUndefined(); + expect(overrides({}, { ...plugin, [owner]: noDirect })).toBeUndefined(); + expect(overrides({ [owner]: noDirect }, plugin)).toBeUndefined(); + expect(overrides({ [owner]: {} }, { ...plugin, [owner]: noDirect })).toEqual( + isolated, + ); +}); + +const parentWithNode = { + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", +} as const; + test.each([ { name: "inherited Node override", @@ -44,6 +211,69 @@ test.each([ runtimeNode: null, route: "direct", }, + { + name: "installed plugin excludes the direct surface", + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", + pluginOmit: ["direct"], + directOmitted: true, + }, + { + name: "configured server permits direct use despite the installed plugin exclusion", + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", + pluginOmit: ["direct"], + configuredOmit: [], + }, + { + name: "configured server excludes the direct surface", + dependencyNode: null, + runtimeNode: "selected runtime", + route: "parent", + configuredOmit: ["direct"], + directOmitted: true, + }, + { + ...parentWithNode, + name: "inline MCP declaration allowing direct calls", + declaration: "inline", + pluginOmit: [], + }, + { + ...parentWithNode, + name: "inline MCP declaration excluding direct calls", + declaration: "inline", + pluginOmit: ["direct"], + directOmitted: true, + }, + { + ...parentWithNode, + name: "alternate MCP declaration allowing direct calls", + declaration: "alternate", + pluginOmit: [], + }, + { + ...parentWithNode, + name: "alternate MCP declaration excluding direct calls", + declaration: "alternate", + pluginOmit: ["direct"], + directOmitted: true, + }, + { + ...parentWithNode, + name: "the installed default when an alternate MCP selector contains a parent component", + declaration: "invalid", + pluginOmit: ["direct"], + }, + { + ...parentWithNode, + name: "an alternate MCP file reached through a link outside the installed plugin", + declaration: "outside", + pluginOmit: [], + directOmitted: true, + }, { name: "invalid code mode supplied by the caller", dependencyNode: null, @@ -59,6 +289,57 @@ test.each([ await mkdir(repository); await mkdir(codexHome); await mkdir(scanDir, { mode: 0o700 }); + const runtime = preparedRuntime(codexHome); + if (scenario.pluginOmit) { + const installed = join(root, "installed-plugin"); + await mkdir(join(installed, ".codex-plugin"), { recursive: true }); + const manifest = JSON.parse( + await readFile(join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), "utf8"), + ); + const servers = (omit: readonly string[]) => ({ + "codex-security": { command: "fixture", omit_tools_from: omit }, + }); + const declared = servers(scenario.pluginOmit); + let defaults = declared; + if ("declaration" in scenario) { + // Only the installed manifest selects this declaration. + defaults = servers(scenario.directOmitted ? [] : ["direct"]); + if (scenario.declaration === "inline") { + manifest.mcpServers = declared; + } else { + manifest.mcpServers = "./routing/servers.json"; + const routing = join(installed, "routing"); + const linked = scenario.declaration === "outside"; + const directory = linked ? join(root, "outside-plugin") : routing; + await mkdir(directory); + if (scenario.declaration === "invalid") { + await mkdir(join(directory, "nested")); + manifest.mcpServers = "./routing/nested/../servers.json"; + defaults = servers([]); + } + if (linked) { + await symlink( + directory, + routing, + process.platform === "win32" ? "junction" : "dir", + ); + } + await writeFile( + join(directory, "servers.json"), + JSON.stringify({ mcpServers: declared }), + ); + } + } + await writeFile( + join(installed, ".mcp.json"), + JSON.stringify({ mcpServers: defaults }), + ); + await writeFile( + join(installed, ".codex-plugin", "plugin.json"), + JSON.stringify(manifest), + ); + runtime.plugin.installedRoot = installed; + } const selectedNode = scenario.runtimeNode === null ? undefined @@ -78,6 +359,7 @@ test.each([ ); throw new Error("direct route reached"); }); + const started: CodexOptions[] = []; const createCodex = mock((options: CodexOptions) => { expect(options.apiKey).toBe("synthetic-routing-key"); expect(options.env?.["CODEX_MCP_NODE_PATH"]).toBe(selectedNode); @@ -87,25 +369,29 @@ test.each([ model_reasoning_effort: "high", features: { shell_tool: false, - code_mode: - typeof codeMode === "string" || scenario.route === "direct" - ? codeMode - : { - direct_only_tool_namespaces: [ - "existing_tools", - "mcp__codex_security", - "codex_security", - ], - }, + code_mode: codeMode, }, }); return { - startThread: () => ({ id: null, runStreamed: parentTurn }), + startThread: () => { + started.push(options); + return { id: null, runStreamed: parentTurn }; + }, }; }); const client = new TestClient( { codexOverrides: { + ...(scenario.configuredOmit === undefined + ? {} + : { + mcp_servers: { + "codex-security": { + command: "test-server", + omit_tools_from: [...scenario.configuredOmit], + }, + }, + }), model: "gpt-6.1-sol", model_reasoning_effort: "high", features: { @@ -126,7 +412,7 @@ test.each([ }), }, prepareRuntime: async () => ({ - ...preparedRuntime(codexHome), + ...runtime, environment: runtimeEnvironment, }), // Keep the host's Bun fallback and account advisory out of this check. @@ -139,7 +425,25 @@ test.each([ await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( `${scenario.route} route reached`, ); - expect(createCodex).toHaveBeenCalledTimes(1); + expect(started).toHaveLength(1); + const rawRule = + 'mcp_servers.codex-security.omit_tools_from=["code_mode","deferred"]'; + const overrides = started[0]!.configOverrides?.filter( + (value) => + value === rawRule || + value.startsWith("features.code_mode.direct_only_tool_namespaces="), + ); + expect(overrides).toEqual( + scenario.route === "parent" && + typeof codeMode !== "string" && + !scenario.directOmitted + ? [ + scenario.configuredOmit !== undefined + ? rawRule + : 'features.code_mode.direct_only_tool_namespaces=["existing_tools","mcp__codex_security","codex_security"]', + ] + : [], + ); expect(parentTurn).toHaveBeenCalledTimes( scenario.route === "parent" ? 1 : 0, ); @@ -151,3 +455,137 @@ test.each([ await client.close(); } }); + +test("older app-servers retain supplied routing without starting in the caller project", async () => { + const home = await temporaryDirectory(); + const scanDir = await temporaryDirectory(); + await writeFile( + join(home, "config.toml"), + '[features.code_mode]\ndirect_only_tool_namespaces = ["inherited_tools"]\n', + ); + const read = ( + config: CodexOptions["config"], + configOverrides: string[] = [], + ) => + readDeepScanParentConfig({ + codexOptions: { config, configOverrides, env: { CODEX_HOME: home } }, + preflightCommand: resolveCodexCommand({}), + scanDir, + signal: new AbortController().signal, + }); + const expected = (name: string) => ({ + features: { code_mode: { direct_only_tool_namespaces: [name] } }, + }); + expect( + await read({ model_instructions_file: "missing-in-caller.md" }), + ).toEqual({}); + expect( + await read({ + features: { "code_mode.direct_only_tool_namespaces": ["first"] }, + "features.code_mode.direct_only_tool_namespaces": ["last"], + model_auto_compact_token_limit: 2 ** 53, + }), + ).toEqual(expected("last")); + expect(await read({ "features.code_mode": "invalid" })).toEqual({ + features: { code_mode: "invalid" }, + }); + const owner = { command: "fixture" }; + const explicit = { mcp_servers: { "codex-security": owner } }; + expect(await read(explicit)).toBeUndefined(); + expect( + await read(explicit, [ + 'mcp_servers.codex-security.omit_tools_from=["direct"]', + "mcp_servers.codex-security.omit_tools_from=[]", + ]), + ).toEqual({ + mcp_servers: { "codex-security": { ...owner, omit_tools_from: [] } }, + }); + const leaf = await read({}, [ + "mcp_servers.codex-security.omit_tools_from=[]", + ]); + expect(deepScanParentToolOverrides(leaf!, {})).toEqual([ + 'mcp_servers.codex-security.omit_tools_from=["code_mode","deferred"]', + ]); +}); + +test("a capable app-server launches from the caller and reads the scan settings and selected plugin", async () => { + const home = await temporaryDirectory(); + const scanDir = await temporaryDirectory(); + const script = join(home, "config-reader.mjs"); + const log = join(home, "requests.json"); + await writeFile( + script, + `import { writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +if (process.argv.includes("--help")) { console.log(" -C, --cd "); process.exit(0); } +const startupCwd = process.argv[process.argv.indexOf("--cd") + 1]; +const [log, replies] = process.argv.slice(2); +const responses = JSON.parse(replies); +const calls = []; +for await (const line of createInterface({ input: process.stdin })) { + const { id, method, params } = JSON.parse(line); + if (id === undefined) continue; + if (method !== "initialize") { + calls.push({ method, params }); + writeFileSync(log, JSON.stringify({ cwd: process.cwd(), startupCwd, calls })); + } + console.log(JSON.stringify({ id, ...(responses[method] ?? { result: {} }) })); +}`, + ); + const plugin = { + marketplacePath: join(home, "marketplace.json"), + pluginName: "selected-security", + }; + const owner = "codex-security"; + const alias = "codex.security"; + const noDirect = { omit_tools_from: ["direct"] }; + const config = { mcp_servers: { [owner]: noDirect } }; + const read = ( + detail: JsonObject, + settings: JsonObject = { result: { config } }, + ) => + readDeepScanParentConfig({ + codexOptions: { env: { CODEX_HOME: home } }, + preflightCommand: { + command: process.execPath, + args: [ + script, + log, + JSON.stringify({ "config/read": settings, "plugin/read": detail }), + ], + }, + scanDir, + signal: new AbortController().signal, + plugin, + }); + const requests = async () => JSON.parse(await readFile(log, "utf8")); + const configRead = { + method: "config/read", + params: { cwd: scanDir, includeLayers: false }, + }; + const detail = { + result: { + plugin: { + summary: { installed: true, enabled: true }, + mcpServers: [owner, alias], + }, + }, + }; + expect(await read(detail)).toEqual({ + mcp_servers: { [owner]: noDirect, [alias]: {} }, + }); + expect(await requests()).toEqual({ + cwd: process.cwd(), + startupCwd: scanDir, + calls: [configRead, { method: "plugin/read", params: plugin }], + }); + expect( + await read({ error: { code: -32601, message: "Method not found" } }), + ).toBeUndefined(); + await expect( + read(detail, { error: { code: -32602, message: "invalid configuration" } }), + ).rejects.toThrow( + /Could not read the Deep Scan parent configuration[\s\S]*invalid configuration/u, + ); + expect((await requests()).calls).toEqual([configRead]); +}); diff --git a/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts b/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts index 5076368d20..9da22ea6e4 100644 --- a/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts +++ b/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts @@ -207,15 +207,26 @@ test.each(["configured", "managed"] as const)( ...runtimeEnvironment, CODEX_API_KEY: "synthetic-bun-scan-key", }; + const postScanPrompt = "Read the completed scan in a code-mode cell."; + const followUp = mock(async (prompt: string) => { + expect(prompt).toBe(postScanPrompt); + return { events: completedEvents("owner") }; + }); + const codeMode = { direct_only_tool_namespaces: ["caller_tools"] }; const parentTurn = mock(async (prompt: string) => { expect(prompt).toContain("start_codex_security_deep_scan"); - throw new Error("parent route reached"); + async function* events() { + yield { type: "thread.started", thread_id: "owner" }; + throw new Error("parent route reached"); + } + return { events: events() }; }); const client = new TestClient( { codexOverrides: { model: "gpt-6.1-sol", model_reasoning_effort: "high", + features: { code_mode: codeMode }, }, }, { @@ -238,10 +249,21 @@ test.each(["configured", "managed"] as const)( model_reasoning_effort: "high", }); return { - startThread: (options) => { - expect(options.workingDirectory).toBe(scanDir); + startThread: (threadOptions) => { + expect(threadOptions.workingDirectory).toBe(scanDir); return { id: null, runStreamed: parentTurn }; }, + resumeThread: (id, threadOptions) => { + expect(id).toBe("owner"); + expect(threadOptions.workingDirectory).toBe(scanDir); + expect(options.config?.["features"]).toMatchObject({ + code_mode: codeMode, + }); + expect(options.configOverrides?.join("\n")).not.toContain( + "direct_only_tool_namespaces", + ); + return { id, runStreamed: followUp }; + }, }; }, runDeepScan: async function* () { @@ -253,9 +275,11 @@ test.each(["configured", "managed"] as const)( await expect( client.run(repository, { mode: "deep", + postScanPrompt, }), ).rejects.toThrow("parent route reached"); expect(parentTurn).toHaveBeenCalledTimes(1); + expect(followUp).toHaveBeenCalledTimes(1); } finally { await client.close(); } diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index b4c5412734..fd16a3ad1a 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -4970,6 +4970,108 @@ describe("CodexSecurity orchestration", () => { }, ); + test.each([undefined, "Record the failed scan."])( + "preserves the requested post-scan turn when Deep Scan routing fails with %p", + async (postScanPrompt) => { + const { repository, codexHome, scanDir } = await scanDirectories(); + const target = ["api", "notes.md"]; + await mkdir(join(repository, "api")); + await writeFile(join(repository, "notes.md"), "fixture"); + const turns: Array<{ prompt: string; paths: string[] }> = []; + const warnings: string[] = []; + const client = TestClient.withDependencies({ + ...scanRuntimeDependencies(codexHome, scanDir), + canRunDirectDeepScan: () => false, + readDeepScanParentConfig: async () => + fail("native configuration failed"), + createCodex: async ({ env }) => + codexFactory(async (prompt: string) => { + const pathsFile = env?.["CODEX_SECURITY_TARGET_PATHS_FILE"]; + if (!pathsFile) throw new Error("Missing scope file location"); + turns.push({ + prompt, + paths: JSON.parse(await readFile(pathsFile, "utf8")), + }); + return { events: completedEvents() }; + })(), + }); + try { + await expect( + client.run(repository, { + mode: "deep", + target, + postScanPrompt, + onWarning: (warning) => warnings.push(warning), + }), + ).rejects.toThrow("native configuration failed"); + expect(warnings).toEqual([]); + expect(turns).toEqual( + postScanPrompt ? [{ prompt: postScanPrompt, paths: target }] : [], + ); + } finally { + await client.close(); + } + }, + ); + + test("checks the resumed budget before routing a Deep Scan failure follow-up", async () => { + const { repository, codexHome, scanDir } = await scanDirectories(); + const scanId = "scan_example_001"; + const threadId = "thread-1"; + await writeUsageSession( + codexHome, + threadId, + { + input_tokens: 2_000, + output_tokens: 0, + total_tokens: 2_000, + }, + { cwd: scanDir }, + ); + const readConfig = mock(rejecting("native configuration failed")); + const runStreamed = mock(async () => ({ + events: completedEvents(threadId), + })); + const thread = { id: threadId, runStreamed }; + const client = TestClient.withDependencies({ + ...scanRuntimeDependencies(codexHome, scanDir), + canRunDirectDeepScan: () => false, + readDeepScanParentConfig: readConfig, + createCodex: () => ({ + startThread: () => thread, + resumeThread: () => thread, + }), + runWorkbench: async (_options, args, input): Promise => { + if (args[0] === "get-cli-scan-resume") + return { + scanId, + scanDir, + threadId, + targetId: "target_sha256_example", + targetRevision: "deadbeef", + recipe: { repository, target: { kind: "repository", paths: [] } }, + contract: { target: { allowedKinds: ["git_revision"] } }, + }; + return mockWorkbench(args, input); + }, + }); + try { + await expect( + client.run(repository, { + mode: "deep", + resumeScanId: scanId, + outputDir: scanDir, + maxCostUsd: 0.004, + postScanPrompt: "Summarize the failed scan.", + }), + ).rejects.toBeInstanceOf(ScanCostLimitExceededError); + expect(readConfig).not.toHaveBeenCalled(); + expect(runStreamed).not.toHaveBeenCalled(); + } finally { + await client.close(); + } + }); + test.each([ ["a completed scan with scope notes", "completed", false, false], ["legacy coverage metadata", "legacy", false, false], @@ -8261,6 +8363,7 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s createCodex: (options: CodexOptions) => { const codex = new Codex(options); return { + resumeThread: codex.resumeThread.bind(codex), startThread: (threadOptions: ThreadOptions) => { const thread = codex.startThread(threadOptions); return { diff --git a/sdk/typescript/tests-ts/component-scan.test.ts b/sdk/typescript/tests-ts/component-scan.test.ts index dff8093455..c38efbc08b 100644 --- a/sdk/typescript/tests-ts/component-scan.test.ts +++ b/sdk/typescript/tests-ts/component-scan.test.ts @@ -34,6 +34,7 @@ import { type ComponentScanEvent, type ComponentScanOptions, } from "../src/component-scan.js"; +import * as deepScan from "../src/deep-scan.js"; import type { Finding, SeverityLevel } from "../src/models.js"; import { ScanResult } from "../src/result.js"; import * as runtime from "../src/runtime.js"; @@ -1854,6 +1855,10 @@ test.each([ return preparedRuntime("unused").plugin; }), spyOn(runtime, "probeCodexSandbox").mockImplementation(async () => {}), + // The Node child below implements only `codex exec`. + spyOn(deepScan, "readDeepScanParentConfig").mockImplementation( + async ({ codexOptions }) => codexOptions.config ?? {}, + ), spyOn(runtime, "resolvePluginPython").mockResolvedValue( "/managed/python", ), diff --git a/sdk/typescript/tests-ts/support/api-client.ts b/sdk/typescript/tests-ts/support/api-client.ts index 1d78e1d4d4..5bd30fcbc5 100644 --- a/sdk/typescript/tests-ts/support/api-client.ts +++ b/sdk/typescript/tests-ts/support/api-client.ts @@ -54,6 +54,9 @@ export function withSyntheticScanEvents>( }; }, runDeepScan: dependencies.runDeepScan ?? runDeepScan, + readDeepScanParentConfig: + dependencies.readDeepScanParentConfig ?? + (async ({ codexOptions }) => codexOptions.config ?? {}), }; }