Skip to content

Commit 17036b4

Browse files
fix: make missing scan artifact failures actionable (#167)
* fix: allow completion when no draft manifest exists yet Fresh recipe-driven scans fail at completion with: Could not save the Codex Security scan: scan-manifest.json: expected a regular file inside the scan directory. `workbench_db.complete_scan` reads `scan-manifest.json` with `required=True` when `scan.recipe_json is not None`, but on a first completion of a fresh scan there is no prior draft on disk yet — the draft is authored later by `_write_prepared_scan_finalization`. The read therefore always raises before the write can happen, and every retry still throws before writing anything, so `scan_dir` stays empty and models are billed with no artifacts produced. Make the pre-write read optional. When a prior manifest is present (resumed / re-completed scans), preserve the sealed timestamps as before; otherwise skip that step and let the downstream finalizer write the draft. Verified against `pnpm run lint` (tsc --noEmit) and the full `bun test` suite on the public SDK: 367 pass, 23 expected skips, 0 fail. Refs #73 * fix: diagnose missing scan agent draft artifacts --------- Co-authored-by: Onur Tirpan <otirpan@cscleasing.com> Co-authored-by: Onur Tirpan <posta@onurtirpan.com>
1 parent 8f4348e commit 17036b4

2 files changed

Lines changed: 153 additions & 12 deletions

File tree

sdk/typescript/_bundled_plugin/scripts/workbench_db.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,18 +1414,32 @@ def complete_scan_locked(
14141414
completion_timestamp = now()
14151415
completion_binding = workbench_completion_binding(scan, completion_timestamp)
14161416
if scan["recipe_json"] is not None:
1417-
# Fresh recipe-driven scans have no prior scan-manifest.json on disk — the
1418-
# draft is authored later by _write_prepared_scan_finalization. Guard the
1419-
# read so first-time completion does not fail with "expected a regular
1420-
# file inside the scan directory" (issue #73). When a prior manifest does
1421-
# exist (resumed / re-completed scans), preserve the sealed timestamps.
1422-
manifest_path = artifact_path(scan_dir, ARTIFACTS["manifest"], required=False)
1423-
if manifest_path is not None:
1424-
manifest = read_json_object(manifest_path)
1425-
manifest_scan = manifest.get("scan")
1426-
if isinstance(manifest_scan, dict) and manifest_scan.get("sealedAt") is not None:
1427-
completion_binding["startedAt"] = manifest_scan.get("startedAt")
1428-
completion_binding["completedAt"] = manifest_scan.get("completedAt")
1417+
draft_artifacts: dict[str, Path] = {}
1418+
missing_drafts = []
1419+
for file_name in (
1420+
ARTIFACTS["manifest"],
1421+
ARTIFACTS["findings"],
1422+
ARTIFACTS["coverage"],
1423+
):
1424+
try:
1425+
(scan_dir / file_name).lstat()
1426+
except FileNotFoundError:
1427+
missing_drafts.append(file_name)
1428+
continue
1429+
draft_path = artifact_path(scan_dir, file_name, required=True)
1430+
if draft_path is not None:
1431+
draft_artifacts[file_name] = draft_path
1432+
if missing_drafts:
1433+
raise SystemExit(
1434+
"Scan agent did not create required draft artifacts: "
1435+
f"{', '.join(missing_drafts)}. Check that the scan agent can run shell "
1436+
"commands and write to the scan directory before retrying."
1437+
)
1438+
manifest = read_json_object(draft_artifacts[ARTIFACTS["manifest"]])
1439+
manifest_scan = manifest.get("scan")
1440+
if isinstance(manifest_scan, dict) and manifest_scan.get("sealedAt") is not None:
1441+
completion_binding["startedAt"] = manifest_scan.get("startedAt")
1442+
completion_binding["completedAt"] = manifest_scan.get("completedAt")
14291443
try:
14301444
prepared = _prepare_scan_finalization(
14311445
scan_dir,

sdk/typescript/tests-ts/runtime.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
mkdir,
88
mkdtemp,
99
readFile,
10+
readlink,
1011
realpath,
1112
readdir,
1213
rename,
@@ -1858,6 +1859,132 @@ describe("runtime directories and plugin Python boundary", () => {
18581859
expect(result).toEqual({ ok: true });
18591860
});
18601861

1862+
test.each([
1863+
["all required draft artifacts", []],
1864+
["the manifest draft", ["findings.json", "coverage.json"]],
1865+
["the findings draft", ["scan-manifest.json", "coverage.json"]],
1866+
["the coverage draft", ["scan-manifest.json", "findings.json"]],
1867+
] as const)(
1868+
"rejects recipe scans when the agent did not create %s",
1869+
async (_description, present) => {
1870+
const python = Bun.which("python3") ?? Bun.which("python");
1871+
expect(python).not.toBeNull();
1872+
const requiredDrafts = [
1873+
"scan-manifest.json",
1874+
"findings.json",
1875+
"coverage.json",
1876+
] as const;
1877+
const root = await temporaryDirectory("codex-security-missing-drafts-");
1878+
const repository = join(root, "repository");
1879+
const scanDir = join(root, "scan");
1880+
await mkdir(repository);
1881+
await mkdir(scanDir, { mode: 0o700 });
1882+
const workbenchOptions = {
1883+
python: python!,
1884+
pluginRoot: PLUGIN_ROOT,
1885+
environment: {
1886+
PATH: process.env["PATH"],
1887+
CODEX_SECURITY_STATE_DIR: join(root, "state"),
1888+
},
1889+
};
1890+
const registration = await runWorkbench(workbenchOptions, [
1891+
"register-cli-scan",
1892+
"--repository",
1893+
repository,
1894+
"--scan-dir",
1895+
scanDir,
1896+
"--recipe-json",
1897+
JSON.stringify({
1898+
config: {},
1899+
mode: "standard",
1900+
repository,
1901+
target: { kind: "repository", paths: [] },
1902+
}),
1903+
]);
1904+
await Promise.all(
1905+
present.map((filename) =>
1906+
copyFile(
1907+
join(PLUGIN_ROOT, "examples", "completed-scan", filename),
1908+
join(scanDir, filename),
1909+
),
1910+
),
1911+
);
1912+
const missing = requiredDrafts.filter(
1913+
(filename) => !present.some((candidate) => candidate === filename),
1914+
);
1915+
1916+
await expect(
1917+
runWorkbench(workbenchOptions, [
1918+
"complete-scan",
1919+
"--scan-id",
1920+
String(registration["scanId"]),
1921+
]),
1922+
).rejects.toThrow(
1923+
`Scan agent did not create required draft artifacts: ${missing.join(
1924+
", ",
1925+
)}. Check that the scan agent can run shell commands and write to the scan directory before retrying.`,
1926+
);
1927+
expect((await readdir(scanDir)).sort()).toEqual([...present].sort());
1928+
const stored = await runWorkbench(workbenchOptions, [
1929+
"get-scan",
1930+
"--scan-id",
1931+
String(registration["scanId"]),
1932+
]);
1933+
expect(stored["scan"]).toMatchObject({
1934+
progress: { status: "running" },
1935+
});
1936+
},
1937+
);
1938+
1939+
testPosix("rejects symlinked recipe scan draft artifacts", async () => {
1940+
const root = await temporaryDirectory("codex-security-symlinked-draft-");
1941+
const repository = join(root, "repository");
1942+
const scanDir = join(root, "scan");
1943+
await mkdir(repository);
1944+
await mkdir(scanDir, { mode: 0o700 });
1945+
const python = Bun.which("python3") ?? Bun.which("python");
1946+
expect(python).not.toBeNull();
1947+
const workbenchOptions = {
1948+
python: python!,
1949+
pluginRoot: PLUGIN_ROOT,
1950+
environment: {
1951+
PATH: process.env["PATH"],
1952+
CODEX_SECURITY_STATE_DIR: join(root, "state"),
1953+
},
1954+
};
1955+
const registration = await runWorkbench(workbenchOptions, [
1956+
"register-cli-scan",
1957+
"--repository",
1958+
repository,
1959+
"--scan-dir",
1960+
scanDir,
1961+
"--recipe-json",
1962+
JSON.stringify({
1963+
config: {},
1964+
mode: "standard",
1965+
repository,
1966+
target: { kind: "repository", paths: [] },
1967+
}),
1968+
]);
1969+
await symlink(
1970+
join(root, "missing-manifest.json"),
1971+
join(scanDir, "scan-manifest.json"),
1972+
);
1973+
1974+
await expect(
1975+
runWorkbench(workbenchOptions, [
1976+
"complete-scan",
1977+
"--scan-id",
1978+
String(registration["scanId"]),
1979+
]),
1980+
).rejects.toThrow(
1981+
"scan-manifest.json: expected a regular file inside the scan directory.",
1982+
);
1983+
expect(await readlink(join(scanDir, "scan-manifest.json"))).toBe(
1984+
join(root, "missing-manifest.json"),
1985+
);
1986+
});
1987+
18611988
test("preserves recorded artifact paths when archiving a completed scan", async () => {
18621989
const root = await temporaryDirectory();
18631990
const scanDir = join(root, "scan");

0 commit comments

Comments
 (0)