diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index fe59b1d94..49526ea4c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1835,17 +1835,26 @@ export class CodexSecurity { reason: safeErrorMessage(failure), }).catch(() => undefined); } + const canceled = + signal.aborted && + (options.signal?.aborted === true || + this.#abortController.signal.aborted) && + isCancellationDerivedFailure(failure, signal); try { await workbench({ ...activeScan.options, signal: undefined }, [ - "fail-scan", + canceled ? "cancel-scan" : "fail-scan", "--scan-id", activeScan.id, - // Scan history can be shared; never persist credential-bearing failures. - "--message", - safeErrorMessage(failure).slice(0, 2400), - ...(snapshot?.cost - ? ["--cost-json", JSON.stringify(snapshot.cost)] - : []), + ...(canceled + ? [] + : [ + // Scan history can be shared; never persist credential-bearing failures. + "--message", + safeErrorMessage(failure).slice(0, 2400), + ...(snapshot?.cost + ? ["--cost-json", JSON.stringify(snapshot.cost)] + : []), + ]), ]); } catch {} } @@ -2698,12 +2707,18 @@ export class CodexSecurity { return result; } catch (error) { if (activeScan !== undefined) { + const canceled = + signal.aborted && + (options.signal?.aborted === true || + this.#abortController.signal.aborted) && + isCancellationDerivedFailure(error, signal); await workbench({ ...activeScan.options, signal: undefined }, [ - "fail-scan", + canceled ? "cancel-scan" : "fail-scan", "--scan-id", activeScan.id, - "--message", - safeErrorMessage(error).slice(0, 2400), + ...(canceled + ? [] + : ["--message", safeErrorMessage(error).slice(0, 2400)]), ]).catch(() => undefined); } if (this.#closed) this.#requireOpen(); @@ -4137,6 +4152,31 @@ function throwIfAborted(signal?: AbortSignal, scanDir = ""): void { throw new ScanInterruptedError(message, scanDir, { cause: signal.reason }); } +function isCancellationDerivedFailure( + failure: unknown, + signal: AbortSignal, +): boolean { + let current = failure; + const seen = new Set(); + while (current instanceof ScanInterruptedError) { + if (current instanceof ScanCostLimitExceededError) return false; + if (current.cause === undefined) return true; + if (seen.has(current)) return false; + seen.add(current); + current = current.cause; + } + if ( + current instanceof CodexSecurityError && + current.message === "CodexSecurity is closed." + ) { + return true; + } + return ( + current === signal.reason || + (isRecord(current) && current["name"] === "AbortError") + ); +} + function bundledCodexSdkEnvironment( command: string, environment: Record, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 6d39f3690..b119a3603 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3049,6 +3049,292 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("records deeply nested caller cancellation as canceled instead of failed", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + const commands: Array = []; + const started = Promise.withResolvers(); + const controller = new AbortController(); + let cancellationReason: unknown = new DOMException("aborted", "AbortError"); + for (let depth = 0; depth < 10; depth += 1) { + cancellationReason = new ScanInterruptedError( + "nested cancellation", + scanDir, + { cause: cancellationReason }, + ); + } + + const client = new TestClient( + {}, + { + environment: {}, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async ( + _options: unknown, + args: readonly string[], + input?: string, + ): Promise => { + commands.push(args); + if (args[0] === "register-cli-scan") { + return mockScanRegistration(args, input); + } + if (args[0] === "get-scan-feedback") { + return { + scanId: "scan_example_001", + targetId: "target_sha256_example", + falsePositives: [], + }; + } + return {}; + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed( + _input: string, + options: { signal: AbortSignal }, + ) { + async function* events(): AsyncGenerator { + yield { type: "thread.started", thread_id: "scan-thread" }; + started.resolve(); + await new Promise((resolve) => { + if (options.signal.aborted) resolve(); + else + options.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + throw cancellationReason; + } + return { events: events() }; + }, + }), + }), + }, + ); + + const pending = client.run(repository, { signal: controller.signal }); + await started.promise; + controller.abort(cancellationReason); + await expect(pending).rejects.toBeInstanceOf(ScanInterruptedError); + expect(commands.map(([command]) => command)).toEqual([ + "register-cli-scan", + "get-scan-feedback", + "set-scan-thread", + "cancel-scan", + ]); + expect(commands.at(-1)).toEqual([ + "cancel-scan", + "--scan-id", + "scan_example_001", + ]); + await client.close(); + }); + + test("records a workbench AbortError as canceled instead of failed", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + const commands: Array = []; + const feedbackStarted = Promise.withResolvers(); + const controller = new AbortController(); + + const client = new TestClient( + {}, + { + environment: {}, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async ( + options: unknown, + args: readonly string[], + input?: string, + ): Promise => { + commands.push(args); + if (args[0] === "register-cli-scan") { + return mockScanRegistration(args, input); + } + if (args[0] === "get-scan-feedback") { + feedbackStarted.resolve(); + const signal = (options as { signal: AbortSignal }).signal; + if (signal.aborted) { + throw new DOMException("aborted", "AbortError"); + } + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + ); + }); + } + return {}; + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + throw new Error("Codex must not start before feedback loads"); + }, + }), + }), + }, + ); + + const pending = client.run(repository, { signal: controller.signal }); + await feedbackStarted.promise; + controller.abort("caller canceled"); + await expect(pending).rejects.toBeInstanceOf(ScanInterruptedError); + expect(commands.map(([command]) => command)).toEqual([ + "register-cli-scan", + "get-scan-feedback", + "cancel-scan", + ]); + expect(commands.at(-1)).toEqual([ + "cancel-scan", + "--scan-id", + "scan_example_001", + ]); + await client.close(); + }); + + test("records an ordinary failure as failed when cancellation races with it", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + const commands: Array = []; + const controller = new AbortController(); + + const client = new TestClient( + {}, + { + environment: {}, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async ( + _options: unknown, + args: readonly string[], + input?: string, + ): Promise => { + commands.push(args); + if (args[0] === "register-cli-scan") { + return mockScanRegistration(args, input); + } + if (args[0] === "get-scan-feedback") { + controller.abort("caller canceled"); + throw new Error("underlying scan failure"); + } + return {}; + }, + createCodex: () => { + throw new Error("Codex must not start after feedback failure"); + }, + }, + ); + + await expect( + client.run(repository, { signal: controller.signal }), + ).rejects.toBeInstanceOf(ScanInterruptedError); + expect(commands.map(([command]) => command)).toEqual([ + "register-cli-scan", + "get-scan-feedback", + "fail-scan", + ]); + expect(commands.at(-1)).toEqual([ + "fail-scan", + "--scan-id", + "scan_example_001", + "--message", + "underlying scan failure", + ]); + await client.close(); + }); + + test("records a client-close cancellation as canceled instead of failed", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + const commands: Array = []; + const feedbackStarted = Promise.withResolvers(); + const releaseFeedback = Promise.withResolvers(); + let client: TestClient; + + client = new TestClient( + {}, + { + environment: {}, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async ( + _options: unknown, + args: readonly string[], + input?: string, + ): Promise => { + commands.push(args); + if (args[0] === "register-cli-scan") { + return mockScanRegistration(args, input); + } + if (args[0] === "get-scan-feedback") { + feedbackStarted.resolve(); + await releaseFeedback.promise; + return { + scanId: "scan_example_001", + targetId: "target_sha256_example", + falsePositives: [], + }; + } + return {}; + }, + createCodex: () => { + throw new Error("Codex must not start after client close"); + }, + }, + ); + + const pending = client.run(repository); + await feedbackStarted.promise; + const closing = client.close(); + releaseFeedback.resolve(); + await expect(pending).rejects.toThrow("CodexSecurity is closed."); + await closing; + expect(commands.map(([command]) => command)).toEqual([ + "register-cli-scan", + "get-scan-feedback", + "cancel-scan", + ]); + expect(commands.at(-1)).toEqual([ + "cancel-scan", + "--scan-id", + "scan_example_001", + ]); + }); + test("reports a Deep Scan terminal failure instead of a completion-state error", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/mock-scan.test.ts b/sdk/typescript/tests-ts/mock-scan.test.ts index 711ae63fd..403d2c797 100644 --- a/sdk/typescript/tests-ts/mock-scan.test.ts +++ b/sdk/typescript/tests-ts/mock-scan.test.ts @@ -294,7 +294,7 @@ test("aborting mock generation leaves a terminal scan record", async () => { ["list-scans", "--repository", repository], ); expect(history["scans"]).toMatchObject([ - { progress: { status: "failed" } }, + { progress: { status: "canceled" } }, ]); } finally { await client.close();