Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 26 additions & 8 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ export class CodexSecurity {
let scanFailure = false;
let completionCost: ScanCost | null = null;
let preparedTargetWarnings: string[] = [];
let runPostScan: (() => ReturnType<CodexThreadLike["runStreamed"]>) | null =
null;
let activeScan: {
id: string;
options: WorkbenchCommandOptions;
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down Expand Up @@ -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(
Expand Down
99 changes: 99 additions & 0 deletions sdk/typescript/tests-ts/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ThreadEvent> {
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");
Expand All @@ -2776,6 +2871,7 @@ describe("CodexSecurity orchestration", () => {
await mkdir(scanDir, { mode: 0o700 });
const commands: Array<readonly string[]> = [];
const costs: number[] = [];
let turns = 0;
const cost = {
model: "gpt-5.6-sol",
inputTokens: 1_250,
Expand Down Expand Up @@ -2813,6 +2909,7 @@ describe("CodexSecurity orchestration", () => {
_input: string,
options: { signal: AbortSignal },
) {
turns += 1;
async function* events(): AsyncGenerator<ThreadEvent> {
yield { type: "thread.started", thread_id: "scan-thread" };
await Promise.all([
Expand Down Expand Up @@ -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),
}),
Expand All @@ -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",
Expand Down
Loading