diff --git a/README.md b/README.md index 385769fc..3b39ddfc 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,8 @@ repeat the option for multiple files or directories. Use `--scan-prompt-file PATH` to add shared scan instructions, and add a `prompt` CSV column for repository-specific instructions. Use -`--post-scan-prompt-file PATH` to run a follow-up after each completed, -validated scan. +`--post-scan-prompt-file PATH` to run a follow-up after each scan, including +incomplete or failed scans. For complete command help, runtime defaults, native multi-agent worker limits, environment variables, deep-scan configuration, and SDK options, see the diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index ce302684..e9d98339 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -490,7 +490,8 @@ service,https://github.com/acme/service.git,0123456789abcdef0123456789abcdef0123 Use `--scan-prompt-file PATH` to add instructions to a scan or every bulk scan. Bulk scans append each repository's CSV `prompt` after the shared instructions. Use `--post-scan-prompt-file PATH` to run a follow-up in the same authenticated -session after each completed scan has been validated. +session after each scan, including incomplete or failed scans. Canceled scans +and scans stopped at their configured cost limit do not start another turn. `--workers` limits concurrent scans and `--max-attempts` retries failures. Results remain under `--output-dir`; rerun the same command to resume. diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index e11352e9..0beed57a 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -393,6 +393,8 @@ export class CodexSecurity { let scanFailure = false; let completionCost: ScanCost | null = null; let preparedTargetWarnings: string[] = []; + let runPostScan: (() => ReturnType) | null = + null; let activeScan: { id: string; options: WorkbenchCommandOptions; @@ -950,6 +952,10 @@ export class CodexSecurity { await chmod(targetPathsFile, 0o400); } checkOpen(); + const postScanPrompt = options.postScanPrompt; + if (postScanPrompt?.trim()) { + runPostScan = () => thread.runStreamed(postScanPrompt, { signal }); + } const { events } = await thread.runStreamed(prompt, { signal, }); @@ -1044,16 +1050,12 @@ export class CodexSecurity { } } } - if ( - options.postScanPrompt?.trim() && - result.coverage.completeness === "complete" - ) { - const followUp = await thread.runStreamed(options.postScanPrompt, { - signal, - }); + if (runPostScan !== null) { + const followUp = runPostScan; + runPostScan = null; await runScanEvents({ thread, - events: followUp.events, + events: (await followUp()).events, signal, scanDir, pluginRoot: runtime.plugin.installedRoot, @@ -1091,6 +1093,22 @@ export class CodexSecurity { ]); } catch {} } + if (runPostScan !== null && !signal.aborted) { + try { + for await (const event of (await runPostScan()).events) { + if (event.type === "turn.failed") { + throw new CodexSecurityError(turnFailureMessage(event["error"])); + } + } + } catch (postScanError) { + notifyObserver( + "onWarning", + options.onWarning, + options.onObserverError, + `Could not run post-scan instructions: ${redactedErrorMessage(postScanError)}`, + ); + } + } if (this.#closed) this.#requireOpen(); if (signal.aborted && !(failure instanceof ScanInterruptedError)) { throwIfAborted(signal, scanDir); diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index a97868fb..96cbfd73 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -1070,7 +1070,7 @@ export async function main( .describe("Append scan instructions from FILE."), postScanPromptFile: optionValue("--post-scan-prompt-file") .optional() - .describe("Run instructions from FILE after a validated scan."), + .describe("Run FILE after each scan, including failures."), diff: optionValue("--diff") .optional() .describe("Scan committed Git changes from BASE to --head."), @@ -1364,7 +1364,7 @@ export async function main( .describe("Append instructions from FILE to every scan."), postScanPromptFile: optionValue("--post-scan-prompt-file") .optional() - .describe("Run FILE after each completed, validated scan."), + .describe("Run FILE after each scan, including failures."), model: optionValue("--model") .optional() .describe( diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 2ea5b331..08a0216d 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -14,6 +14,7 @@ import { } from "node:fs/promises"; import * as fsPromises from "node:fs/promises"; import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; @@ -2766,6 +2767,100 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test.each([ + ["partial coverage", "partial", false], + ["unknown coverage", "unknown", false], + ["a failed scan", "failed", false], + ["a failed scan and follow-up", "failed", true], + ] as const)( + "runs post-scan instructions after %s", + async (_scenario, outcome, followUpFails) => { + 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 prompts: string[] = []; + const warnings: string[] = []; + const scanFails = outcome === "failed"; + + const client = new TestClient( + {}, + { + environment: {}, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: () => ({ + startThread: () => ({ + id: "thread-1", + async runStreamed(prompt: string) { + prompts.push(prompt); + if (prompts.length === 1 && !scanFails) { + await copyCompletedScan(root); + const coveragePath = join(scanDir, "coverage.json"); + const original = await readFile(coveragePath, "utf8"); + const coverage = original.replace( + '"completeness": "complete"', + `"completeness": "${outcome}"`, + ); + const manifestPath = join(scanDir, "scan-manifest.json"); + await writeFile(coveragePath, coverage); + await writeFile( + manifestPath, + (await readFile(manifestPath, "utf8")).replace( + createHash("sha256").update(original).digest("hex"), + createHash("sha256").update(coverage).digest("hex"), + ), + ); + return { events: completedEvents() }; + } + if (prompts.length === 2 && !followUpFails) { + return { events: completedEvents() }; + } + async function* failedEvents(): AsyncGenerator { + yield { + type: "turn.failed", + error: { + message: + prompts.length === 1 + ? "The scan failed." + : "The post-scan instructions failed.", + }, + }; + } + return { events: failedEvents() }; + }, + }), + }), + }, + ); + + const result = client.run(repository, { + postScanPrompt: "Record the scan cost.", + onWarning: (warning) => warnings.push(warning), + }); + if (scanFails) { + await expect(result).rejects.toThrow("The scan failed."); + } else { + expect((await result).coverage.completeness).toBe(outcome); + } + expect(prompts.at(-1)).toBe("Record the scan cost."); + expect(prompts).toHaveLength(2); + expect(warnings).toEqual( + followUpFails + ? [ + "Could not run post-scan instructions: The post-scan instructions failed.", + ] + : [], + ); + await client.close(); + }, + ); + test("stops and records a scan as soon as its live cost exceeds the limit", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -2776,6 +2871,7 @@ describe("CodexSecurity orchestration", () => { await mkdir(scanDir, { mode: 0o700 }); const commands: Array = []; const costs: number[] = []; + let turns = 0; const cost = { model: "gpt-5.6-sol", inputTokens: 1_250, @@ -2813,6 +2909,7 @@ describe("CodexSecurity orchestration", () => { _input: string, options: { signal: AbortSignal }, ) { + turns += 1; async function* events(): AsyncGenerator { yield { type: "thread.started", thread_id: "scan-thread" }; await Promise.all([ @@ -2856,6 +2953,7 @@ describe("CodexSecurity orchestration", () => { await expect( client.run(repository, { maxCostUsd: 0.005, + postScanPrompt: "Record the scan cost.", onCost: (cost) => costs.push(cost.estimatedUsd), signal: AbortSignal.timeout(5_000), }), @@ -2868,6 +2966,7 @@ describe("CodexSecurity orchestration", () => { } finally { clearTimeout(keepEventLoopAlive); } + expect(turns).toBe(1); expect(costs.at(-1)).toBe(0.00625); expect(commands[1]).toEqual([ "get-scan-feedback",