diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 2b276aff..e69cdb32 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -6,10 +6,18 @@ import { constants, existsSync, lstatSync, + readdirSync, realpathSync, writeSync, } from "node:fs"; -import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + readdir, + realpath, + stat, + writeFile, +} from "node:fs/promises"; import { basename, dirname, @@ -2539,6 +2547,24 @@ function isOutsidePath(path: string): boolean { return path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path); } +async function hasPartialOutput(path: string): Promise { + try { + return (await readdir(path)).length > 0; + } catch { + // Keep the path in the diagnostic when it disappeared or cannot be read. + // Only suppress the message when emptiness was confirmed. + return true; + } +} + +function hasPartialOutputSync(path: string): boolean { + try { + return readdirSync(path).length > 0; + } catch { + return true; + } +} + async function runExport( arguments_: ExportArguments, output: Writable, @@ -3150,12 +3176,17 @@ async function runScan( } if (requestedSignal !== null) { + const partialOutput = scanDir !== null && hasPartialOutputSync(scanDir); diagnostic("scan.interrupted", { signal: requestedSignal, - partial_output: scanDir !== null, + partial_output: partialOutput, }); return { - exitCode: interruptedExit(requestedSignal, scanDir, errorOutput), + exitCode: interruptedExit( + requestedSignal, + partialOutput ? scanDir : null, + errorOutput, + ), error: requestedSignal === "SIGINT" ? "Scan canceled by Ctrl-C." @@ -3163,6 +3194,7 @@ async function runScan( }; } if (failed) { + const partialOutput = scanDir !== null && (await hasPartialOutput(scanDir)); const costLimitFailure = failure instanceof ScanCostLimitExceededError ? failure : undefined; const message = @@ -3176,7 +3208,7 @@ async function runScan( : isLocalScanFailure(failure) ? "local" : classifyConnectionFailure(failure), - partial_output: scanDir !== null, + partial_output: partialOutput, max_cost_usd: costLimitFailure?.maxCostUsd, estimated_usd: costLimitFailure?.cost.estimatedUsd, }); @@ -3184,7 +3216,7 @@ async function runScan( if (failure instanceof ScanInterruptedError) { return { exitCode: 2, error: message }; } - if (scanDir !== null) { + if (partialOutput && scanDir !== null) { errorOutput.write( `Partial output was kept at ${errorMessage(scanDir)}.\n`, ); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index a1b3642b..2e80c71a 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -4876,6 +4876,47 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("codex-security:"); }); + test("does not claim partial output when the output directory is empty", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-security-empty-output-")); + try { + for (const [name, populate, expectsPartial] of [ + ["empty", false, false], + ["partial", true, true], + ] as const) { + const scanDir = join(root, name); + await mkdir(scanDir); + if (populate) + await writeFile(join(scanDir, "progress.log"), "partial\n"); + + const stdout = capture(); + const stderr = capture(); + const failing = dependencies(); + failing.createSecurity = () => ({ + run: async (_repository, options) => { + options?.onOutputDirReady?.(scanDir); + throw new Error("SYNTHETIC_SCAN_FAILURE"); + }, + close: async () => {}, + preflight: async () => fakePreflight(), + }); + + expect( + await main(["scan", "."], stdout.stream, stderr.stream, failing), + ).toBe(2); + expect(stderr.text()).toContain("SYNTHETIC_SCAN_FAILURE"); + if (expectsPartial) { + expect(stderr.text()).toContain( + `Partial output was kept at ${scanDir}.`, + ); + } else { + expect(stderr.text()).not.toContain("Partial output was kept"); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test("preserves complete protected-root diagnostics", async () => { const stdout = capture(); const stderr = capture();