From 53a8c9f052a45ca29c2f605238b75aa15983ca61 Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Sat, 29 Aug 2026 12:53:25 +0800 Subject: [PATCH 1/7] fix(sdk): preserve canceled scan state --- sdk/typescript/src/api.ts | 22 +++++--- sdk/typescript/tests-ts/api.test.ts | 82 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index ca308aadf..85b9faf10 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1723,17 +1723,25 @@ export class CodexSecurity { reason: safeErrorMessage(failure), }).catch(() => undefined); } + const canceled = + failure instanceof ScanInterruptedError && + (options.signal?.aborted || this.#abortController.signal.aborted) && + !(failure instanceof ScanCostLimitExceededError); 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 {} } diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index e258155f5..22bfd5582 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3028,6 +3028,88 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("records a caller-canceled scan 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(); + + 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 new DOMException("aborted", "AbortError"); + } + return { events: events() }; + }, + }), + }), + }, + ); + + const pending = client.run(repository, { signal: controller.signal }); + await started.promise; + controller.abort("caller canceled"); + 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("reports a Deep Scan terminal failure instead of a completion-state error", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); From 67afadc3baeaf481fdd3b40e7faae7255e274d2b Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Mon, 31 Aug 2026 14:22:24 +0800 Subject: [PATCH 2/7] fix(sdk): classify aborted registered scans correctly --- sdk/typescript/src/api.ts | 4 +- sdk/typescript/tests-ts/api.test.ts | 73 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 85b9faf10..cb2c5228a 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1724,8 +1724,8 @@ export class CodexSecurity { }).catch(() => undefined); } const canceled = - failure instanceof ScanInterruptedError && - (options.signal?.aborted || this.#abortController.signal.aborted) && + signal.aborted && + !(signal.reason instanceof ScanCostLimitExceededError) && !(failure instanceof ScanCostLimitExceededError); try { await workbench({ ...activeScan.options, signal: undefined }, [ diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 22bfd5582..8f59f0c27 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3110,6 +3110,79 @@ describe("CodexSecurity orchestration", () => { 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("reports a Deep Scan terminal failure instead of a completion-state error", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); From 3394120d06c32e465a4bd2608f1475fb9c350f88 Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Mon, 31 Aug 2026 14:29:55 +0800 Subject: [PATCH 3/7] fix(sdk): preserve tracking failures as failures --- sdk/typescript/src/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index cb2c5228a..b99d62a7c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1725,6 +1725,8 @@ export class CodexSecurity { } const canceled = signal.aborted && + (options.signal?.aborted === true || + this.#abortController.signal.aborted) && !(signal.reason instanceof ScanCostLimitExceededError) && !(failure instanceof ScanCostLimitExceededError); try { From 13ca814c11821bfd41e1c4b01e144a122d24771c Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Thu, 3 Sep 2026 17:18:19 +0800 Subject: [PATCH 4/7] fix(sdk): preserve ordinary failures during cancellation races --- sdk/typescript/src/api.ts | 23 +++++++++++- sdk/typescript/tests-ts/api.test.ts | 58 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index b99d62a7c..db03cb886 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1727,8 +1727,7 @@ export class CodexSecurity { signal.aborted && (options.signal?.aborted === true || this.#abortController.signal.aborted) && - !(signal.reason instanceof ScanCostLimitExceededError) && - !(failure instanceof ScanCostLimitExceededError); + isCancellationDerivedFailure(failure, signal); try { await workbench({ ...activeScan.options, signal: undefined }, [ canceled ? "cancel-scan" : "fail-scan", @@ -3706,6 +3705,26 @@ function throwIfAborted(signal?: AbortSignal, scanDir = ""): void { throw new ScanInterruptedError(message, scanDir, { cause: signal.reason }); } +function isCancellationDerivedFailure( + failure: unknown, + signal: AbortSignal, +): boolean { + let current = failure; + for (let depth = 0; depth < 8; depth += 1) { + if (current instanceof ScanCostLimitExceededError) return false; + if (current instanceof ScanInterruptedError) { + if (current.cause === undefined) return true; + current = current.cause; + continue; + } + return ( + current === signal.reason || + (isRecord(current) && current["name"] === "AbortError") + ); + } + return false; +} + function definedEnvironment( environment: ProcessEnvironment, ): Record { diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 8f59f0c27..b0de5e8fa 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3183,6 +3183,64 @@ describe("CodexSecurity orchestration", () => { 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("reports a Deep Scan terminal failure instead of a completion-state error", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); From ad93cd531dc99add8359706992a7b83877b1b6e2 Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Thu, 3 Sep 2026 17:40:05 +0800 Subject: [PATCH 5/7] fix(sdk): classify client-close scan cancellation --- sdk/typescript/src/api.ts | 6 +++ sdk/typescript/tests-ts/api.test.ts | 65 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 5f22dee78..8a2d55b1c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -4158,6 +4158,12 @@ function isCancellationDerivedFailure( current = current.cause; continue; } + if ( + current instanceof CodexSecurityError && + current.message === "CodexSecurity is closed." + ) { + return true; + } return ( current === signal.reason || (isRecord(current) && current["name"] === "AbortError") diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index ffba9400e..8d43ce275 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3262,6 +3262,71 @@ describe("CodexSecurity orchestration", () => { 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"); From 647d72662f0673146bb5e81450bc02d68d80e8b9 Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Thu, 3 Sep 2026 17:54:54 +0800 Subject: [PATCH 6/7] fix(sdk): cancel aborted mock scans --- sdk/typescript/src/api.ts | 12 +++++++++--- sdk/typescript/tests-ts/mock-scan.test.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 8a2d55b1c..db0e09e67 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2707,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(); 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(); From 3854a75fc915d4c4948267bcb1a245b572a1e712 Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Thu, 3 Sep 2026 18:05:40 +0800 Subject: [PATCH 7/7] fix(sdk): traverse nested cancellation causes --- sdk/typescript/src/api.ts | 33 ++++++++++++++--------------- sdk/typescript/tests-ts/api.test.ts | 14 +++++++++--- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index db0e09e67..49526ea4c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -4157,25 +4157,24 @@ function isCancellationDerivedFailure( signal: AbortSignal, ): boolean { let current = failure; - for (let depth = 0; depth < 8; depth += 1) { + const seen = new Set(); + while (current instanceof ScanInterruptedError) { if (current instanceof ScanCostLimitExceededError) return false; - if (current instanceof ScanInterruptedError) { - if (current.cause === undefined) return true; - current = current.cause; - continue; - } - if ( - current instanceof CodexSecurityError && - current.message === "CodexSecurity is closed." - ) { - return true; - } - return ( - current === signal.reason || - (isRecord(current) && current["name"] === "AbortError") - ); + 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 false; + return ( + current === signal.reason || + (isRecord(current) && current["name"] === "AbortError") + ); } function bundledCodexSdkEnvironment( diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 8d43ce275..b119a3603 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -3049,7 +3049,7 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); - test("records a caller-canceled scan as canceled instead of failed", async () => { + 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"); @@ -3060,6 +3060,14 @@ describe("CodexSecurity orchestration", () => { 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( {}, @@ -3104,7 +3112,7 @@ describe("CodexSecurity orchestration", () => { once: true, }); }); - throw new DOMException("aborted", "AbortError"); + throw cancellationReason; } return { events: events() }; }, @@ -3115,7 +3123,7 @@ describe("CodexSecurity orchestration", () => { const pending = client.run(repository, { signal: controller.signal }); await started.promise; - controller.abort("caller canceled"); + controller.abort(cancellationReason); await expect(pending).rejects.toBeInstanceOf(ScanInterruptedError); expect(commands.map(([command]) => command)).toEqual([ "register-cli-scan",