Skip to content
60 changes: 50 additions & 10 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +1838 to +1842

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat client-close errors as cancellation

When close() aborts an already registered scan just as an awaited operation completes normally, the subsequent checkOpen() throws CodexSecurity is closed. before throwIfAborted() can produce a cancellation-derived error. That ordinary error fails this predicate, so the scan is still persisted through fail-scan even though client closure caused it to stop. Classify this closed-client path as cancellation while continuing to preserve any earlier internal failure.

AGENTS.md reference: sdk/typescript/AGENTS.md:L23-L25

Useful? React with 👍 / 👎.

try {
await workbench({ ...activeScan.options, signal: undefined }, [
"fail-scan",
canceled ? "cancel-scan" : "fail-scan",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route canceled mock scans through cancel-scan

When run() is called with { mock: true }, the dispatch at the start of #run bypasses this lifecycle selection, and #runMock still unconditionally invokes fail-scan in its catch block. A caller abort after onScanStarted therefore leaves the registered mock scan without canceled_at, while the equivalent real scan now uses cancel-scan; apply the same cancellation classification to the mock path so persisted scan state remains consistent.

AGENTS.md reference: sdk/typescript/AGENTS.md:L23-L23

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 647d7266. Mock scans now use the same cancellation classification as real scans: cancellation-derived failures call cancel-scan without a failure message; ordinary failures still call fail-scan with the sanitized message. Updated the existing abort regression to assert the canceled terminal state. Focused API/mock tests, lint, format, model-generation, plugin build, and diff checks pass.

"--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 {}
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<ScanInterruptedError>();
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<string, string>,
Expand Down
286 changes: 286 additions & 0 deletions sdk/typescript/tests-ts/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly string[]> = [];
const started = Promise.withResolvers<void>();
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<JsonObject> => {
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<ThreadEvent> {
yield { type: "thread.started", thread_id: "scan-thread" };
started.resolve();
await new Promise<void>((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<readonly string[]> = [];
const feedbackStarted = Promise.withResolvers<void>();
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<JsonObject> => {
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<never>((_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<readonly string[]> = [];
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<JsonObject> => {
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<readonly string[]> = [];
const feedbackStarted = Promise.withResolvers<void>();
const releaseFeedback = Promise.withResolvers<void>();
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<JsonObject> => {
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");
Expand Down
2 changes: 1 addition & 1 deletion sdk/typescript/tests-ts/mock-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down