From ce9e29dac43c53b81d023ab2e6a3cf74065b0a7f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:22:03 +0000 Subject: [PATCH 001/251] refactor(plugin): remove unused Deep scheduler history --- .../mcp-app/src/deep-scan/coordinator.ts | 126 +----------------- .../mcp-app/src/deep-scan/worker-runner.ts | 94 ++----------- .../tests/test_deep_scan_coordinator.mjs | 6 +- 3 files changed, 18 insertions(+), 208 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 905c0c9a87..b3eff3a045 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -11,16 +11,12 @@ import { type ScanDraftInput } from "../artifact-scan-draft.js"; import type { DeepScanArtifacts } from "./artifacts.js"; -import { - DeepScanWorkerRunner, - sha256 -} from "./worker-runner.js"; +import { DeepScanWorkerRunner } from "./worker-runner.js"; import type { AcceptedDiscovery, DedupOutcome, DiscoveryOutcome, - SuccessfulDedupOutcome, - WorkerExecutionAudit + SuccessfulDedupOutcome } from "./worker-runner.js"; import { boundedDeepScanErrorPair, @@ -62,16 +58,6 @@ interface SchedulerResult { type CoordinatorPhase = "setup" | "discovery" | "terminal"; -interface SchedulerAudit { - accepted: AcceptedDiscovery[]; - mergedWorkerIds: string[]; - omittedWorkerIds: string[]; - canceledWorkerIds: string[]; - bufferedWorkerIds: string[]; - reducers: AcceptedReducer[]; - executions: WorkerExecutionAudit[]; -} - export interface CoordinatorOptions { run: DeepScanRunState; store: DeepScanStore; @@ -124,15 +110,6 @@ export class DeepScanCoordinator { private externallyFailed = false; private phase: CoordinatorPhase = "setup"; private discoveryDeadlineReached = false; - private readonly audit: SchedulerAudit = { - accepted: [], - mergedWorkerIds: [], - omittedWorkerIds: [], - canceledWorkerIds: [], - bufferedWorkerIds: [], - reducers: [], - executions: [] - }; private state: DeepScanRunState; constructor(private readonly options: CoordinatorOptions) { @@ -149,10 +126,7 @@ export class DeepScanCoordinator { clock: this.clock, random: options.random ?? Math.random, log: this.log, - retryDelaysMs: options.retryDelaysMs ?? RETRY_DELAYS_MS, - recordExecution: (execution) => { - this.audit.executions.push(execution); - } + retryDelaysMs: options.retryDelaysMs ?? RETRY_DELAYS_MS } satisfies Omit[0], "signal">; this.workers = new DeepScanWorkerRunner({ ...workerOptions, @@ -610,10 +584,6 @@ export class DeepScanCoordinator { .filter((worker) => worker.kind === "discovery" && worker.status === "canceled") .map((worker) => worker.id); const omittedWorkerIds: string[] = []; - this.audit.accepted = [...accepted]; - this.audit.mergedWorkerIds = mergedDiscoveries.map((worker) => worker.id); - this.audit.canceledWorkerIds = [...canceledWorkerIds]; - this.audit.executions = await this.recoverPersistedExecutions(); const recoveredReducers = await this.recoverCompletedReducers(recovered); const reducerOutcomes = recoveredReducers.reducers; let latestResult = recoveredReducers.result; @@ -636,8 +606,6 @@ export class DeepScanCoordinator { let stopReason: DeepScanTerminalReason | undefined; let lastReplaceableFailure: Extract | undefined; - this.audit.bufferedWorkerIds = buffer.map((worker) => worker.id); - this.audit.reducers = [...reducerOutcomes]; const errorLimit = config.stopAfterConsecutiveErrors ?? config.stopAfterNoNew; let reducerFailures = persistedReducerFailureStreak(this.state.persistedWorkers ?? []); if (this.state.consecutiveErrors >= errorLimit) { @@ -733,10 +701,6 @@ export class DeepScanCoordinator { canceledWorkerIds.push(outcome.workerId); } } - this.audit.accepted = [...accepted]; - this.audit.omittedWorkerIds = unique(omittedWorkerIds); - this.audit.canceledWorkerIds = unique(canceledWorkerIds); - this.audit.bufferedWorkerIds = buffer.map((worker) => worker.id); return firstFailure; }; const reconcileReducerSettlement = async (): Promise => { @@ -750,7 +714,6 @@ export class DeepScanCoordinator { const outcome = result.value; if ("status" in outcome) { buffer = [...outcome.consumed, ...buffer].sort(compareCompletionSequence); - this.audit.bufferedWorkerIds = buffer.map((worker) => worker.id); return outcome.error; } this.state = outcome.run; @@ -759,9 +722,6 @@ export class DeepScanCoordinator { const { result: acceptedResult, ...metadata } = outcome; latestResult = acceptedResult; reducerOutcomes.push(metadata); - this.audit.reducers = [...reducerOutcomes]; - this.audit.mergedWorkerIds = unique(mergedDiscoveries.map((worker) => worker.id)); - this.audit.bufferedWorkerIds = buffer.map((worker) => worker.id); return undefined; }; @@ -847,7 +807,6 @@ export class DeepScanCoordinator { ?? (this.state.consecutiveErrors ?? 0) + 1; this.state = { ...this.state, consecutiveErrors }; canceledWorkerIds.push(outcome.workerId); - this.audit.canceledWorkerIds = unique(canceledWorkerIds); this.log({ event: "discovery_worker_replaced", scanId: this.state.scanId, @@ -875,7 +834,6 @@ export class DeepScanCoordinator { } if (outcome.status === "canceled") { canceledWorkerIds.push(outcome.workerId); - this.audit.canceledWorkerIds = unique(canceledWorkerIds); if ( !this.abortController.signal.aborted && !this.discoveryAbortController.signal.aborted @@ -887,8 +845,6 @@ export class DeepScanCoordinator { accepted.push(outcome.worker); this.state = { ...this.state, consecutiveErrors: 0 }; buffer.push(outcome.worker); - this.audit.accepted = [...accepted]; - this.audit.bufferedWorkerIds = buffer.map((worker) => worker.id); this.logProgress(accepted.length); continue; } @@ -896,7 +852,6 @@ export class DeepScanCoordinator { reducer = undefined; if ("status" in outcome) { buffer = [...outcome.consumed, ...buffer].sort(compareCompletionSequence); - this.audit.bufferedWorkerIds = buffer.map((worker) => worker.id); reducerFailures += 1; this.log({ event: "dedup_worker_replaced", @@ -923,9 +878,6 @@ export class DeepScanCoordinator { const { result: acceptedResult, ...metadata } = outcome; latestResult = acceptedResult; reducerOutcomes.push(metadata); - this.audit.reducers = [...reducerOutcomes]; - this.audit.mergedWorkerIds = unique(mergedDiscoveries.map((worker) => worker.id)); - this.audit.bufferedWorkerIds = buffer.map((worker) => worker.id); if ( !this.discoveryDeadlineReached && outcome.run.noNewStreak >= config.stopAfterNoNew @@ -933,8 +885,6 @@ export class DeepScanCoordinator { ) { stopReason = "saturated"; canceledWorkerIds.push(...active.keys()); - this.audit.canceledWorkerIds = unique(canceledWorkerIds); - this.audit.bufferedWorkerIds = []; this.abortController.abort("deep_scan_saturated"); } } @@ -943,12 +893,6 @@ export class DeepScanCoordinator { // the manifest records which results completed and which were canceled. const lateFailure = await reconcileRemainingDiscoveries("omitted"); - this.audit.accepted = [...accepted]; - this.audit.mergedWorkerIds = unique(mergedDiscoveries.map((worker) => worker.id)); - this.audit.omittedWorkerIds = unique(omittedWorkerIds); - this.audit.canceledWorkerIds = unique(canceledWorkerIds); - this.audit.bufferedWorkerIds = buffer.map((worker) => worker.id); - // Once Deep reaches saturation, late worker errors cannot fail the scan. if (lateFailure && stopReason !== "saturated") throw lateFailure; @@ -981,7 +925,6 @@ export class DeepScanCoordinator { worker.resultManifestPath, this.state.scanId ); - const evidence = await persistedWorkerEvidence(worker); recovered.push({ id: worker.id, label: basename(dirname(worker.promptPath)), @@ -989,8 +932,7 @@ export class DeepScanCoordinator { resultPath: worker.resultManifestPath, completionSequence: worker.completionSequence, attempt: worker.attempt, - ...(worker.threadId ? { threadId: worker.threadId } : {}), - ...evidence + ...(worker.threadId ? { threadId: worker.threadId } : {}) }); } return recovered.sort(compareCompletionSequence); @@ -1031,7 +973,6 @@ export class DeepScanCoordinator { }, this.state.scanId); latestResult = result; noNewStreak = newFindings > 0 ? 0 : noNewStreak + accepted.length; - const evidence = await persistedWorkerEvidence(worker); outcomes.push({ type: "dedup", id: worker.id, @@ -1040,47 +981,12 @@ export class DeepScanCoordinator { newFindings, attempt: worker.attempt, ...(worker.threadId ? { threadId: worker.threadId } : {}), - ...evidence, run: { ...this.state, noNewStreak } }); } return { reducers: outcomes, result: latestResult }; } - private async recoverPersistedExecutions(): Promise { - const executions: WorkerExecutionAudit[] = []; - for (const worker of this.state.persistedWorkers ?? []) { - if ( - worker.kind === "setup" - || worker.status === "queued" - || worker.status === "running" - || (worker.status === "canceled" && worker.attempt === 0) - ) { - continue; - } - const replaceableFailure = persistedReplaceableFailure(worker); - const status = replaceableFailure || worker.status === "failed" - ? "failed" - : worker.status; - executions.push({ - id: worker.id, - label: basename(dirname(worker.promptPath)), - kind: worker.kind, - status, - attempt: worker.attempt, - ...(worker.threadId ? { threadId: worker.threadId } : {}), - promptPath: worker.promptPath, - artifactDir: worker.artifactDir, - ...await persistedWorkerEvidence(worker), - ...(status === "failed" && worker.error - ? { error: replaceableFailure?.message ?? worker.error } - : {}), - ...(replaceableFailure ? { failureKind: replaceableFailure.kind } : {}) - }); - } - return executions; - } - private reducerReady( buffer: AcceptedDiscovery[], previousReducerResultPath: string | undefined, @@ -1238,30 +1144,6 @@ function persistedReplaceableFailure( return undefined; } -async function persistedWorkerEvidence(worker: PersistedDeepScanWorker): Promise<{ - basePromptSha256: string; - attemptPromptPaths: string[]; -}> { - const attemptPromptPaths = [worker.promptPath]; - for (let attempt = 2; attempt <= worker.attempt; attempt += 1) { - const promptPath = join( - dirname(worker.promptPath), - "prompts", - `attempt-${String(attempt).padStart(2, "0")}.md` - ); - try { - await fs.access(promptPath); - attemptPromptPaths.push(promptPath); - } catch { - // Transient execution retries reuse the original prompt. - } - } - return { - basePromptSha256: sha256(await fs.readFile(worker.promptPath, "utf8")), - attemptPromptPaths - }; -} - function discoveryErrorLimitError( count: number, limit: number, diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 82f1199794..51738be8f3 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import { dirname, join } from "node:path"; import { getCodexSecurityDeepReducerInputs } from "../artifact-deep-reducer.js"; @@ -41,8 +40,6 @@ export interface AcceptedDiscovery { completionSequence: number; attempt: number; threadId?: string; - basePromptSha256: string; - attemptPromptPaths: string[]; } export type DiscoveryOutcome = @@ -66,8 +63,6 @@ export interface SuccessfulDedupOutcome { newFindings: number; attempt: number; threadId?: string; - basePromptSha256: string; - attemptPromptPaths: string[]; run: DeepScanRunState; } @@ -81,22 +76,6 @@ export interface FailedDedupOutcome { export type DedupOutcome = SuccessfulDedupOutcome | FailedDedupOutcome; -/** Audit evidence for every logical SDK execution, including failures and cancellation. */ -export interface WorkerExecutionAudit { - id: string; - label: string; - kind: DeepScanWorkerKind; - status: "succeeded" | "failed" | "canceled"; - attempt: number; - threadId?: string; - promptPath: string; - artifactDir: string; - basePromptSha256: string; - attemptPromptPaths: string[]; - error?: string; - failureKind?: DeepScanReplaceableFailureKind; -} - export interface ReducerRequest { id: string; label: string; @@ -115,13 +94,11 @@ export interface DeepScanWorkerRunnerOptions { log: DeepScanLogger; retryDelaysMs: readonly number[]; signal: AbortSignal; - recordExecution?: (execution: WorkerExecutionAudit) => void; } interface WorkerAttemptEvidence { attempt: number; threadId?: string; - attemptPromptPaths: string[]; } type WorkerAttemptOutcome = @@ -207,15 +184,6 @@ export class DeepScanWorkerRunner { if (!discoveryValidated) { await fs.rm(files.resultPath, { force: true }); } - const basePromptSha256 = sha256(basePrompt); - this.recordExecution({ - id: workerId, - label: workerLabel, - kind: "discovery", - promptPath, - artifactDir, - basePromptSha256 - }, outcome); if (outcome.status === "failed") { return { type: "discovery", @@ -283,9 +251,7 @@ export class DeepScanWorkerRunner { resultPath: files.resultPath, completionSequence: persisted.completionSequence, attempt: outcome.attempt, - threadId: outcome.threadId, - basePromptSha256, - attemptPromptPaths: outcome.attemptPromptPaths + threadId: outcome.threadId } }; } @@ -377,15 +343,6 @@ export class DeepScanWorkerRunner { }, outcome.attempt, outcome.threadId); outcome = { ...outcome, status: "canceled" }; } - const basePromptSha256 = sha256(basePrompt); - this.recordExecution({ - id: reducerId, - label: reducerLabel, - kind: "dedup", - promptPath, - artifactDir, - basePromptSha256 - }, outcome); if (outcome.status === "failed") { if (outcome.error instanceof DeepScanNonRetryableError) throw outcome.error; return { @@ -447,8 +404,6 @@ export class DeepScanWorkerRunner { newFindings: reducerValidation.newFindings, attempt: outcome.attempt, threadId: outcome.threadId, - basePromptSha256, - attemptPromptPaths: outcome.attemptPromptPaths, run: committed }; } @@ -470,10 +425,9 @@ export class DeepScanWorkerRunner { let continuationPrompt: string | undefined; let lastThreadId: string | undefined; let executionPromptPath = input.promptPath; - const attemptPromptPaths = [input.promptPath]; for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) { if (signal.aborted) { - return await this.cancelAttempt(input, attempt, lastThreadId, attemptPromptPaths); + return await this.cancelAttempt(input, attempt, lastThreadId); } let validationStarted = false; let validationCompleted = false; @@ -527,7 +481,7 @@ export class DeepScanWorkerRunner { } }); if (signal.aborted) { - return await this.cancelAttempt(input, attempt, activeThreadId, attemptPromptPaths); + return await this.cancelAttempt(input, attempt, activeThreadId); } validationStarted = true; try { @@ -537,7 +491,7 @@ export class DeepScanWorkerRunner { } validationCompleted = true; if (signal.aborted) { - return await this.cancelAttempt(input, attempt, activeThreadId, attemptPromptPaths); + return await this.cancelAttempt(input, attempt, activeThreadId); } this.options.log({ event: "worker_succeeded", @@ -549,12 +503,11 @@ export class DeepScanWorkerRunner { return { status: "succeeded", attempt, - threadId: result.threadId ?? activeThreadId, - attemptPromptPaths: [...attemptPromptPaths] + threadId: result.threadId ?? activeThreadId }; } catch (error) { if (signal.aborted) { - return await this.cancelAttempt(input, attempt, activeThreadId, attemptPromptPaths); + return await this.cancelAttempt(input, attempt, activeThreadId); } const normalized = asError(error); const policyRefusal = input.kind === "discovery" @@ -588,8 +541,7 @@ export class DeepScanWorkerRunner { ? {} : { consecutiveErrors: persistedFailure.consecutiveErrors }), attempt, - threadId: activeThreadId, - attemptPromptPaths: [...attemptPromptPaths] + threadId: activeThreadId }; } await this.options.store.updateWorker({ @@ -627,7 +579,6 @@ export class DeepScanWorkerRunner { failedAttempt: attempt, error: normalized }); - attemptPromptPaths.push(executionPromptPath); } } const delayMs = Math.ceil( @@ -645,7 +596,7 @@ export class DeepScanWorkerRunner { await this.options.clock.sleep(delayMs, signal); } catch (sleepError) { if (signal.aborted) { - return await this.cancelAttempt(input, attempt, activeThreadId, attemptPromptPaths); + return await this.cancelAttempt(input, attempt, activeThreadId); } throw sleepError; } @@ -714,36 +665,15 @@ export class DeepScanWorkerRunner { artifactDir: string; }, attempt: number, - threadId: string | undefined, - attemptPromptPaths: string[] + threadId: string | undefined ): Promise { await this.persistWorkerCancellation(input, attempt, threadId); return { status: "canceled", attempt, - threadId, - attemptPromptPaths: [...attemptPromptPaths] + threadId }; } - - private recordExecution( - input: Omit, - outcome: WorkerAttemptOutcome - ): void { - this.options.recordExecution?.({ - ...input, - status: outcome.status, - attempt: outcome.attempt, - ...(outcome.threadId ? { threadId: outcome.threadId } : {}), - attemptPromptPaths: [...outcome.attemptPromptPaths], - ...(outcome.status === "failed" ? { - error: outcome.error.message, - ...(outcome.replaceableFailureKind - ? { failureKind: outcome.replaceableFailureKind } - : {}) - } : {}) - }); - } } /** @@ -880,10 +810,6 @@ function validationErrorData(error: Error, message: string): Record structuredClone(store.run); const replacementExecutor = new FakeExecutor({ dedupNewFindings: [0] }); const acceptedResult = await readFile(accepted.resultManifestPath, "utf8"); + const acceptedWorkerId = await workerIdFromPrompt(accepted.promptPath); + await Promise.all(persistedWorkers.map((worker) => rm(worker.promptPath, { force: true }))); const resumed = await startOrJoinDeepScanCoordinator({ begin: { run: structuredClone(store.run), shouldStart: false }, registry: new DeepScanCoordinatorRegistry(), @@ -2926,11 +2928,11 @@ async function testPausedDiscoverySurvivesCoordinatorRestart() { assert.equal(continuationClaims.length, 1); assert.equal(continuationClaims[0].handoffClaimToken, handoffClaimToken); - assert.equal(terminal?.status, "succeeded"); + assert.equal(terminal?.status, "succeeded", terminal?.error); assert.equal(store.failCalls, 0); assert.equal(replacementExecutor.logicalDiscoveryWorkers.size, 1); assert.equal( - replacementExecutor.logicalDiscoveryWorkers.has(await workerIdFromPrompt(accepted.promptPath)), + replacementExecutor.logicalDiscoveryWorkers.has(acceptedWorkerId), false ); assert.equal(store.dedupClaims.length, 1); From 35deeaf9c16307bb6e1c0ff511548fbed60ffca5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:20:20 +0000 Subject: [PATCH 002/251] Preserve distinct source remediations in published reports --- .../scripts/report_projection.py | 56 ++++++++++++++----- .../test_deep_scan_successful_publication.py | 32 +++++++++++ .../tests/test_report_projection.py | 20 +++++++ 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/plugins/codex-security/scripts/report_projection.py b/plugins/codex-security/scripts/report_projection.py index 98b4f00d18..b98b052e34 100644 --- a/plugins/codex-security/scripts/report_projection.py +++ b/plugins/codex-security/scripts/report_projection.py @@ -523,6 +523,41 @@ def _surface_notes(surface: dict[str, Any]) -> str: return _cell(f"{notes} Evidence: {evidence}") +def _remediation_section(finding: dict[str, Any]) -> list[str]: + remediation = _text(finding.get("remediation"), "No canonical remediation was recorded.") + lines = ["", "#### Remediation", "", remediation] + seen = {remediation} + sources = finding.get("provenance", {}).get("sourceFindings", []) + originals = ( + [ + source + for source in sources + if isinstance(source, dict) and isinstance(source.get("finding"), dict) + ] + if isinstance(sources, list) + else [] + ) + for source in originals: + text = _text(source["finding"].get("remediation"), "") + if text and text not in seen: + seen.add(text) + lines.extend(["", f"Source {_text(source.get('id'), 'finding')}: {text}"]) + for field, label in ( + ("remediationTests", "Tests"), + ("preventiveControls", "Preventive controls"), + ): + values = list( + dict.fromkeys( + value + for original in [finding, *(source["finding"] for source in originals)] + for value in _strings(original.get(field)) + ) + ) + if values: + lines.extend(["", f"{label}:", *_bullets(values, "None recorded.")]) + return lines + + def _finding_section(number: int, finding: dict[str, Any]) -> list[str]: validation = finding.get("validation") if isinstance(finding.get("validation"), dict) else {} _, raw_root_cause = merged_root_cause(finding) @@ -616,8 +651,6 @@ def _finding_section(number: int, finding: dict[str, Any]) -> list[str]: severity.get("changeConditions"), "Additional runtime or deployment evidence could raise or lower this severity.", ) - remediation_tests = _strings(finding.get("remediationTests")) - preventive_controls = _strings(finding.get("preventiveControls")) attack_steps = _strings(attack_path.get("steps")) cwes = ", ".join(finding["taxonomy"]["cwe"]) or "none" title = _text(finding["title"], "Untitled finding") @@ -736,18 +769,7 @@ def _finding_section(number: int, finding: dict[str, Any]) -> list[str]: lines.extend( ["", f"{label} assessment:", *(f"- **{name}:** {value}" for name, value in details)] ) - lines.extend( - [ - "", - "#### Remediation", - "", - _text(finding["remediation"], "No canonical remediation was recorded."), - ] - ) - if remediation_tests: - lines.extend(["", "Tests:", *_bullets(remediation_tests, "No tests recorded.")]) - if preventive_controls: - lines.extend(["", "Preventive controls:", *_bullets(preventive_controls, "None recorded.")]) + lines.extend(_remediation_section(finding)) return lines @@ -769,8 +791,12 @@ def _linked_finding_section(number: int, finding: dict[str, Any], report_path: s f"| CWE | {_cell(cwes)} |", f"| Affected lines | {_cell(_locations(finding))} |", ] - for heading in ("Summary", "Validation", "Dataflow", "Reachability", "Severity", "Remediation"): + for heading in ("Summary", "Validation", "Dataflow", "Reachability", "Severity"): lines.extend(["", f"#### {heading}", "", f"See the {link}."]) + if finding.get("provenance", {}).get("sourceFindings"): + lines.extend(_remediation_section(finding)) + else: + lines.extend(["", "#### Remediation", "", f"See the {link}."]) return lines diff --git a/plugins/codex-security/tests/test_deep_scan_successful_publication.py b/plugins/codex-security/tests/test_deep_scan_successful_publication.py index 8a144ece48..0c7a3ce753 100644 --- a/plugins/codex-security/tests/test_deep_scan_successful_publication.py +++ b/plugins/codex-security/tests/test_deep_scan_successful_publication.py @@ -173,6 +173,38 @@ def assert_published_aggregate(scan): assert (scan.scan_dir / "report.md").is_file() +def test_deep_publication_renders_each_source_remediation( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + finding = scan.findings[0] + first = copy.deepcopy(finding) + first.pop("provenance") + first["remediation"] = "Check the destination before writing the archive entry." + first["remediationTests"] = ["Reject an archive entry outside the destination."] + second = copy.deepcopy(first) + second["remediation"] = "Reject symbolic links before opening the destination." + second["remediationTests"] = ["Reject a symbolic link inside the destination."] + second["preventiveControls"] = ["Use a directory-relative file handle."] + finding["remediation"] = first["remediation"] + finding["remediationTests"] = first["remediationTests"] + finding["provenance"]["sourceFindings"] = [ + {"id": "review-1:0", "finding": first}, + {"id": "review-2:0", "finding": second}, + ] + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": scan.findings})) + + complete(workbench_api, workbench_db, scan) + + assert_published_aggregate(scan) + report = (scan.scan_dir / "report.md").read_text() + for source in (first, second): + assert report.count(source["remediation"]) == 1 + for test in source["remediationTests"]: + assert report.count(test) == 1 + assert "Use a directory-relative file handle." in report + + @pytest.mark.parametrize("scope", [".", "subdir"], ids=["repository", "scoped"]) def test_deep_publication_keeps_configured_scope_without_worker_observations( workbench_api, workbench_db, publication_scan, scope diff --git a/plugins/codex-security/tests/test_report_projection.py b/plugins/codex-security/tests/test_report_projection.py index e88dcb63af..47495d7d54 100644 --- a/plugins/codex-security/tests/test_report_projection.py +++ b/plugins/codex-security/tests/test_report_projection.py @@ -75,6 +75,26 @@ def test_projection_normalizes_multiline_and_block_structural_text() -> None: assert "Text: ## Injected remediation - unsafe instruction" in markdown +def test_linked_writeup_retains_distinct_source_fixes() -> None: + manifest, findings, coverage = canonical_documents() + finding = findings["findings"][0] + finding["writeup"] = {"reportPath": "findings/parser/parser.md"} + finding["remediation"] = "Validate the record length." + finding["provenance"] = { + "sourceFindings": [ + {"id": "review-1:0", "finding": {"remediation": "Validate the record length."}}, + {"id": "review-2:0", "finding": {"remediation": "Reject duplicate record keys."}}, + {"id": "review-3:0", "finding": {"remediation": "Reject duplicate record keys."}}, + ] + } + + markdown = PROJECTION.build_report_markdown(manifest, findings, coverage) + + assert "findings/parser/parser.md" in markdown + assert markdown.count("Validate the record length.") == 1 + assert markdown.count("Reject duplicate record keys.") == 1 + + def test_projection_renders_inline_code_and_section_code_evidence() -> None: manifest, findings, coverage = canonical_documents() finding = findings["findings"][0] From 5113f40af0d6b3a5e6d6f617de06b94fa61747d8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:25:45 +0000 Subject: [PATCH 003/251] Reject unsupported Deep Scan recovery before ownership changes --- .../mcp-app/src/deep-scan/registry.ts | 18 ++ .../mcp-app/src/deep-scan/store.ts | 2 + .../mcp-app/src/deep-scan/types.ts | 2 + .../tests/test_deep_scan_compatibility.mjs | 38 +++++ .../mcp-app/tests/test_deep_scan_store.mjs | 11 ++ .../scripts/deep_scan_workbench.py | 28 +++- .../tests/test_deep_scan_compatibility.py | 156 ++++++++++++++++++ .../tests-ts/deep-scan-workbench.test.ts | 4 +- 8 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs create mode 100644 plugins/codex-security/tests/test_deep_scan_compatibility.py diff --git a/plugins/codex-security/mcp-app/src/deep-scan/registry.ts b/plugins/codex-security/mcp-app/src/deep-scan/registry.ts index 574ff2c535..f7318fe385 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/registry.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/registry.ts @@ -18,6 +18,7 @@ export class DeepScanCoordinatorRegistry { } start(options: CoordinatorOptions): DeepScanCoordinator { + requireSupportedDeepScan(options.run); const existing = this.coordinators.get(options.run.scanId); if (existing) return existing; const { observeReplacement: _unused, ...remoteOptions } = options; @@ -136,6 +137,7 @@ export class DeepScanRemoteCoordinator { continue; } if (run.status !== "running") return run; + requireSupportedDeepScan(run); const heartbeat = run.updatedAt ? Date.parse(run.updatedAt) : Number.NaN; if ( @@ -190,6 +192,7 @@ export async function startOrJoinDeepScanCoordinator(input: { coordinator: DeepScanCoordinator | DeepScanRemoteCoordinator; joined: boolean; }> { + requireSupportedDeepScan(input.begin.run); const existing = input.registry.get(input.begin.run.scanId); if (existing) return { coordinator: existing, joined: true }; const threadId = input.options.threadId; @@ -217,6 +220,21 @@ export async function startOrJoinDeepScanCoordinator(input: { }; } +function requireSupportedDeepScan(run: DeepScanRunState): void { + // Missing versions are supported for older adapters that did not project them. + if ( + (run.schemaVersion !== undefined && run.schemaVersion !== 1) + || (run.workflowVersion !== undefined + && run.workflowVersion !== "deep-security-scan/v1" + && run.workflowVersion !== "deep-scan-mcp/v1") + ) { + throw new Error( + "This Deep Scan uses an unsupported workflow or schema version. " + + "Resume it with a compatible Codex Security release." + ); + } +} + function remoteAbortError(reason: unknown): Error { const error = new Error("Deep Scan observation was aborted.", { cause: reason }); error.name = "AbortError"; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index 8bf6b0bf7c..ed5094e277 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -586,6 +586,8 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { }; return { scanId: requiredString(value.scanId, "deepScan.scanId"), + schemaVersion: optionalPositiveInteger(value.schemaVersion), + workflowVersion: optionalString(value.workflowVersion), status, phase: deepScanPhase(value.phase), coordinatorGeneration: optionalPositiveInteger(value.coordinatorGeneration), diff --git a/plugins/codex-security/mcp-app/src/deep-scan/types.ts b/plugins/codex-security/mcp-app/src/deep-scan/types.ts index dea2d9431c..612ec4c4d7 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/types.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/types.ts @@ -39,6 +39,8 @@ export type DeepScanReducerArtifacts = DeepScanCanonicalArtifacts; export interface DeepScanRunState { scanId: string; + schemaVersion?: number; + workflowVersion?: string; status: DeepScanRunStatus; phase?: "setup" | "discovery" | "reducing" | "terminal"; coordinatorGeneration?: number; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs new file mode 100644 index 0000000000..0ce73fbd83 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { build } from "esbuild"; + +const bundle = await build({ + bundle: true, + entryPoints: [new URL("../src/deep-scan/registry.ts", import.meta.url).pathname], + format: "esm", + loader: { ".md": "text" }, + platform: "node", + write: false +}); +const { startOrJoinDeepScanCoordinator } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` +); + +await testUnsupportedWorkflowDoesNotAcquireOwnership(); + +async function testUnsupportedWorkflowDoesNotAcquireOwnership() { + for (const version of [ + { schemaVersion: 99, workflowVersion: "deep-scan-mcp/v1" }, + { schemaVersion: 1, workflowVersion: "future/v99" } + ]) { + let mutations = 0; + await assert.rejects(startOrJoinDeepScanCoordinator({ + begin: { run: { scanId: "fixture", ...version }, shouldStart: false }, + registry: { + get: () => undefined, + start: () => { mutations += 1; } + }, + options: { + threadId: "fixture-thread", + store: { claimCoordinator: async () => { mutations += 1; } } + } + }), /unsupported workflow or schema version/); + assert.equal(mutations, 0); + } +} + diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs index 09eb1f8840..afb3d57128 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs @@ -21,6 +21,7 @@ await testCanonicalCommitProtocol(); await testTerminalProtocol(); testCanonicalNullAndPartialParsing(); testRunErrorParsing(); +testWorkflowVersionParsing(); testConfiguredMaximumDurationParsing(); await testWriteSerializationAndRecovery(); await testBeginUsesTheWriteQueue(); @@ -875,3 +876,13 @@ function deferred() { }); return { promise, resolve }; } + +function testWorkflowVersionParsing() { + const value = stateResult(randomUUID()).deepScan; + const run = parseDeepScan({ deepScan: { ...value, schemaVersion: 1, workflowVersion: "deep-scan-mcp/v1" } }); + assert.equal(run.schemaVersion, 1); + assert.equal(run.workflowVersion, "deep-scan-mcp/v1"); + const future = parseDeepScan({ deepScan: { ...value, schemaVersion: 99, workflowVersion: "future/v99" } }); + assert.equal(future.schemaVersion, 99); + assert.equal(future.workflowVersion, "future/v99", "inspection preserves unsupported versions"); +} diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 853094a0af..89e5e99224 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -38,6 +38,7 @@ ) DEEP_SCAN_TERMINAL_REASONS = ("saturated", "capped") DEEP_SCAN_WORKFLOW_VERSION = "deep-security-scan/v1" +SUPPORTED_DEEP_SCAN_WORKFLOWS = {DEEP_SCAN_WORKFLOW_VERSION, "deep-scan-mcp/v1"} DEEP_SCAN_COORDINATOR_LEASE_SECONDS = 30 DEEP_SCAN_LEGACY_COORDINATOR_GRACE_SECONDS = 120 DEEP_SCAN_MAX_ERROR_LENGTH = 2400 @@ -264,6 +265,14 @@ def require_deep_scan_run(connection: sqlite3.Connection, scan_id: str) -> sqlit return row +def require_supported_deep_scan(run: sqlite3.Row) -> None: + if run["schema_version"] != 1 or run["workflow_version"] not in SUPPORTED_DEEP_SCAN_WORKFLOWS: + raise SystemExit( + "This Deep Scan uses an unsupported workflow or schema version. " + "Resume it with a compatible Codex Security release." + ) + + def deep_scan_deadline_reached(run: sqlite3.Row) -> bool: elapsed = _parse_timestamp(now()) - _parse_timestamp(str(run["created_at"])) return elapsed.total_seconds() / 3600 >= run["max_time_hours"] @@ -453,7 +462,11 @@ def deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, A "scanId": run["scan_id"], "targetPath": scan["target_path"], "scope": scan["scope"], - "userContext": scan["user_context"], + "userContext": ( + run["discovery_user_context"] + if "discovery_user_context" in run.keys() + else scan["user_context"] + ), "scanDir": scan["scan_dir"], "schemaVersion": run["schema_version"], "workflowVersion": run["workflow_version"], @@ -572,6 +585,7 @@ def ensure_deep_scan_run( "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan["id"],) ).fetchone() if existing is not None: + require_supported_deep_scan(existing) return existing if scan["mode"] != "deep": raise SystemExit("Deep Scan orchestration requires a scan in deep mode.") @@ -687,6 +701,11 @@ def begin_deep_scan_for_scan( ) -> dict[str, Any]: scan_id = require_uuid(scan_id, "scan-id") candidate = require_scan(connection, scan_id) + existing = connection.execute( + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) + ).fetchone() + if existing is not None: + require_supported_deep_scan(existing) workspace = require_workspace(connection, candidate["workspace_id"]) if ( candidate["mode"] == "deep" @@ -788,8 +807,10 @@ def begin_deep_scan_for_target( existing = existing_deep_scan_for_target(connection, thread_id, target_path, scope) if existing is not None: existing_run = connection.execute( - "SELECT 1 FROM deep_scan_runs WHERE scan_id = ?", (existing["id"],) + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (existing["id"],) ).fetchone() + if existing_run is not None: + require_supported_deep_scan(existing_run) if existing_run is None: config = effective_deep_scan_config(args) workflow_version = optional_text(args.workflow_version, maximum=256) @@ -979,6 +1000,7 @@ def coordinator_lease_is_live( def require_current_coordinator(run: sqlite3.Row, args: argparse.Namespace) -> None: + require_supported_deep_scan(run) generation = getattr(args, "coordinator_generation", None) if run["coordinator_generation"] == 1: if generation is not None: @@ -1047,6 +1069,7 @@ def claim_deep_scan_coordinator_locked( def recover_expired_coordinator( connection: sqlite3.Connection, run: sqlite3.Row, timestamp: str ) -> None: + require_supported_deep_scan(run) scan_id = run["scan_id"] recover_candidate_ledger_publication(connection, scan_id) legacy_generation = int(run["coordinator_generation"] == 1) @@ -1173,6 +1196,7 @@ def require_running_deep_scan( connection: sqlite3.Connection, scan_id: str ) -> tuple[sqlite3.Row, sqlite3.Row]: run = require_deep_scan_run(connection, scan_id) + require_supported_deep_scan(run) scan = require_scan(connection, run["scan_id"]) if run["status"] != "running" or run["cancel_requested"]: raise SystemExit("Only a running Deep Scan can update orchestration state.") diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py new file mode 100644 index 0000000000..2a26d24502 --- /dev/null +++ b/plugins/codex-security/tests/test_deep_scan_compatibility.py @@ -0,0 +1,156 @@ +"""Compatibility checks use real persisted scans and preserve unsupported state.""" + +from __future__ import annotations + +import sqlite3 +import uuid +from pathlib import Path + +import pytest +from workbench_test_support import run_workbench + + +def snapshot(state_dir: Path) -> str: + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + return "\n".join(connection.iterdump()) + + +@pytest.mark.parametrize("version", ["deep-security-scan/v1", "deep-scan-mcp/v1"]) +def test_supported_workflows_keep_their_identity(tmp_path: Path, version: str) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + begun = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + "--workflow-version", + version, + )["deepScan"] + claimed = run_workbench( + state, + "claim-deep-scan-coordinator", + "--scan-id", + str(begun["scanId"]), + "--thread-id", + "fixture-thread", + )["deepScan"] + assert claimed["workflowVersion"] == version + assert claimed["schemaVersion"] == 1 + assert claimed["noNewStreak"] == begun["noNewStreak"] + assert claimed["config"] == begun["config"] + + +@pytest.mark.parametrize("field,value", [("workflow_version", "future/v99")]) +@pytest.mark.parametrize("operation", ["begin", "claim", "handoff"]) +def test_unsupported_execution_does_not_mutate( + tmp_path: Path, + field: str, + value: str | int, + operation: str, +) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + begun = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + )["deepScan"] + scan_id = str(begun["scanId"]) + if operation == "claim": + artifact_dir = Path(str(begun["scanDir"])) / "artifacts" / "deep_discovery" / "worker" + artifact_dir.mkdir(parents=True) + prompt = artifact_dir / "prompt.md" + prompt.write_text("Original discovery input") + run_workbench( + state, + "upsert-deep-scan-worker", + "--scan-id", + scan_id, + "--worker-id", + str(uuid.uuid4()), + "--kind", + "discovery", + "--status", + "running", + "--prompt-path", + str(prompt), + "--artifact-dir", + str(artifact_dir), + "--attempt", + "1", + ) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + f"UPDATE deep_scan_runs SET {field} = ?, updated_at = ?", + (value, "2000-01-01T00:00:00Z"), + ) + if operation == "handoff": + connection.execute( + "UPDATE scans SET deep_scan_owner_thread_id = NULL, recipe_json = '{}'" + ) + connection.execute("UPDATE workspaces SET thread_id = NULL") + before = snapshot(state) + command = "claim-deep-scan-coordinator" if operation == "claim" else "begin-deep-scan" + result = run_workbench( + state, + command, + "--scan-id", + scan_id, + "--thread-id", + "fixture-thread", + *(["--model", "observer-model"] if operation != "claim" else []), + check=False, + ) + assert result["returncode"] != 0 + assert "unsupported" in str(result["stderr"]).lower() + assert snapshot(state) == before + if operation != "handoff": + observed = run_workbench( + state, "get-deep-scan", "--scan-id", scan_id, "--thread-id", "fixture-thread" + )["deepScan"] + assert ( + observed["workflowVersion" if field == "workflow_version" else "schemaVersion"] == value + ) + assert snapshot(state) == before + + +@pytest.mark.parametrize("original", [None, "Original discovery context"]) +def test_reader_honors_original_context_when_present(tmp_path: Path, original: str | None) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + begun = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + )["deepScan"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute("ALTER TABLE deep_scan_runs ADD COLUMN discovery_user_context TEXT") + connection.execute("UPDATE deep_scan_runs SET discovery_user_context = ?", (original,)) + connection.execute("UPDATE scans SET user_context = 'Later discussion'") + observed = run_workbench( + state, + "get-deep-scan", + "--scan-id", + str(begun["scanId"]), + "--thread-id", + "fixture-thread", + )["deepScan"] + assert observed["userContext"] == original diff --git a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts index 1df7f704a3..abe6edcbbb 100644 --- a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts @@ -34,12 +34,12 @@ const deepScanOwnershipProbe = [ "connection.executescript('''", "CREATE TABLE workspaces (id TEXT PRIMARY KEY, thread_id TEXT, updated_at TEXT);", "CREATE TABLE scans (id TEXT PRIMARY KEY, workspace_id TEXT, mode TEXT, status TEXT, recipe_json TEXT, handoff_status TEXT, handoff_claim_token TEXT, deep_scan_owner_thread_id TEXT, updated_at TEXT);", - "CREATE TABLE deep_scan_runs (scan_id TEXT PRIMARY KEY);", + "CREATE TABLE deep_scan_runs (scan_id TEXT PRIMARY KEY, schema_version INTEGER NOT NULL DEFAULT 1, workflow_version TEXT NOT NULL DEFAULT 'deep-scan-mcp/v1');", "''')", "scan_id = '11111111-1111-4111-8111-111111111111'", "connection.execute(\"INSERT INTO workspaces VALUES ('workspace', NULL, 'before')\")", "connection.execute(\"INSERT INTO scans VALUES (?, 'workspace', 'deep', 'running', '{}', 'delivered', ?, NULL, 'before')\", (scan_id, case['storedToken']))", - "connection.execute('INSERT INTO deep_scan_runs VALUES (?)', (scan_id,))", + "connection.execute('INSERT INTO deep_scan_runs (scan_id) VALUES (?)', (scan_id,))", "connection.commit()", "if case.get('mutation') == 'rotate':", " connection.executescript(\"CREATE TRIGGER rotate_claim BEFORE UPDATE OF thread_id ON workspaces BEGIN UPDATE scans SET handoff_claim_token = '33333333-3333-4333-8333-333333333333' WHERE workspace_id = NEW.id; END\")", From 88a811f533816439c206a9ee5c3ce39021ce2232 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:24:01 +0000 Subject: [PATCH 004/251] Read Deep Scan state from one SQLite snapshot --- .../scripts/deep_scan_workbench.py | 13 ++++ .../tests/test_deep_scan_persistence.py | 72 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 plugins/codex-security/tests/test_deep_scan_persistence.py diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 89e5e99224..42859ff2c8 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -419,6 +419,19 @@ def canonical_discovery_artifacts(scan: sqlite3.Row) -> dict[str, str]: def deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, Any]: + if connection.in_transaction: + return _deep_scan_state(connection, scan_id) + connection.execute("BEGIN") + try: + state = _deep_scan_state(connection, scan_id) + connection.commit() + return state + except BaseException: + connection.rollback() + raise + + +def _deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, Any]: run = require_deep_scan_run(connection, scan_id) scan = require_scan(connection, run["scan_id"]) worker_rows = connection.execute( diff --git a/plugins/codex-security/tests/test_deep_scan_persistence.py b/plugins/codex-security/tests/test_deep_scan_persistence.py new file mode 100644 index 0000000000..bd9fe1392f --- /dev/null +++ b/plugins/codex-security/tests/test_deep_scan_persistence.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path + +import pytest +from test_workbench_deep_scan import begin_target_scan, dispatch_discovery_worker + + +def test_state_snapshot_does_not_mix_concurrent_acceptance( + tmp_path: Path, workbench_api, monkeypatch: pytest.MonkeyPatch +) -> None: + state_dir, codex_home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + initial = begin_target_scan(state_dir, codex_home, target, tmp_path / "scans")["deepScan"] + scan_id = initial["scanId"] + worker_id, _, _, _ = dispatch_discovery_worker( + state_dir, + codex_home, + scan_id=scan_id, + scan_dir=Path(initial["scanDir"]), + name="discovery-1", + succeed=False, + ) + database = state_dir / "workbench.sqlite3" + deep_scan = sys.modules["deep_scan_workbench"] + monkeypatch.setattr(deep_scan, "require_scan", workbench_api["require_scan"]) + original = deep_scan.require_deep_scan_run + + def accept_after_read(connection, requested_scan_id): + run = original(connection, requested_scan_id) + with sqlite3.connect(database) as writer: + writer.execute( + "UPDATE deep_scan_runs SET completion_sequence = 1 WHERE scan_id = ?", (scan_id,) + ) + writer.execute( + "UPDATE deep_scan_workers SET status = 'succeeded', completion_sequence = 1 " + "WHERE id = ?", + (worker_id,), + ) + return run + + monkeypatch.setattr(deep_scan, "require_deep_scan_run", accept_after_read) + with sqlite3.connect(database) as reader: + reader.row_factory = sqlite3.Row + snapshot = deep_scan.deep_scan_state(reader, scan_id) + assert snapshot["completionSequence"] == 0 + assert snapshot["workers"][0]["status"] == "running" + assert not reader.in_transaction + + +def test_state_snapshot_preserves_its_callers_transaction( + tmp_path: Path, workbench_api, monkeypatch: pytest.MonkeyPatch +) -> None: + target = tmp_path / "target" + target.mkdir() + initial = begin_target_scan(tmp_path / "state", tmp_path / "codex", target, tmp_path / "scans") + scan_id = initial["deepScan"]["scanId"] + deep_scan = sys.modules["deep_scan_workbench"] + monkeypatch.setattr(deep_scan, "require_scan", workbench_api["require_scan"]) + with sqlite3.connect(tmp_path / "state" / "workbench.sqlite3") as connection: + connection.row_factory = sqlite3.Row + connection.execute("BEGIN IMMEDIATE") + connection.execute( + "UPDATE deep_scan_runs SET consecutive_errors = 2 WHERE scan_id = ?", (scan_id,) + ) + snapshot = deep_scan.deep_scan_state(connection, scan_id) + assert snapshot["consecutiveErrors"] == 2 + assert connection.in_transaction + connection.rollback() + assert deep_scan.deep_scan_state(connection, scan_id)["consecutiveErrors"] == 0 From 16a456e113d0e8fefa4f45a6695bc0f9e8f01238 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:24:01 +0000 Subject: [PATCH 005/251] Distinguish missing scan usage from reported zero --- .../scripts/workbench_scan_usage.py | 7 +++++++ .../tests/test_workbench_scan_usage.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 648924d927..e3296d6c8f 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -151,6 +151,9 @@ def collect_scan_usage( missing_thread_ids.add(session.thread_id) continue accepted_thread_ids.add(session.thread_id) + if "token_usage_unavailable" in session_warnings: + missing_thread_ids.add(session.thread_id) + continue observed_thread_count += 1 _add_token_usage(total, session_usage) @@ -404,6 +407,7 @@ def _read_rollout_usage( warnings: set[str] = set() previous = _empty_token_usage() boundary_reached = False + usage_observed = False with session.path.open("rb") as source: for line_number, raw_line in enumerate(source, start=1): @@ -478,12 +482,15 @@ def _read_rollout_usage( continue if completed_at is not None and timestamp > completed_at: continue + usage_observed = True if delta["totalTokens"] <= 0: continue _add_token_usage(total, delta) if not boundary_reached: warnings.add("thread_ownership_unavailable") + elif not usage_observed: + warnings.add("token_usage_unavailable") return total, warnings diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 06b7ea4250..ccbb6e0721 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -492,6 +492,24 @@ def test_completion_reports_unavailable_without_fabricating_zero(tmp_path: Path) assert "totalTokens" not in usage +@pytest.mark.parametrize("reported", [False, True], ids=["missing", "explicit-zero"]) +def test_completion_distinguishes_missing_token_records_from_zero( + tmp_path: Path, reported: bool +) -> None: + fixture = _start_scan(tmp_path) + counted = fixture.started_at + timedelta(microseconds=1) + parent = _rollout(tmp_path, "scan-parent", [_token_event(counted, 0, 0)] if reported else []) + _state_graph(fixture.environment, {"scan-parent": parent}, []) + usage = _complete_scan(fixture)["scan"]["usage"] + if reported: + assert usage["coverage"] == "complete" + assert usage["totalTokens"] == 0 + else: + assert usage["coverage"] == "unavailable" + assert "token_usage_unavailable" in usage["warnings"] + assert "totalTokens" not in usage + + @pytest.mark.skipif(sys.platform != "darwin", reason="macOS system path aliases") @pytest.mark.parametrize("temporary_root", [tempfile.gettempdir(), "/tmp"], ids=["var", "tmp"]) def test_completion_accepts_macos_system_rollout_alias( From fca3a93e495fa8c3296583349c3766df72d8c282 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:24:55 +0000 Subject: [PATCH 006/251] Honor worker checkpoint heads during result recovery --- .../scripts/workbench_saved_results.py | 49 ++++++++++-- .../test_checkpoint_publication_authority.py | 78 +++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 plugins/codex-security/tests/test_checkpoint_publication_authority.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index a32b965132..51a89f6dcb 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -173,6 +173,23 @@ def _read_saved_result( return draft, _digest(draft) +def _worker_checkpoint_head(scan_dir: Path, directory: str, scan_id: str) -> str | None: + relative = f"{directory}/checkpoint-head.json" + try: + (scan_dir / relative).lstat() + except FileNotFoundError: + return None + head = _read_scan_local_json(scan_dir, relative, "Saved worker checkpoint head") + name = head.get("checkpoint") + if not isinstance(name, str) or not re.fullmatch(r"[0-9a-f]{64}\.json", name): + raise ContractError("Saved worker checkpoint head is invalid.") + checkpoint = f"{directory}/checkpoints/{name}" + # A committed head precedes replacement of result.json. Do not fall back to + # that older result if the selected checkpoint cannot be read. + _read_saved_result(scan_dir, checkpoint, scan_id) + return checkpoint + + def _read_saved_parent_result( scan_dir: Path, scan_id: str ) -> tuple[dict[str, Any], dict[str, Any]]: @@ -560,22 +577,40 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: continue if worker["kind"] != "discovery": continue + head = _worker_checkpoint_head(scan_dir, output, scan_id) + if head is not None: + paths[head] = worker["id"] + current_results.add(head) paths[f"{output}/result.json"] = worker["id"] - current_results.add(f"{output}/result.json") + if head is None: + current_results.add(f"{output}/result.json") checkpoints(f"{output}/checkpoints", worker["id"]) attempts = ( Path(output).parent if Path(output).name == "output" else Path(output) ) / "attempts" - for name in _children(scan_dir, attempts.as_posix()): - if re.fullmatch(r"attempt-\d+", name): - archived = (attempts / name).as_posix() - paths[f"{archived}/result.json"] = worker["id"] - checkpoints(f"{archived}/checkpoints", worker["id"]) + archived_attempts = sorted( + ( + name + for name in _children(scan_dir, attempts.as_posix()) + if re.fullmatch(r"attempt-\d+", name) + ), + key=lambda name: int(name.removeprefix("attempt-")), + reverse=True, + ) + for name in archived_attempts: + archived = (attempts / name).as_posix() + archived_head = _worker_checkpoint_head(scan_dir, archived, scan_id) + if archived_head is not None: + paths[archived_head] = worker["id"] + current_results.add(archived_head) + paths[f"{archived}/result.json"] = worker["id"] + checkpoints(f"{archived}/checkpoints", worker["id"]) if worker["result_manifest_path"]: try: current_path = Path(worker["result_manifest_path"]).relative_to(scan_dir).as_posix() paths[current_path] = worker["id"] - current_results.add(current_path) + if head is None: + current_results.add(current_path) except ValueError: warnings.append("Skipped a worker result outside the scan directory.") diff --git a/plugins/codex-security/tests/test_checkpoint_publication_authority.py b/plugins/codex-security/tests/test_checkpoint_publication_authority.py new file mode 100644 index 0000000000..e074a92030 --- /dev/null +++ b/plugins/codex-security/tests/test_checkpoint_publication_authority.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import copy +import json +from argparse import Namespace + +import pytest +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan +from workbench_test_support import write_checkpoint + + +@pytest.mark.parametrize("archived", [False, True], ids=["current", "archived"]) +@pytest.mark.parametrize("has_head", [True, False], ids=["committed-head", "legacy"]) +@pytest.mark.parametrize("complete", [False, True], ids=["checkpoint", "complete"]) +def test_recovery_honors_rejection_committed_before_result_replacement( + workbench_api, workbench_db, publication_scan, archived, has_head, complete +): + scan = publication_scan() + provisional = copy.deepcopy(scan.findings[0]) + provisional["extensions"] = {"candidateId": "candidate-rejected"} + retained = copy.deepcopy(scan.findings[0]) + retained["identity"]["anchor"] = "independent-finding" + retained["extensions"] = {"candidateId": "candidate-retained"} + retained["locations"][0]["startLine"] = 20 + retained["locations"][0]["endLine"] = 21 + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + result_path = add_worker(workbench_db, scan, status="canceled") + if archived: + result_path = result_path.parent / "attempts" / "attempt-1" / "result.json" + result_path.parent.mkdir(parents=True) + previous = { + "scanId": scan.scan_id, + "complete": True, + "findings": [provisional, retained], + "coverage": scan.coverage, + } + result_path.write_text(json.dumps(previous)) + old_checkpoint = write_checkpoint(result_path.parent / "checkpoints", previous) + rejected = { + **previous, + "complete": complete, + "findings": [retained], + "coverage": { + **scan.coverage, + "surfaces": [ + { + "candidateId": "candidate-rejected", + "label": "Validated candidate disposition", + "disposition": "rejected", + "receiptRefs": [], + } + ], + }, + } + checkpoint = write_checkpoint(result_path.parent / "checkpoints", rejected) + if has_head: + (result_path.parent / "checkpoint-head.json").write_text( + json.dumps({"checkpoint": checkpoint.name}) + ) + saved_bytes = {path: path.read_bytes() for path in (result_path, old_checkpoint, checkpoint)} + + stopped = workbench_api["fail_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped."), + )["scan"] + + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert stopped["findingCount"] == len(findings) == (1 if has_head else 2) + assert any(finding["identity"]["anchor"] == "independent-finding" for finding in findings) + assert all(path.read_bytes() == contents for path, contents in saved_bytes.items()) + if has_head: + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert any( + surface.get("candidateId") == "candidate-rejected" + and surface.get("disposition") == "rejected" + for surface in coverage["surfaces"] + ) From af83456a92a08d01f0d1c4e01326c41930732aaa Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:33:24 +0000 Subject: [PATCH 007/251] Preserve Deep Scan inputs and add execution history schema --- .../mcp-app/src/deep-scan/store.ts | 2 + .../mcp-app/src/deep-scan/types.ts | 2 + .../mcp-app/tests/test_deep_scan_store.mjs | 4 +- .../scripts/deep_scan_workbench.py | 40 +++--- .../scripts/workbench_schema.py | 58 ++++++++ .../tests/test_deep_scan_compatibility.py | 4 +- .../tests/test_deep_scan_recovery_settings.py | 125 ++++++++++++++++++ .../codex-security/tests/test_workbench_db.py | 5 +- .../test_workbench_setup_and_migrations.py | 12 +- 9 files changed, 230 insertions(+), 22 deletions(-) create mode 100644 plugins/codex-security/tests/test_deep_scan_recovery_settings.py diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index ed5094e277..71391f8492 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -596,6 +596,8 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { targetPath: requiredString(value.targetPath, "deepScan.targetPath"), scope: requiredString(value.scope, "deepScan.scope"), userContext: optionalString(value.userContext), + model: optionalString(value.model), + reasoningEffort: optionalString(value.reasoningEffort), scanDir: requiredString(value.scanDir, "deepScan.scanDir"), config, dispatchedCount: nonNegativeInteger(value.dispatchedCount, "deepScan.dispatchedCount"), diff --git a/plugins/codex-security/mcp-app/src/deep-scan/types.ts b/plugins/codex-security/mcp-app/src/deep-scan/types.ts index 612ec4c4d7..b284f68089 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/types.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/types.ts @@ -49,6 +49,8 @@ export interface DeepScanRunState { targetPath: string; scope: string; userContext?: string; + model?: string; + reasoningEffort?: string; scanDir: string; config: DeepScanConfig; dispatchedCount: number; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs index afb3d57128..a6b86f374e 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs @@ -879,7 +879,9 @@ function deferred() { function testWorkflowVersionParsing() { const value = stateResult(randomUUID()).deepScan; - const run = parseDeepScan({ deepScan: { ...value, schemaVersion: 1, workflowVersion: "deep-scan-mcp/v1" } }); + const run = parseDeepScan({ deepScan: { ...value, schemaVersion: 1, workflowVersion: "deep-scan-mcp/v1", model: "original-model", reasoningEffort: "high" } }); + assert.equal(run.model, "original-model"); + assert.equal(run.reasoningEffort, "high"); assert.equal(run.schemaVersion, 1); assert.equal(run.workflowVersion, "deep-scan-mcp/v1"); const future = parseDeepScan({ deepScan: { ...value, schemaVersion: 99, workflowVersion: "future/v99" } }); diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 42859ff2c8..4bbc0788a8 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -475,6 +475,8 @@ def _deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, "scanId": run["scan_id"], "targetPath": scan["target_path"], "scope": scan["scope"], + "model": scan["model"], + "reasoningEffort": scan["reasoning_effort"], "userContext": ( run["discovery_user_context"] if "discovery_user_context" in run.keys() @@ -609,9 +611,9 @@ def ensure_deep_scan_run( INSERT INTO deep_scan_runs ( scan_id, schema_version, workflow_version, status, phase, workers, subagents, stop_after_no_new, stop_after_consecutive_errors, - max_discovery_runs, max_time_hours, + max_discovery_runs, max_time_hours, discovery_user_context, created_at, updated_at - ) VALUES (?, 1, ?, 'running', 'setup', ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, 1, ?, 'running', 'setup', ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( scan["id"], @@ -622,6 +624,7 @@ def ensure_deep_scan_run( config["stopAfterConsecutiveErrors"], config["maxDiscoveryRuns"], config["maxTimeHours"], + scan["user_context"], timestamp, timestamp, ), @@ -756,23 +759,10 @@ def begin_deep_scan_for_scan( ) if scan["mode"] != "deep": raise SystemExit("Deep Scan orchestration requires a scan in deep mode.") - model = optional_text(args.model, maximum=200) - reasoning_effort = optional_text(args.reasoning_effort, maximum=32) - if model is not None or reasoning_effort is not None: - connection.execute( - """ - UPDATE scans - SET model = COALESCE(?, model), reasoning_effort = COALESCE(?, reasoning_effort) - WHERE id = ? - """, - (model, reasoning_effort, scan_id), - ) - connection.commit() - existing = connection.execute( - "SELECT scan_id FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) - ).fetchone() if existing is not None: return deep_scan_result(connection, scan_id, start_disposition="joined") + model = optional_text(args.model, maximum=200) + reasoning_effort = optional_text(args.reasoning_effort, maximum=32) config = effective_deep_scan_config(args) workflow_version = optional_text(args.workflow_version, maximum=256) if workflow_version is None: @@ -785,6 +775,22 @@ def begin_deep_scan_for_scan( args.claim_token, error_message="Deep Scan orchestration is owned by another continuation.", ) + existing = connection.execute( + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) + ).fetchone() + if existing is not None: + require_supported_deep_scan(existing) + connection.commit() + return deep_scan_result(connection, scan_id, start_disposition="joined") + if model is not None or reasoning_effort is not None: + connection.execute( + """ + UPDATE scans + SET model = COALESCE(?, model), reasoning_effort = COALESCE(?, reasoning_effort) + WHERE id = ? + """, + (model, reasoning_effort, scan_id), + ) ensure_deep_scan_run(connection, scan, config, workflow_version, now()) connection.commit() except BaseException: diff --git a/plugins/codex-security/scripts/workbench_schema.py b/plugins/codex-security/scripts/workbench_schema.py index 66baa2b4f8..37758b1355 100644 --- a/plugins/codex-security/scripts/workbench_schema.py +++ b/plugins/codex-security/scripts/workbench_schema.py @@ -867,6 +867,64 @@ ); """, ), + ( + 44, + "preserve original deep scan discovery context", + """ + ALTER TABLE deep_scan_runs ADD COLUMN discovery_user_context TEXT; + UPDATE deep_scan_runs + SET discovery_user_context = ( + SELECT user_context FROM scans WHERE scans.id = deep_scan_runs.scan_id + ) + WHERE workflow_version IN ( + 'deep-security-scan/v1', 'deep-scan-mcp/v1' + ); + """, + ), + ( + 45, + "retain deep scan attempts and exact merge inputs", + """ + CREATE TABLE deep_scan_attempts ( + scan_id TEXT NOT NULL REFERENCES deep_scan_runs(scan_id) ON DELETE CASCADE, + worker_id TEXT NOT NULL REFERENCES deep_scan_workers(id) ON DELETE CASCADE, + attempt INTEGER NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + end_reason TEXT, + error_message TEXT, + accepted_result_path TEXT, + accepted_result_sha256 TEXT, + receipt_json TEXT, + PRIMARY KEY (worker_id, attempt) + ); + + CREATE TABLE deep_scan_attempt_sessions ( + scan_id TEXT NOT NULL REFERENCES deep_scan_runs(scan_id) ON DELETE CASCADE, + worker_id TEXT NOT NULL, + attempt INTEGER NOT NULL, + sdk_thread_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + PRIMARY KEY (worker_id, attempt, sdk_thread_id), + FOREIGN KEY (worker_id, attempt) + REFERENCES deep_scan_attempts(worker_id, attempt) ON DELETE CASCADE + ); + + CREATE TABLE deep_scan_merge_claims ( + worker_id TEXT PRIMARY KEY REFERENCES deep_scan_workers(id) ON DELETE CASCADE, + scan_id TEXT NOT NULL REFERENCES deep_scan_runs(scan_id) ON DELETE CASCADE, + previous_worker_id TEXT, + previous_result_path TEXT, + previous_result_sha256 TEXT, + receipt_json TEXT + ); + + ALTER TABLE deep_scan_dedup_inputs ADD COLUMN result_manifest_path TEXT; + ALTER TABLE deep_scan_dedup_inputs ADD COLUMN result_manifest_sha256 TEXT; + ALTER TABLE deep_scan_dedup_inputs ADD COLUMN attempt INTEGER; + """, + ), ) diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py index 2a26d24502..faf97dc056 100644 --- a/plugins/codex-security/tests/test_deep_scan_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_compatibility.py @@ -142,7 +142,9 @@ def test_reader_honors_original_context_when_present(tmp_path: Path, original: s str(tmp_path / "scans"), )["deepScan"] with sqlite3.connect(state / "workbench.sqlite3") as connection: - connection.execute("ALTER TABLE deep_scan_runs ADD COLUMN discovery_user_context TEXT") + columns = {row[1] for row in connection.execute("PRAGMA table_info(deep_scan_runs)")} + if "discovery_user_context" not in columns: + connection.execute("ALTER TABLE deep_scan_runs ADD COLUMN discovery_user_context TEXT") connection.execute("UPDATE deep_scan_runs SET discovery_user_context = ?", (original,)) connection.execute("UPDATE scans SET user_context = 'Later discussion'") observed = run_workbench( diff --git a/plugins/codex-security/tests/test_deep_scan_recovery_settings.py b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py new file mode 100644 index 0000000000..ba61361257 --- /dev/null +++ b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py @@ -0,0 +1,125 @@ +"""Original discovery input and observation remain stable across reconstruction.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from workbench_test_support import run_workbench + + +@pytest.mark.parametrize("original_context", [None, "Audit the original parser"]) +def test_reconstruction_preserves_discovery_input_settings_and_deadline( + tmp_path: Path, + original_context: str | None, +) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + codex_home = tmp_path / "home" + config = codex_home / "codex-security" / "config.toml" + config.parent.mkdir(parents=True) + config.write_text("[deep_scan]\nworkers = 2\nmax_time_hours = 2.5\n") + environment = {"CODEX_HOME": str(codex_home)} + begun = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + "--model", + "original-model", + "--reasoning-effort", + "high", + *(["--user-context-stdin"] if original_context is not None else []), + input_text=original_context, + environment=environment, + )["deepScan"] + scan_id = str(begun["scanId"]) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute("UPDATE scans SET user_context = 'Later discussion'") + connection.execute("UPDATE deep_scan_runs SET updated_at = '2000-01-01T00:00:00Z'") + before = "\n".join(connection.iterdump()) + config.write_text("[deep_scan]\nworkers = 8\nmax_time_hours = 12\n") + joined = run_workbench( + state, + "begin-deep-scan", + "--scan-id", + scan_id, + "--thread-id", + "fixture-thread", + "--model", + "observer-model", + "--reasoning-effort", + "low", + environment=environment, + )["deepScan"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + assert "\n".join(connection.iterdump()) == before + assert connection.execute("SELECT model, reasoning_effort FROM scans").fetchone() == ( + "original-model", + "high", + ) + recovered = run_workbench( + state, + "claim-deep-scan-coordinator", + "--scan-id", + scan_id, + "--thread-id", + "fixture-thread", + environment=environment, + )["deepScan"] + for run in (joined, recovered): + assert run["model"] == "original-model" + assert run["reasoningEffort"] == "high" + assert run["userContext"] == original_context + assert run["createdAt"] == begun["createdAt"] + assert run["config"] == begun["config"] + assert run["workflowVersion"] == begun["workflowVersion"] + + +def test_supported_old_run_snapshots_context_on_upgrade(tmp_path: Path) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + begun = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + "--user-context", + "Legacy context", + )["deepScan"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute("ALTER TABLE deep_scan_runs DROP COLUMN discovery_user_context") + connection.execute("DELETE FROM schema_migrations WHERE version = 44") + upgraded = run_workbench( + state, + "get-deep-scan", + "--scan-id", + str(begun["scanId"]), + "--thread-id", + "fixture-thread", + )["deepScan"] + assert upgraded["userContext"] == "Legacy context" + assert upgraded["config"] == begun["config"] + assert upgraded["createdAt"] == begun["createdAt"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute("UPDATE scans SET user_context = 'Later discussion'") + observed = run_workbench( + state, + "get-deep-scan", + "--scan-id", + str(begun["scanId"]), + "--thread-id", + "fixture-thread", + )["deepScan"] + assert observed["userContext"] == "Legacy context" diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index f68ccdef94..a9196ec699 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -55,6 +55,9 @@ BUDGET_WARNING = "Scan stopped: estimated cost $0.00625 exceeded the $0.005 cost limit." EXPECTED_TABLES = { + "deep_scan_attempts", + "deep_scan_attempt_sessions", + "deep_scan_merge_claims", "deep_scan_dedup_inputs", "deep_scan_runs", "deep_scan_workers", @@ -1042,7 +1045,7 @@ def test_workbench_persists_progress_and_indexes_completed_findings(tmp_path: Pa ) } assert tables == EXPECTED_TABLES - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (41,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (43,) assert connection.execute("SELECT COUNT(*) FROM findings").fetchone() == (1,) assert connection.execute("SELECT COUNT(*) FROM finding_locations").fetchone() == (1,) diff --git a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py index 679ee77c1d..a0c79f055b 100644 --- a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py +++ b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py @@ -405,7 +405,7 @@ def test_workbench_serializes_concurrent_first_run_migrations(tmp_path: Path) -> {"databasePath": str(state_dir / "workbench.sqlite3")}, ] with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (41,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (43,) @pytest.mark.parametrize("previous_history", ["main", "comparison-preview"]) @@ -867,6 +867,8 @@ def test_workbench_creates_single_final_schema(tmp_path: Path) -> None: (39, "store dedupe checkpoint bindings in columns"), (40, "index finding identity and comparison history"), (41, "checkpoint finding severity assessments"), + (44, "preserve original deep scan discovery context"), + (45, "retain deep scan attempts and exact merge inputs"), ] assert {row[1] for row in connection.execute("PRAGMA table_info(workspaces)")} >= { "diff_target_kind", @@ -969,7 +971,7 @@ def test_workbench_upgrades_preexisting_database(tmp_path: Path) -> None: connection.execute("ALTER TABLE scans DROP COLUMN handoff_claim_token") run_workbench(state_dir, "database-info") with sqlite3.connect(database) as connection: - assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (41,) + assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (45,) assert {row[1] for row in connection.execute("PRAGMA table_info(scans)")} >= { "handoff_claimed_at", "handoff_claim_token", @@ -1996,6 +1998,8 @@ def test_workbench_upgrades_released_database_schema(tmp_path: Path) -> None: (39, "store dedupe checkpoint bindings in columns"), (40, "index finding identity and comparison history"), (41, "checkpoint finding severity assessments"), + (44, "preserve original deep scan discovery context"), + (45, "retain deep scan attempts and exact merge inputs"), ] assert "capability_preflight_json" in { row[1] for row in connection.execute("PRAGMA table_info(workspaces)") @@ -2079,6 +2083,8 @@ def test_workbench_upgrades_pre_release_phase_progress_migration(tmp_path: Path) (39, "store dedupe checkpoint bindings in columns"), (40, "index finding identity and comparison history"), (41, "checkpoint finding severity assessments"), + (44, "preserve original deep scan discovery context"), + (45, "retain deep scan attempts and exact merge inputs"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") @@ -2170,6 +2176,8 @@ def test_workbench_upgrades_pre_release_preflight_progress_migration(tmp_path: P (39, "store dedupe checkpoint bindings in columns"), (40, "index finding identity and comparison history"), (41, "checkpoint finding severity assessments"), + (44, "preserve original deep scan discovery context"), + (45, "retain deep scan attempts and exact merge inputs"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") From 99a6074703c4786faa5adf26789a1ca6310bf2fa Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:28:39 +0000 Subject: [PATCH 008/251] test(plugin): define shared audit acceptance contract --- .../tests/test_audit_acceptance_contract.mjs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs diff --git a/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs new file mode 100644 index 0000000000..01ca056993 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, readdir, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + stdin: { + contents: `export * from "./src/artifact-scan-draft.ts"; + export * from "./src/deep-scan/artifact-validation.ts"; + export * from "./src/deep-scan/artifacts.ts";`, + resolveDir: path.resolve(import.meta.dirname, ".."), + }, + bundle: true, format: "esm", platform: "node", write: false, +}); +const { + createDeepScanArtifacts, recordCodexSecurityScanDraft, + recordCodexSecurityWorkerScanDraft, validateDiscoveryArtifacts, +} = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`); + +const scanId = "811aef98-3709-4c2d-8b7a-742977521865"; +const finding = { + ruleId: "path-traversal.archive-extraction", title: "Unsafe archive extraction", + summary: "An archive entry reaches a filesystem write.", + severity: { level: "high" }, confidence: { level: "high", rationale: "Source review." }, + taxonomy: { category: "path-traversal", cwe: ["CWE-22"] }, + locations: [{ path: "src/extract.py", startLine: 4 }], + remediation: "Validate the resolved output path before writing.", + provenance: { source: "local_plugin", candidateId: "archive-entry" }, +}; + +for (const completeness of ["complete", "partial", "unknown"]) { + test(`Standard and Deep retain accepted semantic evidence with ${completeness} coverage`, async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), "audit-contract-"))); + try { + const repository = path.join(root, "repository"); + const scanDir = path.join(root, "scan"); + const artifacts = createDeepScanArtifacts(scanDir); + const workerRoot = path.join(artifacts.workersRoot, "discovery-0001", "output"); + await Promise.all([mkdir(repository), mkdir(workerRoot, { recursive: true })]); + const semantic = { + scanId, complete: true, + threatModel: { summary: "An untrusted caller supplies archive entries." }, + findings: [finding], + coverage: { + completeness, + surfaces: [{ id: "archive", label: "Archive extraction", disposition: "reported", receiptRefs: [] }], + explicitExclusions: [], + deferred: completeness === "complete" ? [] : [{ id: "deployment-controls", reason: "Deployment controls remain unverified." }], + }, + }; + const standard = { + root: scanDir, repoRoot: repository, layout: "scan", scanId, + mode: "standard", status: "running", scope: ".", + targetContract: { + target: { + allowedKinds: ["directory_snapshot"], targetId: "target_example", displayName: "example", + requiredSnapshotDigest: `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`, + }, + scope: { requiredIncludePaths: ["."], requiredExcludePaths: [] }, diffTarget: null, + }, + }; + const worker = { root: workerRoot, repoRoot: repository, layout: "worker", scanId }; + const checkpoint = { ...semantic, complete: false }; + await recordCodexSecurityScanDraft(standard, checkpoint); + await recordCodexSecurityWorkerScanDraft(worker, checkpoint); + await assert.rejects(validateDiscoveryArtifacts(artifacts, path.join(workerRoot, "result.json"), scanId), /checkpoint/); + assert.equal(JSON.parse(await readFile(path.join(scanDir, "scan-manifest.json"))).scan.complete, false); + const standardWrite = await recordCodexSecurityScanDraft(standard, semantic); + const deepWrite = await recordCodexSecurityWorkerScanDraft(worker, semantic); + assert.equal(standardWrite.status, "draft_written"); + assert.equal(deepWrite.status, "draft_written"); + const accepted = await validateDiscoveryArtifacts(artifacts, path.join(workerRoot, "result.json"), scanId); + const manifest = JSON.parse(await readFile(path.join(scanDir, "scan-manifest.json"))); + const findings = JSON.parse(await readFile(path.join(scanDir, "findings.json"))); + const coverage = JSON.parse(await readFile(path.join(scanDir, "coverage.json"))); + assert.deepEqual(accepted.findings, [finding]); + for (const [key, value] of Object.entries(finding)) { + assert.deepEqual(findings.findings[0][key], value); + } + assert.ok(findings.findings[0].identity.anchor); + assert.deepEqual(accepted.threatModel, manifest.scan.threatModel); + for (const field of ["completeness", "surfaces", "explicitExclusions", "deferred"]) { + assert.deepEqual(coverage[field], accepted.coverage[field]); + } + assert.equal(accepted.scanId, scanId); + assert.equal(manifest.scan.sealedAt, undefined); + assert.equal(manifest.scan.artifacts, undefined); + assert.equal((await readdir(workerRoot)).includes("scan-manifest.json"), false); + assert.equal((await readdir(scanDir)).includes("report.md"), false); + await assert.rejects(validateDiscoveryArtifacts(artifacts, path.join(workerRoot, "result.json"), "b4c84677-5aaf-410c-88d2-3e97e6f8c4d8"), /scan/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} From 7f6f3858a9bd02eab39fcba6d5ce12c266cfaeb6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:29:43 +0000 Subject: [PATCH 009/251] fix(plugin): keep Deep publication failures replayable --- .../codex-security/scripts/workbench_db.py | 4 ++- .../test_deep_scan_successful_publication.py | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 161756ee0c..b53a506ecf 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1547,8 +1547,10 @@ def add_warning() -> None: wrote = True manifest, findings, _ = _write_prepared_scan_finalization(prepared) except ContractError as exc: - if wrote or ( + # Replay a validated Deep aggregate after an output write fails. + if (wrote and scan["mode"] != "deep") or ( scan["mode"] == "deep" + and not wrote and not already_sealed and not isinstance(exc, RecoverableContractError) ): diff --git a/plugins/codex-security/tests/test_deep_scan_successful_publication.py b/plugins/codex-security/tests/test_deep_scan_successful_publication.py index 0c7a3ce753..b56839a323 100644 --- a/plugins/codex-security/tests/test_deep_scan_successful_publication.py +++ b/plugins/codex-security/tests/test_deep_scan_successful_publication.py @@ -492,3 +492,32 @@ def test_standard_publication_preserves_deliberately_partial_coverage( complete(workbench_api, workbench_db, scan) assert_published_aggregate(scan) + + +def test_deep_publication_write_failure_keeps_original_terminal_cause( + workbench_api, workbench_db, publication_scan, monkeypatch +): + scan = publication_scan() + finalizer_globals = workbench_api["_write_prepared_scan_finalization"].__globals__ + write_bytes = finalizer_globals["write_scan_local_bytes"] + + def fail_report(scan_dir, relative_path, payload, **kwargs): + if relative_path == "report.md": + raise finalizer_globals["ContractError"]("Synthetic report write interruption") + return write_bytes(scan_dir, relative_path, payload, **kwargs) + + with monkeypatch.context() as patch: + patch.setitem(finalizer_globals, "write_scan_local_bytes", fail_report) + with pytest.raises(SystemExit, match="Synthetic report write interruption"): + complete(workbench_api, workbench_db, scan) + + assert ( + workbench_db.execute("SELECT status FROM scans WHERE id = ?", (scan.scan_id,)).fetchone()[0] + == "running" + ) + run = workbench_db.execute( + "SELECT status, terminal_reason FROM deep_scan_runs WHERE scan_id = ?", (scan.scan_id,) + ).fetchone() + assert tuple(run) == ("succeeded", "saturated") + assert complete(workbench_api, workbench_db, scan)["progress"]["status"] == "complete" + assert_published_aggregate(scan) From 697d68978baed64f76ea0f8bf8aeadc2513e6e26 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:28:08 +0000 Subject: [PATCH 010/251] test: replay accepted sources across reducer batches --- .../tests/fixtures/accepted_source_bank.mjs | 180 ++++++++++++++++++ .../tests/fixtures/accepted_source_replay.mjs | 105 ++++++++++ .../tests/test_accepted_source_replay.mjs | 69 +++++++ 3 files changed, 354 insertions(+) create mode 100644 plugins/codex-security/mcp-app/tests/fixtures/accepted_source_bank.mjs create mode 100644 plugins/codex-security/mcp-app/tests/fixtures/accepted_source_replay.mjs create mode 100644 plugins/codex-security/mcp-app/tests/test_accepted_source_replay.mjs diff --git a/plugins/codex-security/mcp-app/tests/fixtures/accepted_source_bank.mjs b/plugins/codex-security/mcp-app/tests/fixtures/accepted_source_bank.mjs new file mode 100644 index 0000000000..0fed15b231 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/fixtures/accepted_source_bank.mjs @@ -0,0 +1,180 @@ +// Synthetic accepted artifacts. Expected fixes are independent of display identities. +export const scanId = "7fc17317-9594-49e0-b06a-d72fd7e14bba"; +export const bankVersion = "accepted-source-bank/v1"; + +export const fixes = { + owner: "Check document ownership before returning document contents.", + path: "Resolve archive paths and reject entries outside the extraction directory.", + sql: "Bind the search term as a SQL parameter.", + html: "HTML-encode the search term before rendering the response.", +}; + +function finding(id, remediation, extra = {}) { + return { + ruleId: "synthetic." + id, + identity: { anchor: id }, + title: "Request boundary " + id, + summary: "A request-controlled value crosses an unchecked boundary.", + severity: { level: "high" }, + confidence: { + level: "high", + rationale: "A synthetic local test reaches the sink.", + }, + taxonomy: { category: "input-validation", cwe: ["CWE-20"] }, + locations: [{ path: "src/routes.py", startLine: 12, endLine: 14 }], + remediation, + provenance: { source: "local_plugin" }, + ...extra, + }; +} + +function coverage(completeness = "complete", deferred = []) { + return { completeness, surfaces: [], explicitExclusions: [], deferred }; +} + +function worker(id, findings, scanCoverage = coverage()) { + return { + id, + result: { scanId, complete: true, findings, coverage: scanCoverage }, + }; +} + +export const workers = [ + worker( + "worker-owner", + [ + finding("owner", fixes.owner, { + summary: + "An authenticated user can read another user's document by changing its ID.", + validation: { + summary: "The ownership check is absent; authentication is required.", + }, + }), + ], + coverage("partial", [ + { + candidateId: "candidate-1", + reason: "Check the alternate document handler.", + paths: ["src/alternate.py"], + }, + ]), + ), + worker( + "worker-path", + [finding("path", fixes.path)], + coverage("partial", [ + { + candidateId: "candidate-1", + reason: "Check symlink extraction separately.", + paths: ["src/archive.py"], + }, + ]), + ), + worker("worker-owner-duplicate", [ + finding("owner-copy", fixes.owner, { + summary: "Document contents may be reachable without authentication.", + severity: { level: "critical" }, + confidence: { + level: "low", + rationale: "Authentication middleware was not examined.", + }, + validation: { + summary: + "The unauthenticated claim is untested; ownership check is absent.", + }, + }), + ]), + worker("worker-bundled", [ + finding("search-bundle", fixes.sql + " " + fixes.html, { + summary: + "The search route interpolates the query into SQL and separately into HTML.", + locations: [{ path: "src/search.py", startLine: 5, endLine: 9 }], + }), + ]), +]; + +export const sourceGroups = { + "worker-owner:0": "owner", + "worker-path:0": "path", + "worker-owner-duplicate:0": "owner", + "worker-bundled:0": "search-bundle", +}; + +export const sourceFixes = { + "worker-owner:0": ["owner"], + "worker-path:0": ["path"], + "worker-owner-duplicate:0": ["owner"], + "worker-bundled:0": ["sql", "html"], +}; + +// This history is distinct from the immutable accepted terminal bank above. +// A newer rejection is authoritative for this logical worker's final result. +export const rejectionHistory = { + workerId: "worker-rejected", + earlier: worker("worker-rejected", [finding("safe-query", fixes.sql)]).result, + latest: worker("worker-rejected", [], { + ...coverage(), + surfaces: [ + { + label: "Search SQL", + disposition: "rejected", + notes: + "The driver binds parameters; the earlier interpolation claim was disproved.", + }, + ], + }).result, +}; + +export function permutations(items) { + if (!items.length) return [[]]; + return items.flatMap((item, index) => + permutations(items.filter((_, i) => i !== index)).map((rest) => [ + item, + ...rest, + ]), + ); +} + +export function partitions(items) { + if (!items.length) return [[]]; + return items.flatMap((_, index) => + partitions(items.slice(index + 1)).map((rest) => [ + items.slice(0, index + 1), + ...rest, + ]), + ); +} + +export function originals(inputs) { + const sources = new Map(); + for (const current of inputs.previous?.findings ?? []) { + for (const source of current.provenance.sourceFindings ?? []) + sources.set(source.id, source.finding); + } + for (const discovery of inputs.discoveries) { + discovery.result.findings.forEach((value, i) => + sources.set(`${discovery.workerId}:${i}`, value), + ); + } + return sources; +} + +// Scripted proposals isolate host reconciliation from model variability. +export function proposal(inputs, groupFor = (id) => sourceGroups[id]) { + const grouped = new Map(); + for (const [id, value] of originals(inputs)) { + const group = groupFor(id); + if (!grouped.has(group)) grouped.set(group, { value, refs: [] }); + grouped.get(group).refs.push(id); + } + return { + scanId, + complete: true, + findings: [...grouped].map(([group, { value, refs }]) => ({ + ...structuredClone(value), + ruleId: "synthetic." + group, + identity: { anchor: group }, + provenance: { source: "local_plugin", sourceFindingIds: refs }, + })), + }; +} diff --git a/plugins/codex-security/mcp-app/tests/fixtures/accepted_source_replay.mjs b/plugins/codex-security/mcp-app/tests/fixtures/accepted_source_replay.mjs new file mode 100644 index 0000000000..26b6a61632 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/fixtures/accepted_source_replay.mjs @@ -0,0 +1,105 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { proposal, scanId, workers } from "./accepted_source_bank.mjs"; + +export async function loadReducer() { + const bundled = await build({ + bundle: true, + entryPoints: [ + fileURLToPath( + new URL("../../src/artifact-deep-reducer.ts", import.meta.url), + ), + ], + format: "esm", + platform: "node", + write: false, + }); + return import( + "data:text/javascript;base64," + + Buffer.from(bundled.outputFiles[0].contents).toString("base64") + ); +} + +export async function prepareReplay(root, bank = workers) { + const workerRoot = path.join(root, "artifacts", "deep_discovery", "workers"); + const records = new Map(); + for (const [i, worker] of bank.entries()) { + const output = path.join(workerRoot, worker.id, "output"); + await mkdir(output, { recursive: true }); + const resultPath = path.join(output, "result.json"); + await writeFile(resultPath, JSON.stringify(worker.result) + "\n"); + records.set(worker.id, { + id: worker.id, + resultPath, + completionSequence: i + 1, + }); + } + return records; +} + +export async function runBatch( + reducer, + root, + records, + ids, + index, + previous, + propose = proposal, +) { + const output = path.join( + root, + "artifacts", + "deep_discovery", + "dedup", + `dedup-${index}`, + "output", + ); + await mkdir(output, { recursive: true }); + const context = { + root: output, + repoRoot: root, + scanId, + layout: "reducer", + deepReducer: { + scanRoot: root, + claimedWorkers: ids.map((id) => records.get(id)), + ...(previous ? { previousReducerResultPath: previous } : {}), + }, + }; + const inputs = await reducer.getCodexSecurityDeepReducerInputs(context); + const submitted = propose(inputs); + const receipt = await reducer.recordCodexSecurityDeepReduction( + context, + submitted, + ); + const resultPath = path.join(output, "result.json"); + return { + inputs, + submitted, + receipt, + resultPath, + result: JSON.parse(await readFile(resultPath, "utf8")), + }; +} + +export async function replay(reducer, root, batches, propose = proposal) { + const records = await prepareReplay(root); + const steps = []; + let previous; + for (const [index, batch] of batches.entries()) { + const step = await runBatch( + reducer, + root, + records, + batch, + index, + previous, + propose, + ); + steps.push(step); + previous = step.resultPath; + } + return { steps, result: steps.at(-1).result }; +} diff --git a/plugins/codex-security/mcp-app/tests/test_accepted_source_replay.mjs b/plugins/codex-security/mcp-app/tests/test_accepted_source_replay.mjs new file mode 100644 index 0000000000..2b87097731 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_accepted_source_replay.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { + partitions, + permutations, + sourceFixes, + workers, +} from "./fixtures/accepted_source_bank.mjs"; +import { loadReducer, replay } from "./fixtures/accepted_source_replay.mjs"; + +test("accepted sources survive completion orders and eligible batch partitions", async () => { + const reducer = await loadReducer(); + const root = await realpath( + await mkdtemp(path.join(tmpdir(), "accepted-source-replay-")), + ); + let count = 0; + try { + for (const order of permutations(workers.map((worker) => worker.id))) { + for (const batches of partitions(order)) { + // Ordinary rolling runs require two successes for the first merge. + if (batches[0].length < 2) continue; + const runRoot = path.join(root, String(count++)); + const { result, steps } = await replay(reducer, runRoot, batches); + assert.equal(result.findings.length, 3); + const refs = result.findings.flatMap( + (finding) => finding.provenance.sourceFindingIds, + ); + assert.deepEqual(refs.toSorted(), Object.keys(sourceFixes).toSorted()); + for (const step of steps) { + assert.deepEqual( + step.receipt.consumedWorkerIds, + batches[steps.indexOf(step)], + ); + } + for (const finding of result.findings) { + for (const source of finding.provenance.sourceFindings) { + const [workerId, index] = source.id.split(":"); + const original = workers.find((worker) => worker.id === workerId) + .result.findings[Number(index)]; + assert.deepEqual(source.finding, original); + } + } + for (const worker of workers) { + const persisted = JSON.parse( + await readFile( + path.join( + runRoot, + "artifacts", + "deep_discovery", + "workers", + worker.id, + "output", + "result.json", + ), + "utf8", + ), + ); + assert.deepEqual(persisted, worker.result); + } + } + } + assert.equal(count, 96); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From 844b86df97435fbcc5320023444a15df953eb66f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:48:38 +0000 Subject: [PATCH 011/251] test: compare migration history across coordinator claims --- plugins/codex-security/tests/test_workbench_deep_scan.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/tests/test_workbench_deep_scan.py b/plugins/codex-security/tests/test_workbench_deep_scan.py index c58a274e27..2da2ca020f 100644 --- a/plugins/codex-security/tests/test_workbench_deep_scan.py +++ b/plugins/codex-security/tests/test_workbench_deep_scan.py @@ -279,7 +279,7 @@ def claim() -> dict[str, object]: return claim_deep_scan_coordinator(state_dir, codex_home, scan_id) with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: - assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (41,) + migrations = connection.execute("SELECT * FROM schema_migrations ORDER BY version").fetchall() assert claim()["deepScan"]["coordinatorGeneration"] == 2 assert claim()["coordinatorDisposition"] == "observing" expire_deep_scan_coordinator(state_dir, scan_id) @@ -311,6 +311,11 @@ def claim() -> dict[str, object]: assert sum(result["coordinatorDisposition"] == "adopted" for result in results) == 1 assert sum(result["coordinatorDisposition"] == "observing" for result in results) == 3 assert {result["deepScan"]["coordinatorGeneration"] for result in results} == {3} + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + assert ( + connection.execute("SELECT * FROM schema_migrations ORDER BY version").fetchall() + == migrations + ) def test_legacy_generation_with_active_worker_observes_grace_then_adopts(tmp_path: Path) -> None: From 468317553ebf81638feb086401d887d3e9a88791 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:37:51 +0000 Subject: [PATCH 012/251] Fence Deep publication to the current coordinator and aggregate --- plugins/codex-security/mcp-app/server.ts | 4 +- .../mcp-app/src/artifact-scan-draft.ts | 12 +- .../mcp-app/src/deep-scan/coordinator.ts | 10 +- .../tests/test_artifact_scan_draft.mjs | 8 + .../tests/test_deep_scan_coordinator.mjs | 11 +- .../test_deep_scan_store_integration.mjs | 5 +- .../scripts/workbench_saved_results.py | 50 ++++-- .../test_deep_scan_publication_authority.py | 84 ++++++++++ .../test_deep_scan_publication_replay.py | 146 ++++++++++++++++++ 9 files changed, 314 insertions(+), 16 deletions(-) create mode 100644 plugins/codex-security/tests/test_deep_scan_publication_authority.py create mode 100644 plugins/codex-security/tests/test_deep_scan_publication_replay.py diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index a21fbb6270..2c35f3c7d9 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -765,7 +765,7 @@ export function createCodexSecurityServer(): McpServer { log: logDeepScanEvent, handoffClaimToken, threadId, - onComplete: async (draft, signal) => { + onComplete: async (draft, signal, publication) => { const context = await createScanArtifactContext( begun.run.scanId, runWorkbench, @@ -779,7 +779,7 @@ export function createCodexSecurityServer(): McpServer { await recordCodexSecurityScanDraftViaWorkbench(context, { ...draft, ...(handoffClaimToken === undefined ? {} : { handoffClaimToken }) - }, runWorkbench, signal); + }, runWorkbench, signal, publication); }, onStopped: async (run) => { await runWorkbench([ diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index 4c560822cd..125a02ce9e 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -55,6 +55,12 @@ interface PreparedScanDraft { coverage: JsonObject; } +/** Host-selected Deep aggregate, separate from model-authored draft fields. */ +export interface DeepScanPublication { + coordinatorGeneration?: number; + resultPath: string | null; +} + type PublishScanDraft = ( draft: PreparedScanDraft, expectedDigest: string | undefined, @@ -165,6 +171,7 @@ export async function recordCodexSecurityScanDraftViaWorkbench( input: ScanDraftInput, runWorkbench: RunArtifactWorkbench, signal?: AbortSignal, + publication?: DeepScanPublication, ): Promise { return recordCodexSecurityScanDraft( context, @@ -184,7 +191,10 @@ export async function recordCodexSecurityScanDraftViaWorkbench( const { handoffClaimToken: _claim, ...snapshot } = checkpoint; await Promise.all([ replaceArtifactJson(checkpointPath, snapshot), - replaceArtifactJson(draftPath, draft), + replaceArtifactJson(draftPath, { + ...draft, + ...(publication === undefined ? {} : { deepScanPublication: publication }), + }), ]); const arguments_ = [ "write-scan-draft", diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index b3eff3a045..ffa641d978 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -8,6 +8,7 @@ import { import { validateDiscoveryArtifacts, validateReducerArtifacts, type DeepReductionInput } from "./artifact-validation.js"; import { scanDraftInputSchema, + type DeepScanPublication, type ScanDraftInput } from "../artifact-scan-draft.js"; import type { DeepScanArtifacts } from "./artifacts.js"; @@ -54,6 +55,7 @@ interface SchedulerResult { mergedWorkerIds: string[]; reducers: AcceptedReducer[]; result?: DeepReductionInput; + resultPath?: string; } type CoordinatorPhase = "setup" | "discovery" | "terminal"; @@ -72,7 +74,7 @@ export interface CoordinatorOptions { threadId?: string; heartbeatIntervalMs?: number; observeReplacement?: (run: DeepScanRunState) => Promise; - onComplete?: (draft: ScanDraftInput, signal: AbortSignal) => Promise; + onComplete?: (draft: ScanDraftInput, signal: AbortSignal, publication: DeepScanPublication) => Promise; onStopped?: (run: DeepScanRunState) => Promise; } @@ -293,7 +295,10 @@ export class DeepScanCoordinator { if (draft.scanId !== this.state.scanId) { throw new Error("Deep Scan aggregate does not match its authoritative scan identity."); } - await this.options.onComplete?.(draft, this.publicationAbortController.signal); + await this.options.onComplete?.(draft, this.publicationAbortController.signal, { + coordinatorGeneration: this.state.coordinatorGeneration, + resultPath: schedulerResult.resultPath ?? null, + }); if (this.canceled || this.externallyFailed) return; this.state = await this.finishWithReplay(schedulerResult); if (this.canceled || this.externallyFailed) return; @@ -910,6 +915,7 @@ export class DeepScanCoordinator { mergedWorkerIds: unique(mergedDiscoveries.map((worker) => worker.id)), reducers: reducerOutcomes, result: latestResult, + resultPath: previousReducerResultPath, }; } diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_scan_draft.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_scan_draft.mjs index 72f815fc30..aa5859ad23 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_scan_draft.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_scan_draft.mjs @@ -591,6 +591,10 @@ try { const obsoleteCheckpointPath = path.join(deepParentRoot, "checkpoints", "obsolete.json"); await writeFile(obsoleteCheckpointPath, "{malformed obsolete checkpoint\n"); let deepWorkbenchWrites = 0; + const deepPublication = { + coordinatorGeneration: 3, + resultPath: path.join(deepParentRoot, "workers", "reducer", "result.json"), + }; await recordCodexSecurityScanDraftViaWorkbench( deepParentContext, acceptedDeepDraft, @@ -603,11 +607,15 @@ try { const checkpointPath = arguments_[arguments_.indexOf("--checkpoint-path") + 1]; const staged = JSON.parse(await readFile(draftPath, "utf8")); const stagedCheckpoint = JSON.parse(await readFile(checkpointPath, "utf8")); + assert.deepEqual(staged.deepScanPublication, deepPublication); + assert.equal(stagedCheckpoint.deepScanPublication, undefined); assert.deepEqual(staged.findings, acceptedDeepFindings); assert.deepEqual(staged.coverage, acceptedDeepCoverage); assert.deepEqual(stagedCheckpoint.findings, acceptedDeepDraft.findings); assert.equal(stagedCheckpoint.handoffClaimToken, undefined); }, + undefined, + deepPublication, ); assert.equal(deepWorkbenchWrites, 1, "terminal Deep drafts still publish through the workbench lock despite obsolete malformed checkpoints"); assert.deepEqual(await readdir(path.join(deepParentRoot, "drafts")), []); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs index 01c37dbaaa..167e5cffe7 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs @@ -27,9 +27,11 @@ const { const temporaryRoots = []; async function testCappedQueueAndSerialDedup() { const fixture = await fixtureRun({ workers: 3, subagents: 2, stopAfterNoNew: 10, maxDiscoveryRuns: 5 }); + fixture.run.coordinatorGeneration = 3; const store = new FakeStore(fixture.run); const executor = new FakeExecutor({ dedupNewFindings: [1, 0] }); const completedDrafts = []; + const published = []; const coordinator = new DeepScanCoordinator({ run: fixture.run, store, @@ -39,7 +41,10 @@ async function testCappedQueueAndSerialDedup() { retryDelaysMs: [1, 3, 9], clock: immediateClock, handoffClaimToken: "claim-fixture", - onComplete: async (draft) => completedDrafts.push(structuredClone(draft)) + onComplete: async (draft, _signal, publication) => { + completedDrafts.push(structuredClone(draft)); + published.push(publication); + } }); coordinator.start(); const terminal = await coordinator.wait(undefined, 5_000); @@ -68,6 +73,10 @@ async function testCappedQueueAndSerialDedup() { const reducerWorkers = [...store.workers.values()].filter((worker) => ( worker.kind === "dedup" && worker.status === "succeeded" )); + assert.deepEqual(published, [{ + coordinatorGeneration: 3, + resultPath: reducerWorkers.at(-1).resultManifestPath, + }]); const finalReducerResult = JSON.parse(await readFile( reducerWorkers.at(-1).resultManifestPath, "utf8" diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.mjs index a933849194..589f7874fa 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.mjs @@ -91,7 +91,10 @@ async function testRecoveredPublicationRejectsLateFailure() { paths: ["fixture.py"], }], }, - }, runWorkbench); + }, runWorkbench, undefined, { + coordinatorGeneration: claim.run.coordinatorGeneration, + resultPath: null, + }); await runWorkbench([ "cancel-scan", "--scan-id", run.scanId, "--thread-id", "publication-failure-owner", diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 51a89f6dcb..e7fdc2847c 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1418,6 +1418,41 @@ def save_scan_artifact(db: Any, connection: Any, args: Any) -> dict[str, Any]: return {"scanId": scan_id, "path": str(scan_dir / output)} +def _read_staged_scan_draft(scan_dir: Path, draft_path: str) -> dict[str, Any]: + try: + relative = Path(draft_path).relative_to(scan_dir).as_posix() + except ValueError as exc: + raise SystemExit("Scan draft must be inside the registered scan drafts directory.") from exc + if not re.fullmatch(r"drafts/[0-9a-fA-F-]+\.json", relative): + raise SystemExit("Scan draft must be inside the registered scan drafts directory.") + return _read_scan_local_json(scan_dir, relative, "Staged scan draft") + + +def _require_current_deep_publication( + db: Any, connection: Any, scan_id: str, draft: dict[str, Any] +) -> None: + publication = draft.get("deepScanPublication") + run = db.deep_scan.require_deep_scan_run(connection, scan_id) + db.deep_scan.require_current_coordinator( + run, + argparse.Namespace( + coordinator_generation=publication.get("coordinatorGeneration") if publication else None + ), + ) + # Generation-one runs predate host publication metadata. Keep their existing + # draft path; adopted coordinators must carry their generation and selection. + if publication is None: + return + reducer = _latest_successful_reducer( + connection.execute( + "SELECT * FROM deep_scan_workers WHERE scan_id = ?", (scan_id,) + ).fetchall() + ) + selected_result = reducer["result_manifest_path"] if reducer is not None else None + if publication["resultPath"] != selected_result: + raise SystemExit("Deep Scan aggregate belongs to a superseded publication selection.") + + def write_scan_draft(db: Any, connection: Any, args: Any) -> dict[str, Any]: scan_id = db.require_uuid(args.scan_id, "scan-id") with db.scan_completion_lock(scan_id): @@ -1430,6 +1465,10 @@ def write_scan_draft(db: Any, connection: Any, args: Any) -> dict[str, Any]: "The scan stopped; its saved checkpoint was retained without replacing sealed results." ) scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) + draft = None + if scan["mode"] == "deep": + draft = _read_staged_scan_draft(scan_dir, args.draft_path) + _require_current_deep_publication(db, connection, scan_id, draft) if args.checkpoint_path is not None: try: checkpoint_relative = Path(args.checkpoint_path).relative_to(scan_dir).as_posix() @@ -1459,15 +1498,8 @@ def write_scan_draft(db: Any, connection: Any, args: Any) -> dict[str, Any]: raise SystemExit( "scan_draft_conflict: canonical scan results changed; reconcile the saved checkpoint again." ) - try: - relative = Path(args.draft_path).relative_to(scan_dir).as_posix() - except ValueError as exc: - raise SystemExit( - "Scan draft must be inside the registered scan drafts directory." - ) from exc - if not re.fullmatch(r"drafts/[0-9a-fA-F-]+\.json", relative): - raise SystemExit("Scan draft must be inside the registered scan drafts directory.") - draft = _read_scan_local_json(scan_dir, relative, "Staged scan draft") + if draft is None: + draft = _read_staged_scan_draft(scan_dir, args.draft_path) manifest, findings, coverage = draft["manifest"], draft["findings"], draft["coverage"] binding = db.workbench_completion_binding(scan, db.now()) # Validate on copies: saved canonical documents remain ordinary unsealed drafts. diff --git a/plugins/codex-security/tests/test_deep_scan_publication_authority.py b/plugins/codex-security/tests/test_deep_scan_publication_authority.py new file mode 100644 index 0000000000..b4ff3a0bdb --- /dev/null +++ b/plugins/codex-security/tests/test_deep_scan_publication_authority.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import copy +import json +import uuid +from argparse import Namespace + +import pytest +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan + + +def stage_publication(scan, *, generation, result_path, title): + draft_dir = scan.scan_dir / "drafts" + draft_dir.mkdir(exist_ok=True) + draft_path = draft_dir / f"{uuid.uuid4()}.json" + checkpoint_path = draft_dir / f"{uuid.uuid4()}.checkpoint.json" + findings = copy.deepcopy(scan.findings) + findings[0]["title"] = title + draft = { + "manifest": json.loads((scan.scan_dir / "scan-manifest.json").read_text()), + "findings": {"findings": findings}, + "coverage": scan.coverage, + } + if generation is not None: + draft["deepScanPublication"] = { + "coordinatorGeneration": generation, + "resultPath": str(result_path), + } + draft_path.write_text(json.dumps(draft)) + checkpoint_path.write_text( + json.dumps({"scanId": scan.scan_id, "findings": findings, "coverage": scan.coverage}) + ) + return Namespace( + scan_id=scan.scan_id, + claim_token=None, + draft_path=str(draft_path), + checkpoint_path=str(checkpoint_path), + expected_draft_digest=None, + ) + + +@pytest.mark.parametrize("stale", ["generation", "aggregate", "unfenced"]) +def test_stale_coordinator_cannot_replace_newer_canonical_publication( + workbench_api, workbench_db, publication_scan, stale +): + scan = publication_scan() + old_result = add_worker(workbench_db, scan) + new_result = add_worker(workbench_db, scan) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET coordinator_generation = 3 WHERE scan_id = ?", + (scan.scan_id,), + ) + for result, completed_at in ((old_result, "2026-01-01"), (new_result, "2026-01-02")): + workbench_db.execute( + "UPDATE deep_scan_workers SET kind = 'dedup', merge_state = 'none', completed_at = ? " + "WHERE result_manifest_path = ?", + (completed_at, str(result)), + ) + current = stage_publication( + scan, generation=3, result_path=new_result, title="Current accepted aggregate" + ) + workbench_api["write_scan_draft"](workbench_db, current) + saved = { + path: path.read_bytes() + for path in scan.scan_dir.rglob("*.json") + if "drafts" not in path.parts + } + old = stage_publication( + scan, + generation=None if stale == "unfenced" else 2 if stale == "generation" else 3, + result_path=old_result if stale == "aggregate" else new_result, + title="Superseded aggregate", + ) + + with pytest.raises(SystemExit, match="coordinator|aggregate"): + workbench_api["write_scan_draft"](workbench_db, old) + + assert { + path: path.read_bytes() + for path in scan.scan_dir.rglob("*.json") + if "drafts" not in path.parts + } == saved diff --git a/plugins/codex-security/tests/test_deep_scan_publication_replay.py b/plugins/codex-security/tests/test_deep_scan_publication_replay.py new file mode 100644 index 0000000000..af7a629bee --- /dev/null +++ b/plugins/codex-security/tests/test_deep_scan_publication_replay.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import sqlite3 +import subprocess +import sys +from argparse import Namespace +from pathlib import Path + +import pytest +from test_deep_scan_publication_authority import stage_publication +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan + +_CRASH_PUBLICATION = """ +import json, os, runpy, sqlite3, sys +from argparse import Namespace + +api = runpy.run_path(sys.argv[1], run_name="publication_crash_test") +args = Namespace(**json.loads(sys.argv[3])) +boundary = sys.argv[4] + +class CrashConnection(sqlite3.Connection): + def commit(self): + completing = self.execute( + "SELECT status FROM scans WHERE id = ?", (args.scan_id,) + ).fetchone()[0] == "complete" + if completing and boundary == "sqlite-before": + os._exit(72) + super().commit() + if completing and boundary == "sqlite-after": + os._exit(73) + +connection = sqlite3.connect(sys.argv[2], factory=CrashConnection) +connection.row_factory = sqlite3.Row +connection.execute("PRAGMA foreign_keys = ON") +if boundary.startswith("sqlite-"): + api["complete_scan"](connection, Namespace( + scan_id=args.scan_id, claim_token=None, cost_json=None + )) +else: + saved = api["saved_results"] + original_write = saved.write_scan_local_bytes + def crash_after_write(root, relative, contents): + original_write(root, relative, contents) + if relative == boundary: + os._exit(71) + saved.write_scan_local_bytes = crash_after_write + api["write_scan_draft"](connection, args) +raise AssertionError("publication never reached the requested crash boundary") +""" + + +@pytest.mark.parametrize( + "boundary", + ["findings.json", "coverage.json", "scan-manifest.json", "sqlite-before", "sqlite-after"], +) +def test_publication_crash_replays_selected_input_without_stale_overwrite( + workbench_api, workbench_db, publication_scan, tmp_path, boundary +): + scan = publication_scan() + result_path = add_worker(workbench_db, scan) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET coordinator_generation = 3 WHERE scan_id = ?", + (scan.scan_id,), + ) + workbench_db.execute( + "UPDATE deep_scan_workers SET kind = 'dedup', merge_state = 'none' WHERE scan_id = ?", + (scan.scan_id,), + ) + current = stage_publication( + scan, generation=3, result_path=result_path, title="Selected aggregate" + ) + stale = stage_publication( + scan, generation=2, result_path=result_path, title="Obsolete coordinator draft" + ) + database_path = tmp_path / "publication.sqlite3" + with sqlite3.connect(database_path) as connection: + workbench_db.backup(connection) + connection.row_factory = sqlite3.Row + if boundary.startswith("sqlite-"): + workbench_api["write_scan_draft"](connection, current) + + child = subprocess.run( + [ + sys.executable, + "-c", + _CRASH_PUBLICATION, + str(Path(__file__).resolve().parents[1] / "scripts" / "workbench_db.py"), + str(database_path), + json.dumps(vars(current)), + boundary, + ], + capture_output=True, + text=True, + ) + assert child.returncode == {"sqlite-before": 72, "sqlite-after": 73}.get(boundary, 71), ( + child.stdout, + child.stderr, + ) + interrupted = { + path: path.read_bytes() + for path in scan.scan_dir.rglob("*") + if path.is_file() and "drafts" not in path.parts + } + with sqlite3.connect(database_path) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + row = connection.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + assert row["status"] == ("complete" if boundary == "sqlite-after" else "running") + assert bool(row["seal_manifest_digest"]) == (boundary == "sqlite-after") + run_before = dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) + workers_before = [ + dict(row) for row in connection.execute("SELECT * FROM deep_scan_workers") + ] + + with pytest.raises(SystemExit, match="coordinator|stopped"): + workbench_api["write_scan_draft"](connection, stale) + assert all(path.read_bytes() == contents for path, contents in interrupted.items()) + + if not boundary.startswith("sqlite-"): + workbench_api["write_scan_draft"](connection, current) + completed = workbench_api["complete_scan"]( + connection, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + )["scan"] + assert completed["progress"]["status"] == "complete" + assert completed["findingCount"] == 1 + assert dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) == run_before + assert [ + dict(row) for row in connection.execute("SELECT * FROM deep_scan_workers") + ] == workers_before + assert connection.execute("SELECT COUNT(*) FROM finding_occurrences").fetchone()[0] == 1 + published = { + path: path.read_bytes() + for path in scan.scan_dir.rglob("*") + if path.is_file() and "drafts" not in path.parts + } + workbench_api["complete_scan"]( + connection, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert all(path.read_bytes() == contents for path, contents in published.items()) + if boundary.startswith("sqlite-"): + assert published == interrupted + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert findings[0]["title"] == "Selected aggregate" From 81c3af5225172a6bb3e82830970a95824ce83ae4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:53:01 +0000 Subject: [PATCH 013/251] test: align publication fixtures with supported recovery --- .../mcp-app/tests/deep_scan_publication_cases.mjs | 12 +++++++++++- .../mcp-app/tests/test_deep_scan_coordinator.mjs | 11 +---------- .../tests/test_deep_scan_successful_publication.py | 2 +- .../codex-security/tests/test_workbench_deep_scan.py | 4 +++- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs index 5e44d1796a..4221c95ee4 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs @@ -150,6 +150,7 @@ export async function testDeepScanPublication({ async function testPublicationUsesAcceptedReducerSnapshot() { const fixture = await fixtureRun({ workers: 1, subagents: 0, stopAfterNoNew: 1, maxDiscoveryRuns: 1 }); + fixture.run.coordinatorGeneration = 3; const store = new FakeStore(fixture.run); const commitDedup = store.commitDedup.bind(store); store.commitDedup = async (commit) => { @@ -163,17 +164,26 @@ export async function testDeepScanPublication({ return structuredClone(store.run); }; const completed = []; + const published = []; const coordinator = new DeepScanCoordinator({ run: fixture.run, store, executor: new FakeExecutor({ discoveryCandidateId: "accepted-finding" }), pluginRoot: fixture.pluginRoot, clock: immediateClock, - onComplete: async (draft) => completed.push(structuredClone(draft)), + onComplete: async (draft, _signal, publication) => { + completed.push(structuredClone(draft)); + published.push(publication); + }, }); coordinator.start(); const terminal = await coordinator.wait(undefined, 5_000); assert.equal(terminal?.status, "succeeded", terminal?.error); assert.equal(completed[0].findings[0].provenance.candidateId, "accepted-finding"); assert.equal(completed[0].coverage.completeness, "complete"); + const reducer = [...store.workers.values()].find((worker) => worker.kind === "dedup"); + assert.deepEqual(published, [{ + coordinatorGeneration: 3, + resultPath: reducer.resultManifestPath, + }]); } await testSaturationOmitsWorkerAcceptedDuringCancellation(); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs index 167e5cffe7..01c37dbaaa 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs @@ -27,11 +27,9 @@ const { const temporaryRoots = []; async function testCappedQueueAndSerialDedup() { const fixture = await fixtureRun({ workers: 3, subagents: 2, stopAfterNoNew: 10, maxDiscoveryRuns: 5 }); - fixture.run.coordinatorGeneration = 3; const store = new FakeStore(fixture.run); const executor = new FakeExecutor({ dedupNewFindings: [1, 0] }); const completedDrafts = []; - const published = []; const coordinator = new DeepScanCoordinator({ run: fixture.run, store, @@ -41,10 +39,7 @@ async function testCappedQueueAndSerialDedup() { retryDelaysMs: [1, 3, 9], clock: immediateClock, handoffClaimToken: "claim-fixture", - onComplete: async (draft, _signal, publication) => { - completedDrafts.push(structuredClone(draft)); - published.push(publication); - } + onComplete: async (draft) => completedDrafts.push(structuredClone(draft)) }); coordinator.start(); const terminal = await coordinator.wait(undefined, 5_000); @@ -73,10 +68,6 @@ async function testCappedQueueAndSerialDedup() { const reducerWorkers = [...store.workers.values()].filter((worker) => ( worker.kind === "dedup" && worker.status === "succeeded" )); - assert.deepEqual(published, [{ - coordinatorGeneration: 3, - resultPath: reducerWorkers.at(-1).resultManifestPath, - }]); const finalReducerResult = JSON.parse(await readFile( reducerWorkers.at(-1).resultManifestPath, "utf8" diff --git a/plugins/codex-security/tests/test_deep_scan_successful_publication.py b/plugins/codex-security/tests/test_deep_scan_successful_publication.py index b56839a323..b9c9c9e14c 100644 --- a/plugins/codex-security/tests/test_deep_scan_successful_publication.py +++ b/plugins/codex-security/tests/test_deep_scan_successful_publication.py @@ -72,7 +72,7 @@ def create(*, mode="deep", scope="."): "INSERT INTO deep_scan_runs (scan_id, schema_version, workflow_version, " "status, phase, workers, subagents, stop_after_no_new, max_discovery_runs, " "manifest_path, terminal_reason, created_at, updated_at, completed_at) " - "VALUES (?, 1, 'publication-test', 'succeeded', 'terminal', 1, 0, 1, 1, " + "VALUES (?, 1, 'deep-security-scan/v1', 'succeeded', 'terminal', 1, 0, 1, 1, " "?, 'saturated', ?, ?, ?)", ( scan_id, diff --git a/plugins/codex-security/tests/test_workbench_deep_scan.py b/plugins/codex-security/tests/test_workbench_deep_scan.py index 2da2ca020f..bc8de80585 100644 --- a/plugins/codex-security/tests/test_workbench_deep_scan.py +++ b/plugins/codex-security/tests/test_workbench_deep_scan.py @@ -279,7 +279,9 @@ def claim() -> dict[str, object]: return claim_deep_scan_coordinator(state_dir, codex_home, scan_id) with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: - migrations = connection.execute("SELECT * FROM schema_migrations ORDER BY version").fetchall() + migrations = connection.execute( + "SELECT * FROM schema_migrations ORDER BY version" + ).fetchall() assert claim()["deepScan"]["coordinatorGeneration"] == 2 assert claim()["coordinatorDisposition"] == "observing" expire_deep_scan_coordinator(state_dir, scan_id) From de96630736ef0c91045e82e0818f7fefabf10784 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:45:51 +0000 Subject: [PATCH 014/251] Read selected Deep Scan finalization before recovery --- .../mcp-app/src/deep-scan/registry.ts | 32 +++++-- .../mcp-app/src/deep-scan/store.ts | 22 +++++ .../mcp-app/src/deep-scan/types.ts | 10 ++ .../tests/test_deep_scan_compatibility.mjs | 59 +++++++++++- .../mcp-app/tests/test_deep_scan_store.mjs | 16 ++++ .../scripts/deep_scan_workbench.py | 23 ++++- .../scripts/workbench_schema.py | 7 ++ ...st_deep_scan_finalization_compatibility.py | 91 +++++++++++++++++++ .../codex-security/tests/test_workbench_db.py | 2 +- .../test_workbench_setup_and_migrations.py | 8 +- 10 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py diff --git a/plugins/codex-security/mcp-app/src/deep-scan/registry.ts b/plugins/codex-security/mcp-app/src/deep-scan/registry.ts index f7318fe385..f8127c2f26 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/registry.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/registry.ts @@ -2,13 +2,18 @@ import { setTimeout as delay } from "node:timers/promises"; import { DeepScanCoordinator } from "./coordinator.js"; import type { CoordinatorOptions } from "./coordinator.js"; import { isTransientPersistenceError } from "./store.js"; -import type { BeginDeepScanResult, DeepScanCoordinatorClaim, DeepScanRunState } from "./types.js"; +import type { BeginDeepScanResult, CodexWorkerExecutor, DeepScanCoordinatorClaim, DeepScanRunState } from "./types.js"; const COORDINATOR_LEASE_MS = 30_000; const COORDINATOR_POLL_MS = 1_000; export { DeepScanCoordinator, DeepScanNonRetryableError } from "./coordinator.js"; +export interface DeepScanCoordinatorStartOptions extends CoordinatorOptions { + /** Resolve persisted execution settings only after acquiring this run. */ + prepareExecutor?: (run: DeepScanRunState) => Promise; +} + /** Owns the live coordinators in this MCP server process. */ export class DeepScanCoordinatorRegistry { private readonly coordinators = new Map(); @@ -17,7 +22,7 @@ export class DeepScanCoordinatorRegistry { return this.coordinators.get(scanId); } - start(options: CoordinatorOptions): DeepScanCoordinator { + start(options: DeepScanCoordinatorStartOptions): DeepScanCoordinator { requireSupportedDeepScan(options.run); const existing = this.coordinators.get(options.run.scanId); if (existing) return existing; @@ -105,7 +110,7 @@ export class DeepScanRemoteCoordinator { private readonly input: { run: DeepScanRunState; registry: Pick; - options: Omit; + options: Omit; } ) {} @@ -169,7 +174,7 @@ export class DeepScanRemoteCoordinator { } } if (claim?.acquired) { - const coordinator = registry.start({ ...options, run: claim.run }); + const coordinator = await startClaimedCoordinator(registry, options, claim.run); return deadline === undefined ? await coordinator.wait(signal) : await coordinator.wait(signal, Math.max(0, deadline - Date.now())); @@ -187,7 +192,7 @@ export class DeepScanRemoteCoordinator { export async function startOrJoinDeepScanCoordinator(input: { begin: BeginDeepScanResult; registry: Pick; - options: Omit; + options: Omit; }): Promise<{ coordinator: DeepScanCoordinator | DeepScanRemoteCoordinator; joined: boolean; @@ -215,12 +220,27 @@ export async function startOrJoinDeepScanCoordinator(input: { }; } return { - coordinator: input.registry.start({ ...input.options, run: claim.run }), + coordinator: await startClaimedCoordinator(input.registry, input.options, claim.run), joined: false }; } +async function startClaimedCoordinator( + registry: Pick, + options: Omit, + run: DeepScanRunState +): Promise { + requireSupportedDeepScan(run); + const executor = options.prepareExecutor + ? await options.prepareExecutor(run) + : options.executor; + return registry.start({ ...options, executor, run }); +} + function requireSupportedDeepScan(run: DeepScanRunState): void { + if (run.finalizationInput !== undefined) { + throw new Error("This executor does not support resuming selected Deep Scan finalization."); + } // Missing versions are supported for older adapters that did not project them. if ( (run.schemaVersion !== undefined && run.schemaVersion !== 1) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index 71391f8492..5a71591ed7 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -588,6 +588,7 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { scanId: requiredString(value.scanId, "deepScan.scanId"), schemaVersion: optionalPositiveInteger(value.schemaVersion), workflowVersion: optionalString(value.workflowVersion), + finalizationInput: parseFinalizationInput(value.finalizationInput), status, phase: deepScanPhase(value.phase), coordinatorGeneration: optionalPositiveInteger(value.coordinatorGeneration), @@ -617,6 +618,27 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { }; } +function parseFinalizationInput(value: unknown): DeepScanRunState["finalizationInput"] { + if (value === undefined || value === null) return undefined; + const input = objectValue(value, "deepScan.finalizationInput"); + if (input.terminalReason !== "saturated" && input.terminalReason !== "capped") { + throw new Error("Codex Security workbench returned invalid finalization terminal reason."); + } + if (!Array.isArray(input.omittedWorkerIds)) { + throw new Error("Codex Security workbench returned invalid finalization omissions."); + } + return { + version: positiveInteger(input.version, "deepScan.finalizationInput.version"), + resultPath: input.resultPath === null + ? null : requiredString(input.resultPath, "deepScan.finalizationInput.resultPath"), + resultSha256: input.resultSha256 === null + ? null : requiredString(input.resultSha256, "deepScan.finalizationInput.resultSha256"), + terminalReason: input.terminalReason, + omittedWorkerIds: input.omittedWorkerIds.map((id) => requiredString(id, "omittedWorkerId")), + selectedAt: requiredString(input.selectedAt, "deepScan.finalizationInput.selectedAt") + }; +} + function parsePersistedDedupInputs(value: unknown): PersistedDeepScanDedupInput[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/types.ts b/plugins/codex-security/mcp-app/src/deep-scan/types.ts index b284f68089..52d427159a 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/types.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/types.ts @@ -37,10 +37,20 @@ export interface DeepScanCanonicalArtifacts { export type DeepScanReducerArtifacts = DeepScanCanonicalArtifacts; +export interface DeepScanFinalizationInput { + version: number; + resultPath: string | null; + resultSha256: string | null; + terminalReason: DeepScanTerminalReason; + omittedWorkerIds: string[]; + selectedAt: string; +} + export interface DeepScanRunState { scanId: string; schemaVersion?: number; workflowVersion?: string; + finalizationInput?: DeepScanFinalizationInput; status: DeepScanRunStatus; phase?: "setup" | "discovery" | "reducing" | "terminal"; coordinatorGeneration?: number; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs index 0ce73fbd83..3263052fae 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs @@ -9,7 +9,7 @@ const bundle = await build({ platform: "node", write: false }); -const { startOrJoinDeepScanCoordinator } = await import( +const { startOrJoinDeepScanCoordinator, DeepScanRemoteCoordinator } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); @@ -36,3 +36,60 @@ async function testUnsupportedWorkflowDoesNotAcquireOwnership() { } } + +// A joining client must not resolve or replace the live executor's settings. +for (const local of [true, false]) { + let preparations = 0; + const run = { scanId: "fixture", status: "running", workflowVersion: "deep-scan-mcp/v1" }; + const options = { + threadId: "fixture-thread", + executor: { marker: "observer" }, + prepareExecutor: async () => { preparations += 1; return {}; }, + store: { claimCoordinator: async () => ({ run, acquired: false }) } + }; + await startOrJoinDeepScanCoordinator({ + begin: { run, shouldStart: false }, + registry: { get: () => local ? {} : undefined, start: () => assert.fail("observer started") }, + options + }); + assert.equal(preparations, 0); +} + +const originalNow = Date.now; +try { + let now = 0; + Date.now = () => now; + const run = { scanId: "fixture", status: "running", updatedAt: "1970-01-01T00:00:00Z" }; + const acquired = { ...run, model: "original-model", coordinatorGeneration: 3 }; + let preparations = 0; + const executor = { marker: "restored" }; + const registry = { + get: () => undefined, + start: (options) => { + assert.equal(options.run, acquired); + assert.equal(options.executor, executor); + return { wait: async () => ({ ...acquired, status: "succeeded" }) }; + } + }; + const options = { + threadId: "fixture-thread", + executor: { marker: "observer" }, + prepareExecutor: async (state) => { + assert.equal(state, acquired); + preparations += 1; + return executor; + }, + store: { + get: async () => run, + claimCoordinator: async () => ({ run: acquired, acquired: true }) + } + }; + await startOrJoinDeepScanCoordinator({ begin: { run, shouldStart: true }, registry, options }); + assert.equal(preparations, 1); + const remote = new DeepScanRemoteCoordinator({ run, registry, options }); + now = 60_000; + assert.equal((await remote.wait(undefined, 1_000)).status, "succeeded"); + assert.equal(preparations, 2, "takeover resolves settings from the newly acquired run"); +} finally { + Date.now = originalNow; +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs index a6b86f374e..6b42bf36fb 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs @@ -888,3 +888,19 @@ function testWorkflowVersionParsing() { assert.equal(future.schemaVersion, 99); assert.equal(future.workflowVersion, "future/v99", "inspection preserves unsupported versions"); } + +for (const version of [1, 99]) { + const finalizationInput = { + version, + resultPath: null, + resultSha256: null, + terminalReason: "capped", + omittedWorkerIds: ["fixture-worker"], + selectedAt: "2026-01-01T00:00:00Z" + }; + assert.deepEqual( + parseDeepScan(stateResult(randomUUID(), { deepScan: { finalizationInput } })).finalizationInput, + finalizationInput, + "inspection preserves finalization input and version before execution compatibility checks" + ); +} diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 4bbc0788a8..5d1970a243 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -38,7 +38,11 @@ ) DEEP_SCAN_TERMINAL_REASONS = ("saturated", "capped") DEEP_SCAN_WORKFLOW_VERSION = "deep-security-scan/v1" -SUPPORTED_DEEP_SCAN_WORKFLOWS = {DEEP_SCAN_WORKFLOW_VERSION, "deep-scan-mcp/v1"} +SUPPORTED_DEEP_SCAN_WORKFLOWS = { + DEEP_SCAN_WORKFLOW_VERSION, + "deep-scan-mcp/v1", + "deep-security-scan/v2", +} DEEP_SCAN_COORDINATOR_LEASE_SECONDS = 30 DEEP_SCAN_LEGACY_COORDINATOR_GRACE_SECONDS = 120 DEEP_SCAN_MAX_ERROR_LENGTH = 2400 @@ -272,6 +276,20 @@ def require_supported_deep_scan(run: sqlite3.Row) -> None: "Resume it with a compatible Codex Security release." ) + finalization = deep_scan_finalization_input(run) + if finalization is not None and ( + run["workflow_version"] != "deep-security-scan/v2" + or not isinstance(finalization, dict) + or finalization.get("version") != 1 + ): + raise SystemExit("This Deep Scan uses an unsupported finalization input version.") + + +def deep_scan_finalization_input(run: sqlite3.Row) -> dict[str, Any] | None: + if "finalization_input_json" not in run.keys() or run["finalization_input_json"] is None: + return None + return json.loads(run["finalization_input_json"]) + def deep_scan_deadline_reached(run: sqlite3.Row) -> bool: elapsed = _parse_timestamp(now()) - _parse_timestamp(str(run["created_at"])) @@ -485,6 +503,7 @@ def _deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, "scanDir": scan["scan_dir"], "schemaVersion": run["schema_version"], "workflowVersion": run["workflow_version"], + "finalizationInput": deep_scan_finalization_input(run), "coordinatorGeneration": run["coordinator_generation"], "status": run["status"], "phase": run["phase"], @@ -1089,6 +1108,8 @@ def recover_expired_coordinator( connection: sqlite3.Connection, run: sqlite3.Row, timestamp: str ) -> None: require_supported_deep_scan(run) + if deep_scan_finalization_input(run) is not None: + return scan_id = run["scan_id"] recover_candidate_ledger_publication(connection, scan_id) legacy_generation = int(run["coordinator_generation"] == 1) diff --git a/plugins/codex-security/scripts/workbench_schema.py b/plugins/codex-security/scripts/workbench_schema.py index 37758b1355..1c9fc88e7e 100644 --- a/plugins/codex-security/scripts/workbench_schema.py +++ b/plugins/codex-security/scripts/workbench_schema.py @@ -925,6 +925,13 @@ ALTER TABLE deep_scan_dedup_inputs ADD COLUMN attempt INTEGER; """, ), + ( + 46, + "persist selected deep scan finalization input", + """ + ALTER TABLE deep_scan_runs ADD COLUMN finalization_input_json TEXT; + """, + ), ) diff --git a/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py b/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py new file mode 100644 index 0000000000..2c409463e8 --- /dev/null +++ b/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py @@ -0,0 +1,91 @@ +"""Selected finalization survives ownership recovery without restarting discovery.""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +import pytest +from workbench_test_support import run_workbench + + +def selected_scan(tmp_path: Path, version: int) -> tuple[Path, str, dict[str, object]]: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + run = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + )["deepScan"] + selected = { + "version": version, + "resultPath": None, + "resultSha256": None, + "terminalReason": "capped", + "omittedWorkerIds": [], + "selectedAt": "2000-01-01T00:00:00Z", + } + with sqlite3.connect(state / "workbench.sqlite3") as connection: + columns = {row[1] for row in connection.execute("PRAGMA table_info(deep_scan_runs)")} + if "finalization_input_json" not in columns: + connection.execute("ALTER TABLE deep_scan_runs ADD COLUMN finalization_input_json TEXT") + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "finalization_input_json = ?, phase = 'reducing', " + "created_at = '2000-01-01T00:00:00Z', updated_at = '2000-01-01T00:00:00Z'", + (json.dumps(selected),), + ) + return state, str(run["scanId"]), selected + + +def test_claim_preserves_selected_finalization_without_discovery_recovery(tmp_path: Path) -> None: + state, scan_id, selected = selected_scan(tmp_path, 1) + claimed = run_workbench( + state, + "claim-deep-scan-coordinator", + "--scan-id", + scan_id, + "--thread-id", + "fixture-thread", + )["deepScan"] + assert claimed["finalizationInput"] == selected + assert claimed["phase"] == "reducing" + assert claimed["coordinatorGeneration"] == 2 + assert claimed["dispatchedCount"] == 0 + assert claimed["createdAt"] == "2000-01-01T00:00:00Z" + + +@pytest.mark.parametrize("command", ["begin-deep-scan", "claim-deep-scan-coordinator"]) +def test_unsupported_selection_rejects_without_mutation(tmp_path: Path, command: str) -> None: + state, scan_id, selected = selected_scan(tmp_path, 99) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + before = "\n".join(connection.iterdump()) + rejected = run_workbench( + state, + command, + "--scan-id", + scan_id, + "--thread-id", + "fixture-thread", + check=False, + ) + assert rejected["returncode"] != 0 + assert "unsupported" in str(rejected["stderr"]).lower() + with sqlite3.connect(state / "workbench.sqlite3") as connection: + assert "\n".join(connection.iterdump()) == before + observed = run_workbench( + state, + "get-deep-scan", + "--scan-id", + scan_id, + "--thread-id", + "fixture-thread", + )["deepScan"] + assert observed["finalizationInput"] == selected diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index a9196ec699..2f3ad542b5 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -1045,7 +1045,7 @@ def test_workbench_persists_progress_and_indexes_completed_findings(tmp_path: Pa ) } assert tables == EXPECTED_TABLES - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (43,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (44,) assert connection.execute("SELECT COUNT(*) FROM findings").fetchone() == (1,) assert connection.execute("SELECT COUNT(*) FROM finding_locations").fetchone() == (1,) diff --git a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py index a0c79f055b..09d969b7de 100644 --- a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py +++ b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py @@ -405,7 +405,7 @@ def test_workbench_serializes_concurrent_first_run_migrations(tmp_path: Path) -> {"databasePath": str(state_dir / "workbench.sqlite3")}, ] with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (43,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (44,) @pytest.mark.parametrize("previous_history", ["main", "comparison-preview"]) @@ -869,6 +869,7 @@ def test_workbench_creates_single_final_schema(tmp_path: Path) -> None: (41, "checkpoint finding severity assessments"), (44, "preserve original deep scan discovery context"), (45, "retain deep scan attempts and exact merge inputs"), + (46, "persist selected deep scan finalization input"), ] assert {row[1] for row in connection.execute("PRAGMA table_info(workspaces)")} >= { "diff_target_kind", @@ -971,7 +972,7 @@ def test_workbench_upgrades_preexisting_database(tmp_path: Path) -> None: connection.execute("ALTER TABLE scans DROP COLUMN handoff_claim_token") run_workbench(state_dir, "database-info") with sqlite3.connect(database) as connection: - assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (45,) + assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (46,) assert {row[1] for row in connection.execute("PRAGMA table_info(scans)")} >= { "handoff_claimed_at", "handoff_claim_token", @@ -2000,6 +2001,7 @@ def test_workbench_upgrades_released_database_schema(tmp_path: Path) -> None: (41, "checkpoint finding severity assessments"), (44, "preserve original deep scan discovery context"), (45, "retain deep scan attempts and exact merge inputs"), + (46, "persist selected deep scan finalization input"), ] assert "capability_preflight_json" in { row[1] for row in connection.execute("PRAGMA table_info(workspaces)") @@ -2085,6 +2087,7 @@ def test_workbench_upgrades_pre_release_phase_progress_migration(tmp_path: Path) (41, "checkpoint finding severity assessments"), (44, "preserve original deep scan discovery context"), (45, "retain deep scan attempts and exact merge inputs"), + (46, "persist selected deep scan finalization input"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") @@ -2178,6 +2181,7 @@ def test_workbench_upgrades_pre_release_preflight_progress_migration(tmp_path: P (41, "checkpoint finding severity assessments"), (44, "preserve original deep scan discovery context"), (45, "retain deep scan attempts and exact merge inputs"), + (46, "persist selected deep scan finalization input"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") From 066368199b3bb8d1e253458152d7226a1b23c204 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:38:50 +0000 Subject: [PATCH 015/251] refactor: share Codex session construction and stream reduction --- .../mcp-app/src/deep-scan/executor.ts | 78 +++++---- .../mcp-app/tests/test_deep_scan_executor.mjs | 27 ++- sdk/typescript/src/api.ts | 157 +++++++----------- sdk/typescript/src/codex-session.ts | 116 +++++++++++++ 4 files changed, 235 insertions(+), 143 deletions(-) create mode 100644 sdk/typescript/src/codex-session.ts diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 788745ae4c..6aff6aa42b 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -1,7 +1,10 @@ import { accessSync, constants as fsConstants, existsSync, promises as fs, readdirSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import { delimiter, dirname, isAbsolute, join, resolve, win32 } from "node:path"; -import { Codex } from "@openai/codex-sdk"; +import { + CodexSession, + readCodexSessionTurn +} from "../../../../../sdk/typescript/src/codex-session.js"; import { parse as parseToml } from "smol-toml"; import { executablePathForSpawn } from "./executable-path.js"; import { @@ -76,7 +79,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { signal: request.signal }); const prompt = await fs.readFile(request.promptPath, "utf8"); - const codex = new Codex({ + const codex = new CodexSession({ codexPathOverride: executablePathForSpawn(codexPath), env: childEnv, // Codex exec reads CODEX_API_KEY; the SDK maps apiKey to that variable. @@ -110,7 +113,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { workingDirectory: request.workingDirectory } as const; const thread = request.resumeThreadId - ? codex.resumeThread(request.resumeThreadId, threadOptions) + ? codex.resumeThread!(request.resumeThreadId, threadOptions) : codex.startThread(threadOptions); const input = request.resumeThreadId ? request.continuationPrompt ?? prompt @@ -125,51 +128,44 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { try { const { events } = await thread.runStreamed(input, { signal: controller.signal }); - let finalResponse = ""; - let threadId: string | undefined; - let turnCompleted = false; - let lastStreamError: string | undefined; const diagnostics: CodexWorkerDiagnostic[] = []; - for await (const event of events) { - if (event.type === "thread.started") { - threadId = event.thread_id; - await request.onThreadStarted?.(threadId); - } else if (event.type === "item.completed") { - const fallbackError = event.item.type === "error" - ? deepScanPermissionProfileFallbackError(event.item.message) - : undefined; - if (fallbackError) { - controller.abort(fallbackError); - throw fallbackError; - } - if (event.item.type === "agent_message") { - finalResponse = event.item.text; - } else { + const turn = await readCodexSessionTurn({ + thread, + events, + stopOnCompletion: true, + onEvent: async (event) => { + if (event.type === "thread.started" && typeof event.thread_id === "string") { + await request.onThreadStarted?.(event.thread_id); + } else if (event.type === "item.completed" && isRecord(event.item)) { + const fallbackError = event.item.type === "error" && typeof event.item.message === "string" + ? deepScanPermissionProfileFallbackError(event.item.message) + : undefined; + if (fallbackError) { + controller.abort(fallbackError); + throw fallbackError; + } appendSafeItemDiagnostic(diagnostics, event.item); + } else if (event.type === "turn.completed") { + request.signal.removeEventListener("abort", forwardAbort); + } else if (event.type === "turn.failed") { + throw new Error((event.error as { message: string }).message); + } else if (event.type === "error" && typeof event.message === "string") { + const fallbackError = deepScanPermissionProfileFallbackError(event.message); + if (fallbackError) { + controller.abort(fallbackError); + throw fallbackError; + } + // Codex exec emits retry-in-progress notifications as error events. } - } else if (event.type === "turn.completed") { - turnCompleted = true; - request.signal.removeEventListener("abort", forwardAbort); - break; - } else if (event.type === "turn.failed") { - throw new Error(event.error.message); - } else if (event.type === "error") { - const fallbackError = deepScanPermissionProfileFallbackError(event.message); - if (fallbackError) { - controller.abort(fallbackError); - throw fallbackError; - } - // Codex exec currently emits retry-in-progress notifications as error events. - lastStreamError = event.message; } - } - if (!turnCompleted) { - const detail = lastStreamError ? `: ${lastStreamError}` : ""; + }); + if (turn.status !== "completed") { + const detail = turn.lastStreamError ? `: ${turn.lastStreamError}` : ""; throw new Error(`Codex worker stream ended before turn.completed${detail}`); } return { - finalResponse, - threadId: threadId ?? thread.id ?? undefined, + finalResponse: turn.finalResponse, + threadId: turn.threadId ?? thread.id ?? undefined, ...(diagnostics.length > 0 ? { diagnostics } : {}) }; } finally { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 76112891dd..0955f3eb85 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -74,6 +74,7 @@ const deniedWorkerPermissionProfile = { try { await testOpenAiCredentialsReachWorker(); await testWorkerReasoningSummaries(); + if (process.platform !== "win32") await testNullUsageCompletion(); if (process.platform !== "win32") { await testMissingParentSandboxFailsBeforeWorkerLaunch(); await testDisallowedWorkerProfileFailsBeforeWorkerLaunch(); @@ -737,6 +738,30 @@ async function testOpenAiCredentialsReachWorker() { } } +async function testNullUsageCompletion() { + const fixture = await fakeCodexFixture(); + const previousPath = process.env.CODEX_CLI_PATH; + process.env.CODEX_CLI_PATH = fixture.executablePath; + try { + const promptPath = path.join(fixture.root, "prompt.md"); + await writeFile(promptPath, "NULL_USAGE\n"); + for (const kind of ["discovery", "dedup"]) { + for (const resumeThreadId of [undefined, "fixture-resumed-thread"]) { + const result = await new CodexSdkWorkerExecutor({ + parentSandbox: trustedParentSandbox + }).run({ + kind, promptPath, workingDirectory: fixture.root, subagents: 0, + resumeThreadId, signal: new AbortController().signal + }); + assert.equal(result.threadId, resumeThreadId ?? "fixture-thread-id"); + assert.equal(result.finalResponse, "fixture final response"); + } + } + } finally { + restoreEnv("CODEX_CLI_PATH", previousPath); + } +} + async function testWorkerReasoningSummaries() { const cases = [ ["", undefined], @@ -1562,7 +1587,7 @@ async function fakeCodexFixture( " console.log(JSON.stringify({ type: 'item.completed', item }));", "}", "console.log(JSON.stringify({ type: 'item.completed', item: { id: 'message-1', type: 'agent_message', text: 'fixture final response' } }));", - "console.log(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));", + "console.log(JSON.stringify({ type: 'turn.completed', usage: stdin.includes('NULL_USAGE') ? null : { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));", "if (stdin.includes('COMPLETE_THEN_HANG')) { setInterval(() => {}, 1_000); await new Promise(() => {}); }", "}", "" diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 425e486c4a..8540ec8813 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -23,13 +23,16 @@ import { resolve, sep, } from "node:path"; -import { - Codex, - type CodexOptions, - type ThreadOptions, - type TurnOptions, -} from "@openai/codex-sdk"; +import { type CodexOptions, type ThreadOptions } from "@openai/codex-sdk"; import { z } from "incur"; +import { + CodexSession, + createCodexClient, + readCodexSessionTurn, + type CodexSessionClient as CodexClientLike, + type CodexSessionThread as CodexThreadLike, + type CodexSessionEvent as ScanEvent, +} from "./codex-session.js"; import { CODEX_AUTH_CONFIG_KEYS, NO_CREDENTIALS_MESSAGE, @@ -216,24 +219,6 @@ import { validateMode, } from "./targets.js"; -interface CodexThreadLike { - readonly id: string | null; - runStreamed( - input: string, - options: TurnOptions, - ): Promise<{ events: AsyncGenerator }>; -} - -interface ScanEvent { - readonly type: string; - readonly [key: string]: unknown; -} - -interface CodexClientLike { - startThread(options: ThreadOptions): CodexThreadLike; - resumeThread?(threadId: string, options: ThreadOptions): CodexThreadLike; -} - interface PreparedRuntime { codexHome: string; persistentCredentialHome?: boolean; @@ -453,7 +438,7 @@ interface ClientDependencies { } const DEFAULT_DEPENDENCIES: ClientDependencies = { - createCodex: (options) => new Codex(options), + createCodex: createCodexClient, environment: process.env, }; @@ -2685,30 +2670,33 @@ export class CodexSecurity { sdkEnvironment, ); } - const codex = this.#dependencies.createCodex({ - ...(codexPathOverride === undefined - ? {} - : { codexPathOverride: executablePathForSpawn(codexPathOverride) }), - ...(externalProvider !== null || apiKey === null ? {} : { apiKey }), - ...(commandAuth || configOverrides.length > 0 - ? { - configOverrides: [ - ...(commandAuth - ? modelProviderConfigOverride(sessionConfig) - : []), - ...configOverrides, - ], - } - : {}), - env: sdkEnvironment, - config: { - ...(sdkCodexConfig as NonNullable), - responses_api_metadata: { - ...configuredResponsesMetadata, - codex_security_surface: this.#surface, + const codex = new CodexSession( + { + ...(codexPathOverride === undefined + ? {} + : { codexPathOverride: executablePathForSpawn(codexPathOverride) }), + ...(externalProvider !== null || apiKey === null ? {} : { apiKey }), + ...(commandAuth || configOverrides.length > 0 + ? { + configOverrides: [ + ...(commandAuth + ? modelProviderConfigOverride(sessionConfig) + : []), + ...configOverrides, + ], + } + : {}), + env: sdkEnvironment, + config: { + ...(sdkCodexConfig as NonNullable), + responses_api_metadata: { + ...configuredResponsesMetadata, + codex_security_surface: this.#surface, + }, }, }, - }); + this.#dependencies.createCodex, + ); return { codex, environment }; } @@ -3798,61 +3786,28 @@ async function readCodexTurn(options: { usage: unknown; lastStreamError: string | null; }> { - let threadId = options.thread.id; - let status: "in_progress" | "completed" = "in_progress"; - let finalResponse = ""; - let usage: unknown = null; - let lastStreamError: string | null = null; - for await (const event of eventsWithOptionalUsage(options.events)) { - await options.onEvent?.(event); - if ( - event.type === "thread.started" && - typeof event["thread_id"] === "string" - ) { - threadId = event["thread_id"]; - } else if ( - event.type === "item.completed" && - isRecord(event["item"]) && - event["item"]["type"] === "agent_message" && - typeof event["item"]["text"] === "string" - ) { - finalResponse = event["item"]["text"]; - } else if (event.type === "turn.completed") { - status = "completed"; - usage = event["usage"]; - } else if (event.type === "turn.failed") { - throw new CodexSecurityError(turnFailureMessage(event["error"])); - } else if (event.type === "error" && typeof event["message"] === "string") { - const message = event["message"]; - const classification = classifyConnectionFailure(message); - if (classification === "unauthorized" || classification === "forbidden") { - throw new CodexSecurityError(message); + return readCodexSessionTurn({ + ...options, + onEvent: async (event) => { + await options.onEvent?.(event); + if (event.type === "turn.failed") { + throw new CodexSecurityError(turnFailureMessage(event["error"])); } - const reconnect = reconnectAttempt(message); - if (reconnect === null) throw new CodexSecurityError(message); - lastStreamError = message; - options.onReconnect?.(message, reconnect); - } - } - return { threadId, status, finalResponse, usage, lastStreamError }; -} - -async function* eventsWithOptionalUsage( - events: AsyncGenerator, -): AsyncGenerator { - try { - yield* events; - } catch (error) { - if ( - error instanceof TypeError && - /\b(?:null|undefined)\b/u.test(error.message) && - /\bcache_write_input_tokens\b/u.test(error.message) - ) { - yield { type: "turn.completed", usage: null }; - return; - } - throw error; - } + if (event.type === "error" && typeof event["message"] === "string") { + const message = event["message"]; + const classification = classifyConnectionFailure(message); + if ( + classification === "unauthorized" || + classification === "forbidden" + ) { + throw new CodexSecurityError(message); + } + const reconnect = reconnectAttempt(message); + if (reconnect === null) throw new CodexSecurityError(message); + options.onReconnect?.(message, reconnect); + } + }, + }); } function trustedAccessStatusFromEvent( diff --git a/sdk/typescript/src/codex-session.ts b/sdk/typescript/src/codex-session.ts new file mode 100644 index 0000000000..0112681ed0 --- /dev/null +++ b/sdk/typescript/src/codex-session.ts @@ -0,0 +1,116 @@ +import { + Codex, + type CodexOptions, + type ThreadOptions, + type TurnOptions, +} from "@openai/codex-sdk"; + +export interface CodexSessionEvent { + readonly type: string; + readonly [key: string]: unknown; +} + +export interface CodexSessionThread { + readonly id: string | null; + runStreamed( + input: string, + options: TurnOptions, + ): Promise<{ events: AsyncGenerator }>; +} + +export interface CodexSessionClient { + startThread(options: ThreadOptions): CodexSessionThread; + resumeThread?(threadId: string, options: ThreadOptions): CodexSessionThread; +} + +export const createCodexClient = (options: CodexOptions): CodexSessionClient => + new Codex(options); + +/** Resource resolution and role-specific configuration belong to the caller. */ +export class CodexSession { + private readonly client: CodexSessionClient; + + constructor( + options: CodexOptions, + createClient: ( + options: CodexOptions, + ) => CodexSessionClient = createCodexClient, + ) { + this.client = createClient(options); + } + + startThread(options: ThreadOptions): CodexSessionThread { + return this.client.startThread(options); + } + + get resumeThread(): CodexSessionClient["resumeThread"] { + return this.client.resumeThread?.bind(this.client); + } +} + +/** Reduce a single stream; callers retain error, retry and acceptance policy. */ +export async function readCodexSessionTurn(options: { + thread: CodexSessionThread; + events: AsyncGenerator; + onEvent: (event: CodexSessionEvent) => Promise | void; + stopOnCompletion?: boolean; +}): Promise<{ + threadId: string | null; + status: "in_progress" | "completed"; + finalResponse: string; + usage: unknown; + lastStreamError: string | null; +}> { + let threadId = options.thread.id; + let status: "in_progress" | "completed" = "in_progress"; + let finalResponse = ""; + let usage: unknown = null; + let lastStreamError: string | null = null; + for await (const event of eventsWithOptionalUsage(options.events)) { + await options.onEvent(event); + if ( + event.type === "thread.started" && + typeof event["thread_id"] === "string" + ) { + threadId = event["thread_id"]; + } else if ( + event.type === "item.completed" && + isRecord(event["item"]) && + event["item"]["type"] === "agent_message" && + typeof event["item"]["text"] === "string" + ) { + finalResponse = event["item"]["text"]; + } else if (event.type === "turn.completed") { + status = "completed"; + usage = event["usage"] ?? null; + if (options.stopOnCompletion) break; + } else if (event.type === "error" && typeof event["message"] === "string") { + lastStreamError = event["message"]; + } + } + return { threadId, status, finalResponse, usage, lastStreamError }; +} + +async function* eventsWithOptionalUsage( + events: AsyncGenerator, +): AsyncGenerator { + try { + yield* events; + } catch (error) { + // The pinned SDK accesses this field before yielding a completion with + // absent usage. Preserve completion without inventing a zero-token receipt. + if ( + error instanceof TypeError && + /\b(?:null|undefined)\b/u.test(error.message) && + /\bcache_write_input_tokens\b/u.test(error.message) + ) { + yield { type: "turn.completed", usage: null }; + return; + } + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} From 0bba8677072295a75456310404fbf8df86b93b69 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:39:07 +0000 Subject: [PATCH 016/251] fix: bind Deep worker model selections to each scan --- .../mcp-app/src/deep-scan/executor.ts | 87 +++++++--- .../mcp-app/tests/test_deep_scan_executor.mjs | 156 +++++++++++++++++- sdk/typescript/tests-ts/api.test.ts | 110 ++++++++++++ 3 files changed, 324 insertions(+), 29 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 6aff6aa42b..1f3147c172 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -5,6 +5,7 @@ import { CodexSession, readCodexSessionTurn } from "../../../../../sdk/typescript/src/codex-session.js"; +import type { CodexOptions } from "@openai/codex-sdk"; import { parse as parseToml } from "smol-toml"; import { executablePathForSpawn } from "./executable-path.js"; import { @@ -25,6 +26,8 @@ import type { } from "./types.js"; export interface CodexSdkWorkerModelSettings { + /** Resolved by the execution owner, including when reconstructing a scan. */ + codexOptions?: CodexOptions; model?: string; reasoningEffort?: string; artifactContext?: CodexSdkWorkerArtifactContext; @@ -42,7 +45,7 @@ export interface CodexSdkWorkerArtifactContext { } export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { - private runtimeReasoningSummary?: Promise; + private runtimeModelConfig?: Promise>; constructor(private readonly modelSettings: CodexSdkWorkerModelSettings = {}) {} @@ -55,15 +58,32 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { ); } const workerProfile = workerPermissionProfile(parentSandbox); - const configOverrides = workerPermissionProfileConfigOverrides(workerProfile); + const resolved = this.modelSettings.codexOptions; const originalCwd = process.cwd(); - const childEnv = await snapshotWorkerEnvironment(); - // Snapshot the SDK's per-scan config once for this coordinator, including resumes. - const reasoningSummary = await (this.runtimeReasoningSummary ??= workerReasoningSummary(childEnv)); + const childEnv = await snapshotWorkerEnvironment(resolved?.env); + if (resolved?.apiKey !== undefined) childEnv.CODEX_API_KEY = resolved.apiKey; + // Snapshot per-scan selections once; a reconstructed owner can supply them. + // Native account credentials continue to refresh in the selected home. + const modelConfig: NonNullable = { + ...await (this.runtimeModelConfig ??= resolved?.config + ? Promise.resolve(resolved.config) + : workerModelConfig(childEnv)), + ...(this.modelSettings.model ? { model: this.modelSettings.model } : {}), + // The CLI can add effort levels before the pinned SDK widens ThreadOptions. + ...(this.modelSettings.reasoningEffort + ? { model_reasoning_effort: this.modelSettings.reasoningEffort } + : {}) + }; + const configOverrides = [ + ...(resolved?.configOverrides ?? []), + ...workerPermissionProfileConfigOverrides(workerProfile) + ]; const openAiApiKey = environmentVariable(childEnv, "OPENAI_API_KEY", process.platform)?.trim(); const codexApiKey = environmentVariable(childEnv, "CODEX_API_KEY", process.platform)?.trim(); const codexPath = resolveCodexPath( - childEnv, + resolved?.codexPathOverride === undefined + ? childEnv + : { ...childEnv, CODEX_CLI_PATH: resolved.codexPathOverride }, process.platform, process.arch, originalCwd @@ -72,7 +92,12 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { codexPath, cwd: request.workingDirectory, profileId: DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID, - configOverrides, + configOverrides: [ + ...Object.entries(workerModelSelection(modelConfig)) + .map(([key, value]) => `${key}=${tomlInlineValue(value)}`), + ...configOverrides, + ...(resolved?.baseUrl ? [`openai_base_url=${tomlString(resolved.baseUrl)}`] : []) + ], expectedProfile: workerProfile, env: childEnv, allowOpenAiApiKeyFallback: Boolean(openAiApiKey && !codexApiKey), @@ -80,26 +105,22 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { }); const prompt = await fs.readFile(request.promptPath, "utf8"); const codex = new CodexSession({ + ...resolved, codexPathOverride: executablePathForSpawn(codexPath), env: childEnv, // Codex exec reads CODEX_API_KEY; the SDK maps apiKey to that variable. // Keep native credentials unless the worker has no configured account. ...(useOpenAiApiKey ? { apiKey: openAiApiKey } : {}), config: { - ...(reasoningSummary === undefined - ? {} - : { model_reasoning_summary: reasoningSummary }), - // The CLI can add effort levels before the pinned SDK widens ThreadOptions. - ...(this.modelSettings.reasoningEffort - ? { model_reasoning_effort: this.modelSettings.reasoningEffort } - : {}), + ...modelConfig, mcp_servers: { + ...(isRecord(modelConfig.mcp_servers) ? modelConfig.mcp_servers : {}), // Discovery workers use the bundled skills and artifacts, not the parent workbench MCP. // A disabled server still needs a valid transport while Codex resolves plugin configuration. "codex-security": { command: "node", enabled: false }, ...this.compactArtifactServer(request) }, - ...workerSubagentConfig(request.subagents) + ...workerSubagentConfig(request.subagents, modelConfig) }, // Structured SDK config cannot preserve literal filesystem keys such as // ":root" or "/repo/.env"; raw overrides keep this inline TOML intact. @@ -242,13 +263,16 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { } } -function workerSubagentConfig(subagents: number) { +function workerSubagentConfig(subagents: number, config: NonNullable) { return { // V1 counts children; V2 counts the root plus its children. Keeping its // feature disabled lets the model choose either runtime without rejecting // inherited agents.max_threads configuration. - ...(subagents > 0 ? { agents: { max_threads: subagents } } : {}), + ...(subagents > 0 + ? { agents: { ...(isRecord(config.agents) ? config.agents : {}), max_threads: subagents } } + : {}), features: { + ...(isRecord(config.features) ? config.features : {}), multi_agent_v2: { enabled: false, max_concurrent_threads_per_session: subagents + 1 @@ -264,7 +288,7 @@ function workerSubagentConfig(subagents: number) { }; } -type TomlValue = string | number | boolean | TomlObject; +type TomlValue = string | number | boolean | TomlValue[] | TomlObject; type TomlObject = { [key: string]: TomlValue }; function workerPermissionProfile( @@ -303,6 +327,7 @@ function tomlInlineValue(value: TomlValue): string { if (typeof value === "string") return tomlString(value); if (typeof value === "number") return String(value); if (typeof value === "boolean") return value ? "true" : "false"; + if (Array.isArray(value)) return `[${value.map(tomlInlineValue).join(",")}]`; return `{${Object.entries(value) .map(([key, entry]) => `${tomlKey(key)}=${tomlInlineValue(entry)}`) .join(",")}}`; @@ -379,30 +404,38 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -async function workerReasoningSummary(environment: Record): Promise { +// These are the existing non-secret selections written by the SDK preflight +// adapter. Reading only summary left provider selection in a shared home. +function workerModelSelection(config: NonNullable): TomlObject { + const result: TomlObject = {}; + for (const key of ["model", "model_provider", "model_reasoning_effort", "model_reasoning_summary", "service_tier", "model_providers"]) { + const value = config[key]; + if (value !== undefined) result[key] = value; + } + return result; +} + +async function workerModelConfig(environment: Record): Promise> { const configPath = environmentVariable(environment, "CODEX_SECURITY_CONFIG_PATH", process.platform); - if (!configPath) return undefined; + if (!configPath) return {}; const config = parseToml(await fs.readFile(configPath, "utf8")); const profiles = config.profiles; const profile = typeof config.profile === "string" && isRecord(profiles) ? profiles[config.profile] : undefined; - const summary = isRecord(profile) && profile.model_reasoning_summary !== undefined - ? profile.model_reasoning_summary - : config.model_reasoning_summary; - return typeof summary === "string" ? summary : undefined; + return workerModelSelection({ ...config, ...(isRecord(profile) ? profile : {}) } as NonNullable); } -async function snapshotWorkerEnvironment(): Promise> { +async function snapshotWorkerEnvironment(source: NodeJS.ProcessEnv = process.env): Promise> { const environment = Object.fromEntries( - Object.entries(process.env) + Object.entries(source) .filter((entry): entry is [string, string] => entry[1] !== undefined) ) as Record; if (process.platform === "win32") { // process.env is case-insensitive on Windows; a plain object is not. // Keep its selected values while giving the child one spelling per key. for (const name of ["CODEX_CLI_PATH", "CODEX_HOME", "CODEX_MANAGED_PACKAGE_ROOT", "LOCALAPPDATA"]) { - const value = process.env[name]; + const value = environmentVariable(source, name, process.platform); for (const key of Object.keys(environment)) { if (key.toUpperCase() === name) delete environment[key]; } diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 0955f3eb85..946e362b94 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -74,6 +74,8 @@ const deniedWorkerPermissionProfile = { try { await testOpenAiCredentialsReachWorker(); await testWorkerReasoningSummaries(); + await testWorkerProviderSelection(); + await testIsolatedReconstructedWorkers(); if (process.platform !== "win32") await testNullUsageCompletion(); if (process.platform !== "win32") { await testMissingParentSandboxFailsBeforeWorkerLaunch(); @@ -738,6 +740,156 @@ async function testOpenAiCredentialsReachWorker() { } } +async function testIsolatedReconstructedWorkers() { + const previousMarker = process.env.FAKE_CODEX_MARKER; + const originalSpawn = childProcess.spawn; + const scans = []; + try { + for (const name of ["first", "second"]) { + const fixture = await fakeCodexFixture(deniedWorkerPermissionProfile); + const codexHome = path.join(fixture.root, "home"); + const configPath = path.join(fixture.root, "scan config.toml"); + const promptPath = path.join(fixture.root, "prompt.md"); + await mkdir(codexHome); + const config = { + model: `fixture-${name}-inherited`, + model_provider: `fixture-${name}-provider`, + model_reasoning_effort: "medium", + model_reasoning_summary: name === "first" ? "none" : "concise", + service_tier: name === "first" ? "flex" : "fast" + }; + await writeFile(configPath, Object.entries(config).map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); + await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); + const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); + await copyFile(process.execPath, executable); + const codexOptions = { + codexPathOverride: executable, + baseUrl: `https://${name}.example.invalid/v1`, + env: { + PATH: path.dirname(process.execPath), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + CODEX_HOME: codexHome, + CODEX_SECURITY_CONFIG_PATH: configPath, + CODEX_API_KEY: `synthetic-${name}-credential`, + FAKE_CODEX_MARKER: fixture.markerPath, + FAKE_CODEX_SCAN_VALUE: name + } + }; + const settings = { + codexOptions, + model: `fixture-${name}-override`, + reasoningEffort: "ultra", + parentSandbox: trustedParentSandboxWithDenials + }; + scans.push({ name, fixture, config, configPath, promptPath, settings, executor: new CodexSdkWorkerExecutor(settings) }); + } + childProcess.spawn = (command, args, options) => { + const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); + return originalSpawn(command, scan ? [scan.fixture.executablePath, ...args] : args, options); + }; + syncBuiltinESMExports(); + + for (const phase of ["fresh", "resume", "reconstructed"]) { + if (phase === "reconstructed") { + for (const scan of scans) { + // The caller restores recorded selections. Its old config file need + // not exist; current credentials still come from the selected home/env. + await rm(scan.configPath); + scan.executor = new CodexSdkWorkerExecutor({ + ...scan.settings, + codexOptions: { ...scan.settings.codexOptions, config: scan.config } + }); + } + } + for (const kind of ["discovery", "dedup"]) { + await Promise.all(scans.map(async (scan) => { + const resumeThreadId = phase === "fresh" ? undefined : `fixture-${scan.name}-resumed`; + const result = await scan.executor.run({ + kind, promptPath: scan.promptPath, workingDirectory: scan.fixture.root, + subagents: scan.name === "first" ? 0 : 2, + resumeThreadId, continuationPrompt: "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE continuation", + signal: new AbortController().signal + }); + assert.equal(result.threadId, resumeThreadId ?? "fixture-thread-id"); + const child = JSON.parse(await readFile(scan.fixture.markerPath, "utf8")); + const preflight = JSON.parse(await readFile(scan.fixture.preflightMarkerPath, "utf8")); + assert.equal(child.executable, scan.settings.codexOptions.codexPathOverride); + assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); + assert.equal(preflight.codexHome, child.codexHome); + assert.equal(child.scanValue, scan.name); + assert.equal(child.configPath, scan.configPath); + assert.deepEqual(child.openaiAuthentication, { CODEX_API_KEY: `synthetic-${scan.name}-credential` }); + assertFlagPair(child.argv, "--model", scan.settings.model); + for (const key of ["model_provider", "model_reasoning_summary", "service_tier"]) { + const override = `${key}=${JSON.stringify(scan.config[key])}`; + assert.equal(child.argv.includes(override), true, override); + assert.equal(preflight.argv.includes(override), true, override); + } + assert.equal(child.argv.includes('model_reasoning_effort="ultra"'), true); + assert.equal(preflight.argv.includes('model_reasoning_effort="ultra"'), true); + assert.equal(preflight.argv.includes(`model=${JSON.stringify(scan.settings.model)}`), true); + const baseUrl = `openai_base_url=${JSON.stringify(scan.settings.codexOptions.baseUrl)}`; + assert.equal(child.argv.includes(baseUrl), true); + assert.equal(preflight.argv.includes(baseUrl), true); + assertReadOnlyWorkerPolicy(child.argv); + assertWorkerSubagentPolicy(child.argv, scan.name === "first" ? 0 : 2); + assert.equal(workerPermissionProfileOverride(child.argv).includes('"/repo/.env"="deny"'), true); + assert.equal(child.argv.includes("resume"), resumeThreadId !== undefined); + assert.equal(child.stdin.includes("continuation"), resumeThreadId !== undefined); + })); + } + if (phase === "fresh") { + for (const scan of scans) { + await writeFile(scan.configPath, 'model_provider = "changed-provider"\nmodel_reasoning_summary = "detailed"\n'); + } + } + } + } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); + restoreEnv("FAKE_CODEX_MARKER", previousMarker); + } +} + +async function testWorkerProviderSelection() { + const fixture = await fakeCodexFixture(); + const saved = Object.fromEntries( + ["CODEX_CLI_PATH", "CODEX_SECURITY_CONFIG_PATH", "OPENAI_API_KEY", "CODEX_API_KEY"].map((name) => [name, process.env[name]]) + ); + const originalSpawn = childProcess.spawn; + try { + delete process.env.OPENAI_API_KEY; + delete process.env.CODEX_API_KEY; + const configPath = path.join(fixture.root, "scan config.toml"); + const promptPath = path.join(fixture.root, "prompt.md"); + await writeFile(configPath, 'model_provider = "fixture-provider"\n'); + await writeFile(promptPath, "fixture provider selection"); + process.env.CODEX_CLI_PATH = process.execPath; + process.env.CODEX_SECURITY_CONFIG_PATH = configPath; + childProcess.spawn = (command, args, options) => originalSpawn( + command, + command === process.execPath || command === path.toNamespacedPath(process.execPath) + ? [fixture.executablePath, ...args] + : args, + options + ); + syncBuiltinESMExports(); + const executor = new CodexSdkWorkerExecutor({ parentSandbox: trustedParentSandbox }); + for (const kind of ["discovery", "dedup"]) { + await executor.run({ + kind, promptPath, workingDirectory: fixture.root, subagents: 0, + signal: new AbortController().signal + }); + const invocation = JSON.parse(await readFile(fixture.markerPath, "utf8")); + assert.equal(invocation.argv.includes('model_provider="fixture-provider"'), true); + } + } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); + for (const [name, value] of Object.entries(saved)) restoreEnv(name, value); + } +} + async function testNullUsageCompletion() { const fixture = await fakeCodexFixture(); const previousPath = process.env.CODEX_CLI_PATH; @@ -1505,7 +1657,7 @@ async function fakeCodexFixture( `const accountResult = ${JSON.stringify(accountResult)};`, `const preflightMarkerPath = ${JSON.stringify(preflightMarkerPath)};`, "if (process.argv.includes('app-server')) {", - " const preflight = { cwd: process.cwd(), codexHome: process.env.CODEX_HOME, requests: [] };", + " const preflight = { argv: process.argv.slice(2), cwd: process.cwd(), codexHome: process.env.CODEX_HOME, requests: [] };", " writeFileSync(preflightMarkerPath, JSON.stringify(preflight));", " let buffer = '';", " process.stdin.setEncoding('utf8');", @@ -1547,7 +1699,7 @@ async function fakeCodexFixture( "for await (const chunk of process.stdin) stdin += chunk;", "const openaiAuthentication = stdin.includes('CAPTURE_SYNTHETIC_OPENAI_AUTH') ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY } : undefined;", "const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;", - "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", + "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", "if (stdin.includes('COMPLETE_THEN_HANG')) process.on('SIGTERM', () => setTimeout(() => process.exit(0), 100));", "if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); }", "if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); }", diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 68fa92e1a4..f2ee81ecce 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -7298,6 +7298,116 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(scanSignal?.aborted).toBe(false); }); + test("isolates concurrent managed sessions at the Codex child boundary", async () => { + const clients: TestClient[] = []; + try { + const outcomes = await Promise.allSettled( + ["first", "second"].map(async (name) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + const preload = join(root, "fake-codex.mjs"); + const marker = join(root, "invocation.json"); + await Promise.all([ + mkdir(repository), + mkdir(codexHome), + mkdir(scanDir, { mode: 0o700 }), + ]); + await writeFile( + preload, + [ + 'import { writeFileSync } from "node:fs";', + 'let prompt = ""; for await (const chunk of process.stdin) prompt += chunk;', + `writeFileSync(${JSON.stringify(marker)}, JSON.stringify({args:process.argv, executable:process.execPath, home:process.env.CODEX_HOME, key:process.env.CODEX_API_KEY, value:process.env.FIXTURE_SCAN_VALUE, prompt}));`, + `console.log(JSON.stringify({type:"thread.started",thread_id:${JSON.stringify(`fixture-${name}-thread`)}}));`, + 'console.log(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"scan complete"}}));', + 'console.log(JSON.stringify({type:"turn.completed",usage:null}));', + "process.exit(0);", + ].join("\n"), + ); + const fake = nodeCodex(preload); + const model = `fixture-${name}-model`; + const provider = `fixture-${name}-provider`; + const client = new TestClient( + { + codexOverrides: { + model, + model_provider: provider, + model_reasoning_effort: "ultra", + model_reasoning_summary: name === "first" ? "none" : "concise", + features: { + multi_agent_v2: { max_concurrent_threads_per_session: 4 }, + }, + }, + }, + { + environment: { + OPENAI_API_KEY: `synthetic-${name}-key`, + CODEX_CLI_PATH: fake.command.command, + }, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment: { ...fake.environment, FIXTURE_SCAN_VALUE: name }, + }), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => { + const codex = new Codex(options); + return { + startThread: (threadOptions: ThreadOptions) => { + const thread = codex.startThread(threadOptions); + return { + get id() { + return thread.id; + }, + runStreamed: async ( + ...args: Parameters + ) => { + await copyCompletedScan(root); + return thread.runStreamed(...args); + }, + }; + }, + }; + }, + }, + ); + clients.push(client); + const result = await client.run(repository); + expect(result.threadId).toBe(`fixture-${name}-thread`); + expect(result.turnResult.usage).toBeNull(); + const child = JSON.parse(await readFile(marker, "utf8")); + expect(child.executable).toBe(fake.command.command); + expect(child.home).toBe(codexHome); + expect(child.key).toBe(`synthetic-${name}-key`); + expect(child.value).toBe(name); + expect(child.args).toContain(`model=${JSON.stringify(model)}`); + expect(child.args).toContain( + `model_provider=${JSON.stringify(provider)}`, + ); + expect(child.args).toContain('model_reasoning_effort="ultra"'); + expect(child.args).toContain( + `model_reasoning_summary=${JSON.stringify(name === "first" ? "none" : "concise")}`, + ); + expect(child.args).toContain( + "features.multi_agent_v2.max_concurrent_threads_per_session=4", + ); + expect(child.args).toContain( + 'default_permissions="codex_security_scan"', + ); + expect(child.args).toContain('approval_policy="on-request"'); + }), + ); + for (const outcome of outcomes) { + if (outcome.status === "rejected") throw outcome.reason; + } + } finally { + await Promise.all(clients.map((client) => client.close())); + } + }); + test("closes a real Codex subprocess cleanly after a streamed terminal failure", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); From ce2fcc87893c1a4229567afcae7f0be2d211da2f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:39:07 +0000 Subject: [PATCH 017/251] test: exercise Deep lifecycle against installed plugins --- .../mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index e2a0fd3cda..7d8ca664a1 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -10,7 +10,10 @@ import { build } from "esbuild"; const execFileAsync = promisify(execFile); const mcpAppRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const pluginRoot = path.resolve(mcpAppRoot, ".."); +const installedPluginRoot = process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT; +const pluginRoot = installedPluginRoot + ? path.resolve(installedPluginRoot) + : path.resolve(mcpAppRoot, ".."); const workbenchPath = path.join(pluginRoot, "scripts", "workbench_db.py"); const parentSandboxState = { permissionProfile: { @@ -55,7 +58,7 @@ async function testDeepScanStdioLifecycle() { const serverBundlePath = path.join( pluginRoot, "mcp", - `.deep-scan-stdio-test-${randomUUID()}.cjs` + installedPluginRoot ? "server.mjs" : `.deep-scan-stdio-test-${randomUUID()}.cjs` ); const threadId = "deep-scan-stdio-lifecycle-thread"; @@ -86,7 +89,7 @@ async function testDeepScanStdioLifecycle() { '' ].join('\n')); await writePythonWrapper(pythonWrapperPath); - await bundleServer(serverBundlePath); + if (!installedPluginRoot) await bundleServer(serverBundlePath); const environment = { ...process.env, @@ -569,7 +572,7 @@ async function testDeepScanStdioLifecycle() { throw error; } finally { await server.stop(); - await rm(serverBundlePath, { force: true }); + if (!installedPluginRoot) await rm(serverBundlePath, { force: true }); await rm(fixtureRoot, { recursive: true, force: true }); } } From 6ab9c7b75742f1a1658c0256122209a13be06b97 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:55:03 +0000 Subject: [PATCH 018/251] test: cover managed modes and follow-up child sessions --- sdk/typescript/tests-ts/api.test.ts | 259 +++++++++++++++++----------- 1 file changed, 155 insertions(+), 104 deletions(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index f2ee81ecce..227d137092 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -7298,115 +7298,166 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(scanSignal?.aborted).toBe(false); }); - test("isolates concurrent managed sessions at the Codex child boundary", async () => { - const clients: TestClient[] = []; - try { - const outcomes = await Promise.allSettled( - ["first", "second"].map(async (name) => { - const root = await temporaryDirectory(); - const repository = join(root, "repository"); - const codexHome = join(root, "codex-home"); - const scanDir = join(root, "scan"); - const preload = join(root, "fake-codex.mjs"); - const marker = join(root, "invocation.json"); - await Promise.all([ - mkdir(repository), - mkdir(codexHome), - mkdir(scanDir, { mode: 0o700 }), - ]); - await writeFile( - preload, - [ - 'import { writeFileSync } from "node:fs";', - 'let prompt = ""; for await (const chunk of process.stdin) prompt += chunk;', - `writeFileSync(${JSON.stringify(marker)}, JSON.stringify({args:process.argv, executable:process.execPath, home:process.env.CODEX_HOME, key:process.env.CODEX_API_KEY, value:process.env.FIXTURE_SCAN_VALUE, prompt}));`, - `console.log(JSON.stringify({type:"thread.started",thread_id:${JSON.stringify(`fixture-${name}-thread`)}}));`, - 'console.log(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"scan complete"}}));', - 'console.log(JSON.stringify({type:"turn.completed",usage:null}));', - "process.exit(0);", - ].join("\n"), - ); - const fake = nodeCodex(preload); - const model = `fixture-${name}-model`; - const provider = `fixture-${name}-provider`; - const client = new TestClient( - { - codexOverrides: { - model, - model_provider: provider, - model_reasoning_effort: "ultra", - model_reasoning_summary: name === "first" ? "none" : "concise", - features: { - multi_agent_v2: { max_concurrent_threads_per_session: 4 }, + test.each(["standard", "deep"] as const)( + "isolates concurrent managed %s sessions at the Codex child boundary", + async (mode) => { + const clients: TestClient[] = []; + try { + const outcomes = await Promise.allSettled( + ["first", "second"].map(async (name) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + const preload = join(root, "fake-codex.mjs"); + const marker = join(root, "invocation.jsonl"); + await Promise.all([ + mkdir(repository), + mkdir(codexHome), + mkdir(scanDir, { mode: 0o700 }), + ]); + await writeFile( + preload, + [ + 'import { appendFileSync } from "node:fs";', + 'let prompt = ""; for await (const chunk of process.stdin) prompt += chunk;', + `appendFileSync(${JSON.stringify(marker)}, JSON.stringify({args:process.argv, executable:process.execPath, home:process.env.CODEX_HOME, key:process.env.CODEX_API_KEY, value:process.env.FIXTURE_SCAN_VALUE, prompt}) + "\\n");`, + `console.log(JSON.stringify({type:"thread.started",thread_id:${JSON.stringify(`fixture-${name}-thread`)}}));`, + 'console.log(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"scan complete"}}));', + 'console.log(JSON.stringify({type:"turn.completed",usage:null}));', + "process.exit(0);", + ].join("\n"), + ); + const fake = nodeCodex(preload); + const model = `fixture-${name}-model`; + const provider = `fixture-${name}-provider`; + const client = new TestClient( + { + codexOverrides: { + model, + model_provider: provider, + model_reasoning_effort: "ultra", + model_reasoning_summary: + name === "first" ? "none" : "concise", + features: { + multi_agent_v2: { max_concurrent_threads_per_session: 4 }, + }, }, }, - }, - { - environment: { - OPENAI_API_KEY: `synthetic-${name}-key`, - CODEX_CLI_PATH: fake.command.command, - }, - prepareRuntime: async () => ({ - ...preparedRuntime(codexHome), - environment: { ...fake.environment, FIXTURE_SCAN_VALUE: name }, - }), - resolvePluginPython: async () => "/managed/python", - prepareOutputDir: async () => scanDir, - repositoryRevision: async () => "deadbeef", - createCodex: (options: CodexOptions) => { - const codex = new Codex(options); - return { - startThread: (threadOptions: ThreadOptions) => { - const thread = codex.startThread(threadOptions); - return { - get id() { - return thread.id; - }, - runStreamed: async ( - ...args: Parameters - ) => { - await copyCompletedScan(root); - return thread.runStreamed(...args); - }, - }; + { + environment: { + OPENAI_API_KEY: `synthetic-${name}-key`, + CODEX_CLI_PATH: fake.command.command, + }, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment: { + ...fake.environment, + FIXTURE_SCAN_VALUE: name, }, - }; + }), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: (options: CodexOptions) => { + const codex = new Codex(options); + return { + startThread: (threadOptions: ThreadOptions) => { + const thread = codex.startThread(threadOptions); + return { + get id() { + return thread.id; + }, + runStreamed: async ( + ...args: Parameters + ) => { + if (thread.id === null) { + await copyCompletedScan(root); + if (mode === "deep") { + const coveragePath = join( + scanDir, + "coverage.json", + ); + const coverage = JSON.parse( + await readFile(coveragePath, "utf8"), + ); + coverage.mode = "deep_repository"; + const coverageBytes = JSON.stringify(coverage); + await writeFile(coveragePath, coverageBytes); + const manifestPath = join( + scanDir, + "scan-manifest.json", + ); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ); + manifest.scan.artifacts.find( + (artifact: { path: string }) => + artifact.path === "coverage.json", + ).sha256 = createHash("sha256") + .update(coverageBytes) + .digest("hex"); + await writeFile( + manifestPath, + JSON.stringify(manifest), + ); + } + } + return thread.runStreamed(...args); + }, + }; + }, + }; + }, }, - }, - ); - clients.push(client); - const result = await client.run(repository); - expect(result.threadId).toBe(`fixture-${name}-thread`); - expect(result.turnResult.usage).toBeNull(); - const child = JSON.parse(await readFile(marker, "utf8")); - expect(child.executable).toBe(fake.command.command); - expect(child.home).toBe(codexHome); - expect(child.key).toBe(`synthetic-${name}-key`); - expect(child.value).toBe(name); - expect(child.args).toContain(`model=${JSON.stringify(model)}`); - expect(child.args).toContain( - `model_provider=${JSON.stringify(provider)}`, - ); - expect(child.args).toContain('model_reasoning_effort="ultra"'); - expect(child.args).toContain( - `model_reasoning_summary=${JSON.stringify(name === "first" ? "none" : "concise")}`, - ); - expect(child.args).toContain( - "features.multi_agent_v2.max_concurrent_threads_per_session=4", - ); - expect(child.args).toContain( - 'default_permissions="codex_security_scan"', - ); - expect(child.args).toContain('approval_policy="on-request"'); - }), - ); - for (const outcome of outcomes) { - if (outcome.status === "rejected") throw outcome.reason; + ); + clients.push(client); + const postScanPrompt = "Summarize the completed synthetic scan."; + const result = await client.run(repository, { + mode, + postScanPrompt, + }); + expect(result.threadId).toBe(`fixture-${name}-thread`); + expect(result.turnResult.usage).toBeNull(); + const children = (await readFile(marker, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(children).toHaveLength(2); + expect(children[1].prompt).toBe(postScanPrompt); + expect(children[1].args).toContain("resume"); + expect(children[1].args).toContain(`fixture-${name}-thread`); + for (const child of children) { + expect(child.executable).toBe(fake.command.command); + expect(child.home).toBe(codexHome); + expect(child.key).toBe(`synthetic-${name}-key`); + expect(child.value).toBe(name); + expect(child.args).toContain(`model=${JSON.stringify(model)}`); + expect(child.args).toContain( + `model_provider=${JSON.stringify(provider)}`, + ); + expect(child.args).toContain('model_reasoning_effort="ultra"'); + expect(child.args).toContain( + `model_reasoning_summary=${JSON.stringify(name === "first" ? "none" : "concise")}`, + ); + expect(child.args).toContain( + "features.multi_agent_v2.max_concurrent_threads_per_session=4", + ); + expect(child.args).toContain( + 'default_permissions="codex_security_scan"', + ); + expect(child.args).toContain('approval_policy="on-request"'); + } + }), + ); + for (const outcome of outcomes) { + if (outcome.status === "rejected") throw outcome.reason; + } + } finally { + await Promise.all(clients.map((client) => client.close())); } - } finally { - await Promise.all(clients.map((client) => client.close())); - } - }); + }, + ); test("closes a real Codex subprocess cleanly after a streamed terminal failure", async () => { const root = await temporaryDirectory(); From 5f7bbe2e23b58ecb6697f947a888631688eaa014 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:55:06 +0000 Subject: [PATCH 019/251] Reserve frozen checkpoint selections and guard requested workflows --- .../scripts/deep_scan_workbench.py | 3 ++ .../scripts/workbench_schema.py | 7 +++ .../tests/test_deep_scan_compatibility.py | 43 +++++++++++++++++++ .../codex-security/tests/test_workbench_db.py | 2 +- .../test_workbench_setup_and_migrations.py | 8 +++- 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 5d1970a243..bbd8fd16aa 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -983,6 +983,9 @@ def begin_deep_scan_for_target( def begin_deep_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: + workflow_version = optional_text(args.workflow_version, maximum=256) + if workflow_version not in SUPPORTED_DEEP_SCAN_WORKFLOWS: + raise SystemExit("This Deep Scan uses an unsupported workflow version.") thread_id = optional_text(args.thread_id, maximum=512) if thread_id is None: raise SystemExit("thread-id is required.") diff --git a/plugins/codex-security/scripts/workbench_schema.py b/plugins/codex-security/scripts/workbench_schema.py index 1c9fc88e7e..e3ce1647c1 100644 --- a/plugins/codex-security/scripts/workbench_schema.py +++ b/plugins/codex-security/scripts/workbench_schema.py @@ -932,6 +932,13 @@ ALTER TABLE deep_scan_runs ADD COLUMN finalization_input_json TEXT; """, ), + ( + 47, + "freeze stopped scan checkpoint selections", + """ + ALTER TABLE scans ADD COLUMN retained_checkpoint_heads_json TEXT; + """, + ), ) diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py index faf97dc056..b9305b87a0 100644 --- a/plugins/codex-security/tests/test_deep_scan_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_compatibility.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import sqlite3 import uuid from pathlib import Path @@ -156,3 +157,45 @@ def test_reader_honors_original_context_when_present(tmp_path: Path, original: s "fixture-thread", )["deepScan"] assert observed["userContext"] == original + + +def test_unsupported_new_workflow_does_not_claim_registered_scan(tmp_path: Path) -> None: + state = tmp_path / "state" + target = tmp_path / "target" + target.mkdir() + scan_dir = tmp_path / "scan" + scan_dir.mkdir(mode=0o700) + registered = run_workbench( + state, + "register-cli-scan", + "--scan-dir", + str(scan_dir), + "--repository", + str(target), + "--registration-json-stdin", + input_text=json.dumps( + { + "recipe": { + "config": {}, + "mode": "deep", + "repository": str(target), + "target": {"kind": "repository", "paths": []}, + } + } + ), + ) + before = snapshot(state) + rejected = run_workbench( + state, + "begin-deep-scan", + "--scan-id", + str(registered["scanId"]), + "--thread-id", + "fixture-thread", + "--workflow-version", + "future/v99", + check=False, + ) + assert rejected["returncode"] != 0 + assert "unsupported" in str(rejected["stderr"]).lower() + assert snapshot(state) == before diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 2f3ad542b5..5ad21c2c8a 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -1045,7 +1045,7 @@ def test_workbench_persists_progress_and_indexes_completed_findings(tmp_path: Pa ) } assert tables == EXPECTED_TABLES - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (44,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (45,) assert connection.execute("SELECT COUNT(*) FROM findings").fetchone() == (1,) assert connection.execute("SELECT COUNT(*) FROM finding_locations").fetchone() == (1,) diff --git a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py index 09d969b7de..6a793f66a7 100644 --- a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py +++ b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py @@ -405,7 +405,7 @@ def test_workbench_serializes_concurrent_first_run_migrations(tmp_path: Path) -> {"databasePath": str(state_dir / "workbench.sqlite3")}, ] with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (44,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (45,) @pytest.mark.parametrize("previous_history", ["main", "comparison-preview"]) @@ -870,6 +870,7 @@ def test_workbench_creates_single_final_schema(tmp_path: Path) -> None: (44, "preserve original deep scan discovery context"), (45, "retain deep scan attempts and exact merge inputs"), (46, "persist selected deep scan finalization input"), + (47, "freeze stopped scan checkpoint selections"), ] assert {row[1] for row in connection.execute("PRAGMA table_info(workspaces)")} >= { "diff_target_kind", @@ -972,7 +973,7 @@ def test_workbench_upgrades_preexisting_database(tmp_path: Path) -> None: connection.execute("ALTER TABLE scans DROP COLUMN handoff_claim_token") run_workbench(state_dir, "database-info") with sqlite3.connect(database) as connection: - assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (46,) + assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (47,) assert {row[1] for row in connection.execute("PRAGMA table_info(scans)")} >= { "handoff_claimed_at", "handoff_claim_token", @@ -2002,6 +2003,7 @@ def test_workbench_upgrades_released_database_schema(tmp_path: Path) -> None: (44, "preserve original deep scan discovery context"), (45, "retain deep scan attempts and exact merge inputs"), (46, "persist selected deep scan finalization input"), + (47, "freeze stopped scan checkpoint selections"), ] assert "capability_preflight_json" in { row[1] for row in connection.execute("PRAGMA table_info(workspaces)") @@ -2088,6 +2090,7 @@ def test_workbench_upgrades_pre_release_phase_progress_migration(tmp_path: Path) (44, "preserve original deep scan discovery context"), (45, "retain deep scan attempts and exact merge inputs"), (46, "persist selected deep scan finalization input"), + (47, "freeze stopped scan checkpoint selections"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") @@ -2182,6 +2185,7 @@ def test_workbench_upgrades_pre_release_preflight_progress_migration(tmp_path: P (44, "preserve original deep scan discovery context"), (45, "retain deep scan attempts and exact merge inputs"), (46, "persist selected deep scan finalization input"), + (47, "freeze stopped scan checkpoint selections"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") From 69df53e59463dcdf3f84ddb1d3e318fe370b9118 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:57:38 +0000 Subject: [PATCH 020/251] test(sdk): exercise installed Deep scan lifecycle and recovery --- .../scripts/fixtures/package-deep-codex.mjs | 175 +++++++ .../scripts/fixtures/package-deep-scan.mjs | 489 ++++++++++++++++++ .../scripts/fixtures/package-deep-spawn.mjs | 20 + .../scripts/fixtures/package-rpc.mjs | 90 ++++ sdk/typescript/scripts/smoke-package.mjs | 9 + 5 files changed, 783 insertions(+) create mode 100644 sdk/typescript/scripts/fixtures/package-deep-codex.mjs create mode 100644 sdk/typescript/scripts/fixtures/package-deep-scan.mjs create mode 100644 sdk/typescript/scripts/fixtures/package-deep-spawn.mjs create mode 100644 sdk/typescript/scripts/fixtures/package-rpc.mjs diff --git a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs new file mode 100644 index 0000000000..4d645dfb9f --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { appendFile, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { createInterface } from "node:readline"; +import { startRpc } from "./package-rpc.mjs"; + +try { + await run(); + process.exit(0); +} catch (error) { + await trace({ phase: "fixture-error", error: error.stack }); + console.error(error); + process.exit(1); +} + +async function trace(event) { + await appendFile( + process.env.PACKAGE_DEEP_TRACE, + `${JSON.stringify(event)}\n`, + ); +} + +async function run() { + const args = process.argv.slice(2); + if (args.includes("app-server")) { + await trace({ phase: "preflight", args }); + for await (const line of createInterface({ input: process.stdin })) { + const message = JSON.parse(line); + if (message.id === undefined) continue; + let result; + switch (message.method) { + case "initialize": + result = { userAgent: "package-fixture" }; + break; + case "config/read": + result = { + config: { + default_permissions: "codex_security_deep_scan_worker", + permissions: { + codex_security_deep_scan_worker: { + extends: ":read-only", + filesystem: { ":root": "read" }, + network: { enabled: false }, + }, + }, + }, + origins: {}, + layers: null, + }; + break; + case "permissionProfile/list": + result = { + data: [ + { + id: "codex_security_deep_scan_worker", + description: null, + allowed: true, + }, + ], + nextCursor: null, + }; + break; + case "account/read": + result = { account: null, requiresOpenaiAuth: true }; + break; + default: + throw new Error(`Unexpected preflight method: ${message.method}`); + } + console.log(JSON.stringify({ jsonrpc: "2.0", id: message.id, result })); + } + return; + } + let prompt = ""; + for await (const chunk of process.stdin) prompt += chunk; + const config = {}; + for (let index = 0; index < args.length; index++) { + if (args[index] !== "-c" && args[index] !== "--config") continue; + const setting = args[++index]; + const equals = setting.indexOf("="); + config[setting.slice(0, equals)] = setting.slice(equals + 1); + } + const prefix = "mcp_servers.cs_artifacts."; + const env = Object.fromEntries( + Object.entries(config) + .filter(([name]) => name.startsWith(`${prefix}env.`)) + .map(([name, value]) => [ + name.slice(`${prefix}env.`.length), + JSON.parse(value), + ]), + ); + const root = env.CODEX_SECURITY_ARTIFACT_ROOT; + assert.ok(root, "The real worker must supply its bound artifact root."); + assert.equal(config["mcp_servers.codex-security.enabled"], "false"); + const layout = env.CODEX_SECURITY_ARTIFACT_LAYOUT; + const threadId = `package-${layout}-${basename(root)}-${basename(join(root, ".."))}`; + console.log(JSON.stringify({ type: "thread.started", thread_id: threadId })); + if ( + layout === "worker" && + basename(join(root, "..")) === "discovery-0002" && + process.env.PACKAGE_DEEP_HOLD && + existsSync(process.env.PACKAGE_DEEP_HOLD) + ) { + await trace({ phase: "held", scanId: env.CODEX_SECURITY_SCAN_ID }); + await new Promise(() => setInterval(() => {}, 1_000)); + } + const server = await startRpc( + JSON.parse(config[`${prefix}command`]), + JSON.parse(config[`${prefix}args`]), + { cwd: root, env: { ...process.env, ...env } }, + ); + let complete = true; + try { + if (layout === "worker") { + const draft = { + scanId: env.CODEX_SECURITY_SCAN_ID, + findings: [], + coverage: { + completeness: "complete", + surfaces: [], + explicitExclusions: [], + deferred: [], + }, + }; + const marker = process.env.PACKAGE_DEEP_EMPTY_ONCE; + if (marker && !existsSync(marker)) { + await writeFile(marker, "process completed without a final artifact"); + complete = false; + } else { + await server.call("record_codex_security_scan_draft", { + ...draft, + complete: false, + }); + await server.call("record_codex_security_scan_draft", { + ...draft, + complete: true, + }); + } + } else { + assert.equal(layout, "reducer"); + const inputs = await server.call( + "get_codex_security_deep_reducer_inputs", + {}, + ); + assert.ok(inputs.discoveries.length > 0); + await server.call("record_codex_security_deep_reduction", { + scanId: env.CODEX_SECURITY_SCAN_ID, + findings: [], + }); + } + await trace({ + phase: layout, + complete, + resumed: args.includes("resume"), + scanId: env.CODEX_SECURITY_SCAN_ID, + home: process.env.CODEX_HOME, + hasApiKey: process.env.CODEX_API_KEY === "synthetic-package-deep-key", + root, + args, + }); + console.log( + JSON.stringify({ + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1, + }, + }), + ); + } finally { + await server.close(); + } +} diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs new file mode 100644 index 0000000000..248d6031bb --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -0,0 +1,489 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + chmod, + copyFile, + cp, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { startRpc } from "./package-rpc.mjs"; +import { packageSmokeTimeouts } from "../package-smoke-timeouts.mjs"; + +const installedRoot = await realpath(process.argv[2]); +const root = await realpath( + await mkdtemp(join(tmpdir(), "package deep % fixture-")), +); +const installedPlugin = join(installedRoot, "_bundled_plugin"); +try { + const detachedPlugin = join(root, "standalone plugin %", "codex-security"); + await cp(installedPlugin, detachedPlugin, { recursive: true }); + // Assert physical independence instead of merely changing the working directory. + for (let ancestor = detachedPlugin; ; ancestor = dirname(ancestor)) { + for (const dependency of ["node_modules", join("sdk", "typescript")]) { + await assert.rejects(stat(join(ancestor, dependency)), { + code: "ENOENT", + }); + } + if (ancestor === dirname(ancestor)) break; + } + for (const name of [ + "package-deep-codex.mjs", + "package-rpc.mjs", + "package-deep-spawn.mjs", + ]) { + await copyFile(new URL(name, import.meta.url), join(root, name)); + } + const executable = join( + root, + process.platform === "win32" + ? "package-codex.exe" + : "package-deep-codex.mjs", + ); + if (process.platform === "win32") + await copyFile(process.execPath, executable); + await chmod(executable, 0o700); + + await runInstalledSdk(installedPlugin, executable); + await runDetachedPlugin(detachedPlugin, executable); + console.log( + "Validated installed SDK and detached plugin: real Deep processes, bound artifact tools, checkpoints, reducer acceptance, restart before finalization, and sealed results.", + ); +} catch (error) { + for (const name of ["installed", "detached"]) { + try { + error.message += `\n${await readFile(join(root, name, "executions.jsonl"), "utf8")}`; + } catch (readError) { + if (readError.code !== "ENOENT") throw readError; + } + } + throw error; +} finally { + await rm(root, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); +} + +async function fixture(name, pluginRoot, executable) { + const directory = join(root, name); + const target = join(directory, "target with spaces %"); + const home = join(directory, "home"); + await mkdir(target, { recursive: true }); + await mkdir(join(home, "codex-security"), { recursive: true }); + await writeFile( + join(target, "fixture.py"), + "print('synthetic package fixture')\n", + ); + await writeFile( + join(home, "codex-security", "config.toml"), + "[deep_scan]\nworkers = 1\nsubagents = 0\nstop_after_no_new = 1\nmax_discovery_runs = 2\n", + ); + const env = Object.fromEntries( + [ + "PATH", + "Path", + "SystemRoot", + "WINDIR", + "ComSpec", + "PATHEXT", + "TMP", + "TEMP", + "TMPDIR", + ] + .filter((key) => process.env[key] !== undefined) + .map((key) => [key, process.env[key]]), + ); + Object.assign(env, { + HOME: home, + USERPROFILE: home, + CODEX_HOME: home, + CODEX_CLI_PATH: executable, + CODEX_SECURITY_PLUGIN_ROOT: pluginRoot, + CODEX_SECURITY_STATE_DIR: join(directory, "state"), + CODEX_SECURITY_SCAN_ROOT: join(directory, "scans"), + PYTHON: process.env.PYTHON || "python3", + OPENAI_API_KEY: "synthetic-package-deep-key", + ...(process.platform === "win32" + ? { + PACKAGE_DEEP_EXECUTABLE: executable, + NODE_OPTIONS: `--import=${pathToFileURL(join(root, "package-deep-spawn.mjs")).href}`, + } + : {}), + PACKAGE_DEEP_TRACE: join(directory, "executions.jsonl"), + }); + return { directory, target, home, env, pluginRoot }; +} + +function metadata(f, owner) { + return { + "openai/threadId": owner, + "codex/sandbox-state-meta": { + permissionProfile: { + type: "managed", + file_system: { + type: "restricted", + entries: [ + { + path: { type: "special", value: { kind: "root" } }, + access: "read", + }, + ], + }, + network: "restricted", + }, + sandboxCwd: pathToFileURL(f.target).href, + }, + "x-codex-turn-metadata": { model: "gpt-5.5", reasoning_effort: "high" }, + }; +} + +function server(f, env = f.env) { + return startRpc( + process.execPath, + [join(f.pluginRoot, "mcp", "server.mjs"), "--stdio"], + { + cwd: f.target, + env, + requestTimeoutMs: packageSmokeTimeouts().commandTimeoutMs, + }, + ); +} + +async function runDetachedPlugin(pluginRoot, executable) { + const f = await fixture("detached", pluginRoot, executable); + const owner = "package-detached-owner"; + f.env.PACKAGE_DEEP_HOLD = join(f.directory, "hold-second-worker"); + await writeFile(f.env.PACKAGE_DEEP_HOLD, "hold"); + let rpc = await server(f); + let scanId; + let scanDir; + let partial; + const handoffClaimToken = randomUUID(); + try { + const opened = await rpc.call( + "open_codex_security_workspace", + { + targetPath: f.target, + scope: ".", + mode: "deep", + }, + metadata(f, owner), + ); + const sessionId = opened.workspace.id; + await rpc.call( + "submit_codex_security_setup", + { + sessionId, + targetPath: f.target, + scope: ".", + mode: "deep", + }, + metadata(f, owner), + ); + const started = await rpc.call( + "start_codex_security_scan", + { sessionId }, + metadata(f, owner), + ); + ({ scanId, scanDir } = started.workspace.results); + await rpc.call( + "claim_codex_security_scan_handoff_delivery", + { + scanId, + claimToken: handoffClaimToken, + }, + metadata(f, owner), + ); + await rpc.call( + "attach_codex_security_scan_continuation_thread", + { + scanId, + claimToken: handoffClaimToken, + threadId: owner, + }, + metadata(f, owner), + ); + const pending = rpc + .call( + "start_codex_security_deep_scan", + { scanId, handoffClaimToken }, + metadata(f, owner), + ) + .catch((error) => error); + const deadline = Date.now() + 30_000; + while (!(await readExecutions(f)).some((entry) => entry.phase === "held")) { + assert.ok( + Date.now() < deadline, + "Second worker did not reach the interruption boundary.", + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + partial = ( + await workbench(f, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + owner, + ]) + ).deepScan; + assert.equal( + partial.workers.filter( + (worker) => + worker.kind === "discovery" && worker.status === "succeeded", + ).length, + 1, + ); + await rpc.close(); + await pending; + } finally { + await rpc.close(); + } + // Simulate an expired owner lease without waiting for wall-clock expiry. Keep + // the real stored workers/results and use the production recovery path. + await rm( + join( + scanDir, + "artifacts", + "deep_discovery", + `coordinator-heartbeat-${partial.coordinatorGeneration}.json`, + ), + { force: true }, + ); + await promisify(execFile)( + f.env.PYTHON, + [ + "-c", + "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('UPDATE deep_scan_runs SET updated_at = ? WHERE scan_id = ?', ('2000-01-01T00:00:00Z',sys.argv[2])); c.commit()", + join(f.env.CODEX_SECURITY_STATE_DIR, "workbench.sqlite3"), + scanId, + ], + { env: f.env }, + ); + await rm(f.env.PACKAGE_DEEP_HOLD); + rpc = await server(f); + try { + const result = await rpc.call( + "start_codex_security_deep_scan", + { scanId, handoffClaimToken }, + metadata(f, owner), + ); + await assertDraft(result.manifestPath); + const recovered = ( + await workbench(f, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + owner, + ]) + ).deepScan; + assert.equal( + recovered.coordinatorGeneration, + partial.coordinatorGeneration + 1, + ); + assert.equal(recovered.dispatchedCount, 2); + const retained = partial.workers.find( + (worker) => worker.status === "succeeded", + ); + assert.equal( + recovered.workers.find((worker) => worker.id === retained.id).status, + "succeeded", + ); + } finally { + await rpc.close(); + } + // Publication uses the saved aggregate after the recovered executor exits too. + rpc = await server(f); + try { + await rpc.call( + "complete_codex_security_scan", + { scanId, handoffClaimToken }, + metadata(f, owner), + ); + const completed = await rpc.call( + "get_codex_security_completed_scan", + { scanId, handoffClaimToken }, + metadata(f, owner), + ); + assert.equal(completed.manifest.scan.id, scanId); + assert.equal(completed.manifest.scan.status, "completed"); + assert.ok(completed.manifest.scan.sealedAt); + } finally { + await rpc.close(); + } + await assertExecutions(f, scanId, 4); +} + +async function workbench(f, args) { + const { stdout } = await promisify(execFile)( + f.env.PYTHON, + [join(f.pluginRoot, "scripts", "workbench_db.py"), ...args], + { + env: f.env, + cwd: f.target, + maxBuffer: 4 * 1024 * 1024, + }, + ); + return JSON.parse(stdout); +} + +async function runInstalledSdk(pluginRoot, executable) { + const f = await fixture("installed", pluginRoot, executable); + f.env.PACKAGE_DEEP_EMPTY_ONCE = join( + f.directory, + "missing-result-completion", + ); + const sdk = await import( + pathToFileURL(join(installedRoot, "dist", "index.js")).href + ); + const manifest = JSON.parse( + await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), + ); + const owner = "package-sdk-owner"; + let scanId; + const client = new sdk.CodexSecurity( + { pythonPath: f.env.PYTHON }, + { + environment: f.env, + prepareRuntime: async () => ({ + codexHome: f.home, + environment: f.env, + credentialsAvailable: true, + plugin: { + pluginRoot, + marketplaceRoot: pluginRoot, + installedRoot: pluginRoot, + marketplaceName: "codex-security-sdk", + name: manifest.name, + version: manifest.version, + }, + }), + // Replace only the parent model's tool choice. The installed SDK registers + // and finalizes the scan; the packaged MCP runs the real Deep lifecycle. + createCodex({ env, apiKey }) { + return { + startThread() { + return { + id: owner, + async runStreamed() { + return { + events: (async function* () { + yield { type: "thread.started", thread_id: owner }; + scanId = env.CODEX_SECURITY_SCAN_ID; + // The pinned SDK maps its apiKey option to this child variable. + const rpc = await server(f, { + ...env, + ...(apiKey ? { CODEX_API_KEY: apiKey } : {}), + }); + try { + const result = await rpc.call( + "start_codex_security_deep_scan", + { scanId }, + metadata(f, owner), + ); + await assertDraft(result.manifestPath); + } finally { + await rpc.close(); + } + yield { + type: "turn.completed", + usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1, + }, + }; + })(), + }; + }, + }; + }, + }; + }, + }, + ); + try { + const result = await client.run(f.target, { + mode: "deep", + auth: "api-key", + workers: 1, + subagents: 0, + maxDiscoveryRuns: 2, + stopAfterNoNew: 1, + outputDir: join(f.directory, "output"), + }); + assert.equal(result.threadId, owner); + assert.equal(result.manifest.scan.status, "completed"); + assert.ok(result.manifest.scan.sealedAt); + assert.equal(result.manifest.scan.id, scanId); + assert.deepEqual(result.findings.findings, []); + assert.ok( + (await readFile(join(f.directory, "output", "report.md"), "utf8")) + .length > 0, + ); + } finally { + await client.close(); + } + await assertExecutions(f, scanId, 4); +} + +async function assertDraft(path) { + const document = JSON.parse(await readFile(path, "utf8")); + const findings = JSON.parse( + await readFile(join(dirname(path), "findings.json"), "utf8"), + ); + assert.deepEqual(findings.findings, []); + assert.ok(document.scan.target); +} + +async function readExecutions(f) { + try { + return (await readFile(f.env.PACKAGE_DEEP_TRACE, "utf8")) + .trim() + .split("\n") + .filter(Boolean) + .map(JSON.parse); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } +} + +async function assertExecutions(f, scanId, preflights = 3) { + const executions = await readExecutions(f); + const workers = executions.filter((entry) => entry.phase === "worker"); + const reducers = executions.filter((entry) => entry.phase === "reducer"); + const incomplete = f.env.PACKAGE_DEEP_EMPTY_ONCE ? 1 : 0; + assert.equal(workers.length, 2 + incomplete); + assert.equal(workers.filter((entry) => !entry.complete).length, incomplete); + assert.equal(workers.filter((entry) => entry.resumed).length, incomplete); + assert.equal(reducers.length, 1); + assert.equal( + executions.filter((entry) => entry.phase === "preflight").length, + preflights, + ); + for (const execution of [...workers, ...reducers]) { + assert.equal(execution.scanId, scanId); + assert.equal(execution.home, f.home); + assert.equal(execution.hasApiKey, true); + assert.equal( + execution.args[execution.args.indexOf("--model") + 1], + "gpt-5.5", + ); + assert.ok(execution.args.includes('approval_policy="never"')); + } +} diff --git a/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs b/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs new file mode 100644 index 0000000000..e594405586 --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs @@ -0,0 +1,20 @@ +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { win32 } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Windows cannot execute the POSIX fixture's shebang. Preserve the selected +// native executable and its options, inserting only the deterministic protocol +// script, as in the worker launch tests. Every other child runs unchanged. +const executable = win32.toNamespacedPath(process.env.PACKAGE_DEEP_EXECUTABLE); +const script = fileURLToPath( + new URL("package-deep-codex.mjs", import.meta.url), +); +const spawn = childProcess.spawn; +childProcess.spawn = (command, args, options) => + spawn( + command, + win32.toNamespacedPath(command) === executable ? [script, ...args] : args, + options, + ); +syncBuiltinESMExports(); diff --git a/sdk/typescript/scripts/fixtures/package-rpc.mjs b/sdk/typescript/scripts/fixtures/package-rpc.mjs new file mode 100644 index 0000000000..ceb6156f2a --- /dev/null +++ b/sdk/typescript/scripts/fixtures/package-rpc.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createInterface } from "node:readline"; + +// This client deliberately uses only Node builtins. Detached plugin tests must +// not resolve an MCP client or SDK from the checkout's node_modules. +export async function startRpc(command, args, options) { + const { requestTimeoutMs = 30_000, ...spawnOptions } = options; + const child = spawn(command, args, { + ...spawnOptions, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + let sequence = 0; + let stderr = ""; + const pending = new Map(); + child.stderr.setEncoding("utf8").on("data", (chunk) => { + stderr += chunk; + }); + createInterface({ input: child.stdout }).on("line", (line) => { + const response = JSON.parse(line); + const waiter = pending.get(response.id); + if (!waiter) return; + pending.delete(response.id); + clearTimeout(waiter.timer); + if (response.error) + waiter.reject(new Error(JSON.stringify(response.error))); + else waiter.resolve(response.result); + }); + const exited = once(child, "exit"); + child.on("exit", (code, signal) => { + for (const waiter of pending.values()) { + clearTimeout(waiter.timer); + waiter.reject( + new Error(`Fixture RPC exited (${code}, ${signal}): ${stderr}`), + ); + } + pending.clear(); + }); + const client = { + child, + request(method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++sequence; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Fixture RPC timed out: ${method}\n${stderr}`)); + }, requestTimeoutMs); + pending.set(id, { resolve, reject, timer }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, + ); + }); + }, + async call(name, args, meta) { + const result = await this.request("tools/call", { + name, + arguments: args, + ...(meta ? { _meta: meta } : {}), + }); + assert.notEqual(result.isError, true, JSON.stringify(result)); + return result.structuredContent ?? JSON.parse(result.content[0].text); + }, + async close() { + if (child.exitCode !== null || child.signalCode !== null) return; + child.stdin.end(); + const timeout = setTimeout(() => child.kill("SIGKILL"), 5_000); + try { + await exited; + } finally { + clearTimeout(timeout); + } + }, + }; + try { + await client.request("initialize", { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "installed-deep-fixture", version: "1.0.0" }, + }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`, + ); + return client; + } catch (error) { + await client.close(); + throw error; + } +} diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 60945f75dd..39f834a5de 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -808,6 +808,15 @@ try { ); await smokeNestedDeepScanWorker(installedRoot, consumer); + run( + process.execPath, + [ + join(packageRoot, "scripts", "fixtures", "package-deep-scan.mjs"), + installedRoot, + ], + { cwd: consumer }, + ); + console.log( `Validated installed ${packageManifest.name}@${packageManifest.version}: public import, NodeNext types, CLI, SDK lifecycle, credential locking, ${expectedPluginFiles.length} bundled plugin files, MCP initialization, bundled Codex version, dashboard assets, and a nested worker without global codex.`, ); From a6a2dd4ff1535646884e58eb2d935c947506bbd9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:06:49 +0000 Subject: [PATCH 021/251] test(sdk): allow the shared session module in package checks --- sdk/typescript/scripts/check-package.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index f782713744..8fd657e584 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -175,6 +175,7 @@ const distFiles = new Set( "severity-store", "cloud-publish", "codex-prompt", + "codex-session", "component-plan", "component-scan", "config", From a548cea49175296776028129cbdc18ff4104d7ef Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:09:36 +0000 Subject: [PATCH 022/251] Reserve original Deep Scan usage ownership metadata --- plugins/codex-security/scripts/workbench_schema.py | 7 +++++++ plugins/codex-security/tests/test_workbench_db.py | 2 +- .../tests/test_workbench_setup_and_migrations.py | 8 ++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_schema.py b/plugins/codex-security/scripts/workbench_schema.py index e3ce1647c1..9c69dcbfc5 100644 --- a/plugins/codex-security/scripts/workbench_schema.py +++ b/plugins/codex-security/scripts/workbench_schema.py @@ -939,6 +939,13 @@ ALTER TABLE scans ADD COLUMN retained_checkpoint_heads_json TEXT; """, ), + ( + 48, + "bind original deep scan parent usage turn", + """ + ALTER TABLE deep_scan_runs ADD COLUMN usage_owner_json TEXT; + """, + ), ) diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 5ad21c2c8a..697bb0e368 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -1045,7 +1045,7 @@ def test_workbench_persists_progress_and_indexes_completed_findings(tmp_path: Pa ) } assert tables == EXPECTED_TABLES - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (45,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (46,) assert connection.execute("SELECT COUNT(*) FROM findings").fetchone() == (1,) assert connection.execute("SELECT COUNT(*) FROM finding_locations").fetchone() == (1,) diff --git a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py index 6a793f66a7..f551a3964b 100644 --- a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py +++ b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py @@ -405,7 +405,7 @@ def test_workbench_serializes_concurrent_first_run_migrations(tmp_path: Path) -> {"databasePath": str(state_dir / "workbench.sqlite3")}, ] with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (45,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (46,) @pytest.mark.parametrize("previous_history", ["main", "comparison-preview"]) @@ -871,6 +871,7 @@ def test_workbench_creates_single_final_schema(tmp_path: Path) -> None: (45, "retain deep scan attempts and exact merge inputs"), (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), + (48, "bind original deep scan parent usage turn"), ] assert {row[1] for row in connection.execute("PRAGMA table_info(workspaces)")} >= { "diff_target_kind", @@ -973,7 +974,7 @@ def test_workbench_upgrades_preexisting_database(tmp_path: Path) -> None: connection.execute("ALTER TABLE scans DROP COLUMN handoff_claim_token") run_workbench(state_dir, "database-info") with sqlite3.connect(database) as connection: - assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (47,) + assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (48,) assert {row[1] for row in connection.execute("PRAGMA table_info(scans)")} >= { "handoff_claimed_at", "handoff_claim_token", @@ -2004,6 +2005,7 @@ def test_workbench_upgrades_released_database_schema(tmp_path: Path) -> None: (45, "retain deep scan attempts and exact merge inputs"), (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), + (48, "bind original deep scan parent usage turn"), ] assert "capability_preflight_json" in { row[1] for row in connection.execute("PRAGMA table_info(workspaces)") @@ -2091,6 +2093,7 @@ def test_workbench_upgrades_pre_release_phase_progress_migration(tmp_path: Path) (45, "retain deep scan attempts and exact merge inputs"), (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), + (48, "bind original deep scan parent usage turn"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") @@ -2186,6 +2189,7 @@ def test_workbench_upgrades_pre_release_preflight_progress_migration(tmp_path: P (45, "retain deep scan attempts and exact merge inputs"), (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), + (48, "bind original deep scan parent usage turn"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") From 85bcdbe74cdd3c96f5cdc39ef65d0851611726d1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:05:29 +0000 Subject: [PATCH 023/251] Freeze checkpoint selections for stopped scan replay --- .../scripts/workbench_saved_results.py | 71 +++++++- .../test_checkpoint_publication_authority.py | 161 ++++++++++++++++++ 2 files changed, 224 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index e7fdc2847c..9fdf30101f 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -190,6 +190,29 @@ def _worker_checkpoint_head(scan_dir: Path, directory: str, scan_id: str) -> str return checkpoint +def _worker_checkpoint_heads(scan_dir: Path, workers: list[Any], scan_id: str) -> dict[str, str]: + heads: dict[str, str] = {} + for worker in workers: + if worker["kind"] != "discovery": + continue + try: + output = Path(worker["artifact_dir"]).relative_to(scan_dir) + except (TypeError, ValueError): + continue + attempts = (output.parent if output.name == "output" else output) / "attempts" + directories = [output] + [ + attempts / name + for name in _children(scan_dir, attempts.as_posix()) + if re.fullmatch(r"attempt-\d+", name) + ] + for directory in directories: + relative = directory.as_posix() + head = _worker_checkpoint_head(scan_dir, relative, scan_id) + if head is not None: + heads[relative] = head + return heads + + def _read_saved_parent_result( scan_dir: Path, scan_id: str ) -> tuple[dict[str, Any], dict[str, Any]]: @@ -271,6 +294,10 @@ def has_saved_source() -> bool: published_sources = _source_digests( manifest_scan.get("preservedSources", {}), "Published scan" ) + if _worker_checkpoint_heads(scan_dir, workers, scan["id"]) != manifest_scan.get( + "preservedCheckpointHeads", {} + ): + return True current_sources = dict(published_sources) for path in paths: try: @@ -284,7 +311,9 @@ def has_saved_source() -> bool: return False -def _recovery_source_digests(db: Any, connection: Any, scan: Any) -> tuple[dict[str, str], bool]: +def _recovery_source_digests( + db: Any, connection: Any, scan: Any +) -> tuple[dict[str, str], bool, dict[str, str]]: scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) frozen_sources: dict[str, str] | None = None include_parent = True @@ -327,6 +356,7 @@ def _recovery_source_digests(db: Any, connection: Any, scan: Any) -> tuple[dict[ "FROM deep_scan_workers WHERE scan_id = ?", (scan["id"],), ).fetchall() + checkpoint_heads = _worker_checkpoint_heads(scan_dir, workers, scan["id"]) paths = dict(_saved_result_paths(scan_dir, workers)) recovery_sources = dict(frozen_sources or {}) for relative, expected_digest in recovery_sources.items(): @@ -344,7 +374,7 @@ def _recovery_source_digests(db: Any, connection: Any, scan: Any) -> tuple[dict[ ) except (ContractError, OSError, ValueError): continue - return recovery_sources, include_parent + return recovery_sources, include_parent, checkpoint_heads def scan_results_recovery_needed(db: Any, connection: Any, scan: Any) -> bool: @@ -486,10 +516,13 @@ def merge_saved_results( stopped: bool, reason: str, frozen_source_digests: dict[str, str] | None = None, + checkpoint_heads: dict[str, str] | None = None, allow_frozen_legacy_parent: bool = False, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None: """Read only bound parent/worker files; return an unsealed loss-preserving union.""" initial_warnings = set(warnings) + if checkpoint_heads is None: + checkpoint_heads = _worker_checkpoint_heads(scan_dir, workers, scan_id) parent: dict[str, Any] | None = None parent_manifest: dict[str, Any] | None = None if frozen_source_digests is None or allow_frozen_legacy_parent: @@ -577,7 +610,7 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: continue if worker["kind"] != "discovery": continue - head = _worker_checkpoint_head(scan_dir, output, scan_id) + head = checkpoint_heads.get(output) if head is not None: paths[head] = worker["id"] current_results.add(head) @@ -599,7 +632,7 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: ) for name in archived_attempts: archived = (attempts / name).as_posix() - archived_head = _worker_checkpoint_head(scan_dir, archived, scan_id) + archived_head = checkpoint_heads.get(archived) if archived_head is not None: paths[archived_head] = worker["id"] current_results.add(archived_head) @@ -672,6 +705,7 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: and parent_manifest["scan"].get("sealedAt") and parent_manifest["scan"].get("status") == binding["status"] and parent_manifest["scan"].get("preservedSources") == source_digests + and parent_manifest["scan"].get("preservedCheckpointHeads", {}) == checkpoint_heads and all(warning in initial_warnings for warning in warnings) ): return None @@ -703,6 +737,7 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: for key in ("sealedAt", "artifacts"): manifest["scan"].pop(key, None) manifest["scan"]["preservedSources"] = source_digests + manifest["scan"]["preservedCheckpointHeads"] = checkpoint_heads coverage = ( copy.deepcopy(parent["coverage"]) if parent and parent["coverage"] @@ -1135,6 +1170,7 @@ def preserve_scan_results_locked( scan_id: str, *, recovery_source_digests: dict[str, str] | None = None, + recovery_checkpoint_heads: dict[str, str] | None = None, include_parent_with_recovery: bool = False, ) -> bool: """Publish or verify retained terminal results through the workbench host.""" @@ -1142,6 +1178,7 @@ def preserve_scan_results_locked( if scan["status"] != "failed": return False frozen_source_digests: dict[str, str] | None = None + checkpoint_heads = recovery_checkpoint_heads raw_frozen_sources = scan["retained_source_digests_json"] if recovery_source_digests is not None: frozen_source_digests = recovery_source_digests @@ -1149,6 +1186,7 @@ def preserve_scan_results_locked( frozen_source_digests = _source_digests( json.loads(raw_frozen_sources), "Saved stopped-scan" ) + checkpoint_heads = json.loads(scan["retained_checkpoint_heads_json"] or "{}") scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) deep_run = connection.execute( "SELECT status FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) @@ -1202,11 +1240,15 @@ def record_publication(manifest: dict[str, Any], findings: dict[str, Any]) -> No db.index_findings(connection, scan_id, findings, scan["completed_at"]) connection.execute( "UPDATE scans SET seal_manifest_digest = ?, retained_source_digests_json = ?, " + "retained_checkpoint_heads_json = ?, " "completion_warnings_json = ?, " "updated_at = ? WHERE id = ? AND status = 'failed'", ( digest, json.dumps(retained_sources, sort_keys=True), + json.dumps( + manifest["scan"].get("preservedCheckpointHeads", {}), sort_keys=True + ), json.dumps(list(dict.fromkeys(warnings))), timestamp, scan_id, @@ -1233,6 +1275,7 @@ def record_publication(manifest: dict[str, Any], findings: dict[str, Any]) -> No db.verify_manifest_binding(scan, existing) if existing_scan.get("status") == outcome: existing_sources = existing_scan.get("preservedSources") + existing_heads = existing_scan.get("preservedCheckpointHeads", {}) if frozen_source_digests is None: if not isinstance(existing_sources, dict) or not all( isinstance(relative, str) and isinstance(digest, str) @@ -1240,7 +1283,8 @@ def record_publication(manifest: dict[str, Any], findings: dict[str, Any]) -> No ): raise ContractError("Stopped scan source digests could not be frozen.") frozen_source_digests = existing_sources - if existing_sources == frozen_source_digests: + checkpoint_heads = existing_heads + if existing_sources == frozen_source_digests and existing_heads == checkpoint_heads: if ( raw_frozen_sources is not None and scan["seal_manifest_digest"] is not None @@ -1267,6 +1311,7 @@ def record_publication(manifest: dict[str, Any], findings: dict[str, Any]) -> No f"{scan['failure_message'] or ''}" ).strip(), frozen_source_digests=frozen_source_digests, + checkpoint_heads=checkpoint_heads, allow_frozen_legacy_parent=( include_parent_with_recovery or ( @@ -1298,9 +1343,16 @@ def record_publication(manifest: dict[str, Any], findings: dict[str, Any]) -> No frozen_source_digests = retained_sources with connection: connection.execute( - "UPDATE scans SET retained_source_digests_json = ? " + "UPDATE scans SET retained_source_digests_json = ?, " + "retained_checkpoint_heads_json = ? " "WHERE id = ? AND retained_source_digests_json IS NULL", - (json.dumps(retained_sources, sort_keys=True), scan_id), + ( + json.dumps(retained_sources, sort_keys=True), + json.dumps( + documents[0]["scan"].get("preservedCheckpointHeads", {}), sort_keys=True + ), + scan_id, + ), ) prepared = _prepare_scan_finalization( scan_dir, @@ -1328,12 +1380,15 @@ def recover_scan_results(db: Any, connection: Any, args: Any) -> dict[str, Any]: raise SystemExit("Only a stopped scan can recover terminal results.") if scan["canceled_at"] is not None: raise SystemExit("Canceled scans cannot recover terminal results.") - recovery_source_digests, include_parent = _recovery_source_digests(db, connection, scan) + recovery_source_digests, include_parent, checkpoint_heads = _recovery_source_digests( + db, connection, scan + ) if not preserve_scan_results_locked( db, connection, scan_id, recovery_source_digests=recovery_source_digests, + recovery_checkpoint_heads=checkpoint_heads, include_parent_with_recovery=include_parent, ): raise SystemExit("No saved stopped-scan results were available to recover.") diff --git a/plugins/codex-security/tests/test_checkpoint_publication_authority.py b/plugins/codex-security/tests/test_checkpoint_publication_authority.py index e074a92030..a079f280cd 100644 --- a/plugins/codex-security/tests/test_checkpoint_publication_authority.py +++ b/plugins/codex-security/tests/test_checkpoint_publication_authority.py @@ -76,3 +76,164 @@ def test_recovery_honors_rejection_committed_before_result_replacement( and surface.get("disposition") == "rejected" for surface in coverage["surfaces"] ) + + +def save_disposition(scan, directory, disposition): + directory.mkdir(parents=True, exist_ok=True) + finding = copy.deepcopy(scan.findings[0]) + finding["extensions"] = {"candidateId": "candidate-disposition"} + draft = { + "scanId": scan.scan_id, + "complete": True, + "findings": [finding] if disposition == "reported" else [], + "coverage": { + **scan.coverage, + "surfaces": [ + { + "candidateId": "candidate-disposition", + "label": "Validated candidate disposition", + "disposition": disposition, + "receiptRefs": [], + } + ], + }, + } + checkpoint = write_checkpoint(directory / "checkpoints", draft) + (directory / "checkpoint-head.json").write_text(json.dumps({"checkpoint": checkpoint.name})) + return draft + + +@pytest.mark.parametrize("archived", [False, True], ids=["current-head", "newer-archive"]) +@pytest.mark.parametrize("disposition", ["reported", "rejected"]) +def test_newer_checkpoint_disposition_precedes_older_archived_head( + workbench_api, workbench_db, publication_scan, archived, disposition +): + scan = publication_scan() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + result = add_worker(workbench_db, scan, status="canceled") + old = result.parent / "attempts" / "attempt-1" + save_disposition(scan, old, "rejected" if disposition == "reported" else "reported") + current = result.parent / "attempts" / "attempt-2" if archived else result.parent + draft = save_disposition(scan, current, disposition) + (current / "result.json").write_text(json.dumps(draft)) + + stopped = workbench_api["fail_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped."), + )["scan"] + + assert stopped["findingCount"] == (1 if disposition == "reported" else 0) + + +@pytest.mark.parametrize("head_change", ["replaced", "removed", "missing-checkpoint"]) +def test_frozen_stopped_replay_ignores_later_worker_head_changes( + workbench_api, workbench_db, publication_scan, monkeypatch, head_change +): + scan = publication_scan() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + result = add_worker(workbench_db, scan, status="canceled") + previous = save_disposition(scan, result.parent, "reported") + result.write_text(json.dumps(previous)) + save_disposition(scan, result.parent, "rejected") + saved = workbench_api["saved_results"] + + def fail_before_publication(*args, **kwargs): + raise OSError("Synthetic publication interruption") + + with monkeypatch.context() as patch: + patch.setattr(saved, "_write_prepared_scan_finalization", fail_before_publication) + workbench_api["fail_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped." + ), + ) + row = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + assert row["retained_source_digests_json"] + assert row["seal_manifest_digest"] is None + head = result.parent / "checkpoint-head.json" + if head_change == "replaced": + save_disposition(scan, result.parent, "reported") + elif head_change == "removed": + head.unlink() + else: + head.write_text(json.dumps({"checkpoint": "a" * 64 + ".json"})) + + replayed = workbench_api["preserve_scan_results"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, claim_token=None, thread_id=None, coordinator_generation=None + ), + )["scan"] + + assert replayed["findingCount"] == 0 + assert json.loads((scan.scan_dir / "findings.json").read_text())["findings"] == [] + assert json.loads(result.read_text()) == previous + + +def test_explicit_recovery_observes_head_change_between_existing_checkpoints( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + result = add_worker(workbench_db, scan, status="canceled") + previous = save_disposition(scan, result.parent, "reported") + result.write_text(json.dumps(previous)) + save_disposition(scan, result.parent, "rejected") + stopped = workbench_api["fail_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped."), + )["scan"] + assert stopped["findingCount"] == 0 + + save_disposition(scan, result.parent, "reported") + + context = workbench_api["scan_context"](workbench_db, scan.scan_id)["scan"] + assert context["resultsRecoveryNeeded"] is True + recovered = workbench_api["recover_scan_results"]( + workbench_db, Namespace(scan_id=scan.scan_id) + )["scan"] + assert recovered["findingCount"] == 1 + assert recovered["resultsRecoveryNeeded"] is False + + +def test_legacy_frozen_publication_keeps_result_fallback_without_saved_heads( + workbench_api, workbench_db, publication_scan, monkeypatch +): + scan = publication_scan() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + result = add_worker(workbench_db, scan, status="canceled") + previous = save_disposition(scan, result.parent, "reported") + result.write_text(json.dumps(previous)) + save_disposition(scan, result.parent, "rejected") + (result.parent / "checkpoint-head.json").unlink() + + def fail_before_publication(*args, **kwargs): + raise OSError("Synthetic publication interruption") + + with monkeypatch.context() as patch: + patch.setattr( + workbench_api["saved_results"], + "_write_prepared_scan_finalization", + fail_before_publication, + ) + workbench_api["fail_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped." + ), + ) + with workbench_db: + workbench_db.execute( + "UPDATE scans SET retained_checkpoint_heads_json = NULL WHERE id = ?", (scan.scan_id,) + ) + save_disposition(scan, result.parent, "rejected") + + replayed = workbench_api["preserve_scan_results"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, claim_token=None, thread_id=None, coordinator_generation=None + ), + )["scan"] + + assert replayed["findingCount"] == 1 From f3fa2e928d05dbe24bd564f09f09bcd95cb48b7e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:13:38 +0000 Subject: [PATCH 024/251] Preserve accepted source coverage during stopped recovery --- .../scripts/workbench_saved_results.py | 60 +++++-- .../tests/test_stopped_source_coverage.py | 167 ++++++++++++++++++ 2 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 plugins/codex-security/tests/test_stopped_source_coverage.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 9fdf30101f..73d21ba22f 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -166,9 +166,12 @@ def _read_saved_result( draft = _read_scan_local_json(scan_dir, relative, "Saved scan checkpoint") if draft.get("scanId") != scan_id: raise ContractError("checkpoint belongs to a different scan") - if not isinstance(draft.get("findings"), list) or not isinstance( - draft.get("coverage", {} if kind == "dedup" else None), dict - ): + coverage = ( + draft.get("sourceCoverage", draft.get("coverage", {})) + if kind == "dedup" + else draft.get("coverage") + ) + if not isinstance(draft.get("findings"), list) or not isinstance(coverage, dict): raise ContractError("checkpoint has no semantic findings or coverage") return draft, _digest(draft) @@ -569,6 +572,7 @@ def merge_saved_results( reducer_paths.add(latest_reducer) except ValueError: warnings.append("Skipped a reducer result outside the scan directory.") + accepted_reducer = latest_reducer def checkpoints(directory: str, worker_id: str | None) -> None: for name in _children(scan_dir, directory): @@ -665,9 +669,14 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: if frozen_source_digests is not None and frozen_source_digests[relative] != digest: raise ContractError("checkpoint changed after the scan stopped") source_digests[relative] = digest - # Recovery expects coverage, but reducer results only contain findings - # and context. Add an empty value after hashing the original result. - sources.append((relative, {"coverage": {}, **draft}, worker_id)) + # The host supplies reducer coverage separately from model output. + # Preserve the digest of the original accepted document. + if relative in reducer_paths: + draft = { + **draft, + "coverage": draft.get("sourceCoverage", draft.get("coverage", {})), + } + sources.append((relative, draft, worker_id)) except (ContractError, OSError, ValueError) as exc: if (scan_dir / relative).exists(): warnings.append(f"Preserved unreadable checkpoint {relative}: {exc}") @@ -676,6 +685,15 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: raise ContractError("Frozen stopped-scan checkpoint set is incomplete.") drafts_by_path = {relative: draft for relative, draft, _ in sources} + if "sourceCoverage" not in drafts_by_path.get(accepted_reducer, {}): + accepted_reducer = None + accepted_coverage = drafts_by_path.get(accepted_reducer, {}).get("coverage", {}) + reviewed_attempts = { + (review.get("workerId"), review.get("attempt")) + for review in accepted_coverage.get("reviews", []) + if isinstance(review, dict) + } + workers_by_id = {worker["id"]: worker for worker in workers} latest_reducer_key = ( (reducer["completed_at"] or "", reducer["id"], int(reducer["attempt"] or 0)) if reducer is not None and latest_reducer in drafts_by_path @@ -792,8 +810,19 @@ def valid_finding(value: Any) -> bool: all_sources = ([("parent", parent, None)] if parent else []) + sources current_drafts = ([(None, parent)] if parent else []) + [ - (worker_id, draft) for relative, draft, worker_id in sources if relative in current_results + (worker_id, draft) + for relative, draft, worker_id in sources + if relative in current_results or relative == accepted_reducer ] + + def coverage_candidate(owner: str | None, item: dict[str, Any]) -> tuple[str | None, Any]: + provenance = item.get("provenance") + if owner is None and isinstance(provenance, dict): + return provenance.get("workerId"), provenance.get( + "candidateId", item.get("candidateId") + ) + return owner, item.get("candidateId") + resolved: dict[tuple[str | None, str], str] = {} for owner, draft in current_drafts: for finding in draft["findings"]: @@ -811,7 +840,7 @@ def valid_finding(value: Any) -> bool: and isinstance(item.get("candidateId"), str) and item.get("disposition") in {"reported", "rejected", "not_applicable"} ): - resolved.setdefault((owner, item["candidateId"]), item["disposition"]) + resolved.setdefault(coverage_candidate(owner, item), item["disposition"]) # Only the current parent may claim that another worker finding was absorbed. # A superseded checkpoint must not suppress a newer independent result. for draft in [parent] if parent else []: @@ -1025,11 +1054,18 @@ def valid_finding(value: Any) -> bool: continue finding_positions[key] = len(findings) findings.append(finding) - if superseded: + worker = workers_by_id.get(worker_id) + reviewed = ( + worker is not None + and worker["status"] == "succeeded" + and worker["merge_state"] == "merged" + and (worker_id, worker["attempt"]) in reviewed_attempts + ) + if reviewed or (superseded and relative != accepted_reducer): continue - for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions"): + for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions", "reviews"): items = draft["coverage"].get(field, []) - if not isinstance(items, list): + if not isinstance(items, list) or (field == "reviews" and not items): continue output = coverage.setdefault(field, []) if not isinstance(output, list): @@ -1060,7 +1096,7 @@ def valid_finding(value: Any) -> bool: history.append(copy.deepcopy(finding)) if ( isinstance(item, dict) - and (worker_id, item.get("candidateId")) in resolved + and coverage_candidate(worker_id, item) in resolved and (field == "deferred" or item.get("disposition") == "needs_follow_up") ): continue diff --git a/plugins/codex-security/tests/test_stopped_source_coverage.py b/plugins/codex-security/tests/test_stopped_source_coverage.py new file mode 100644 index 0000000000..57b8e65343 --- /dev/null +++ b/plugins/codex-security/tests/test_stopped_source_coverage.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import copy +import json +from argparse import Namespace + +import pytest +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan + + +@pytest.mark.parametrize("host_coverage", [True, False], ids=["accepted-projection", "legacy"]) +@pytest.mark.parametrize("parent_draft", [True, False], ids=["parent-draft", "no-parent"]) +def test_stopped_recovery_preserves_accepted_coverage_without_worker_id_collisions( + workbench_api, workbench_db, publication_scan, host_coverage, parent_draft +): + scan = publication_scan() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + source_coverage = { + "completeness": "partial", + "surfaces": [], + "explicitExclusions": [], + "deferred": [], + "reviews": [], + } + source_files = [] + for disposition in ("needs_follow_up", "rejected"): + result = add_worker(workbench_db, scan) + worker_id = result.parent.name + surface = { + "id": "surface-1", + "candidateId": "candidate-1", + "label": "Independent review", + "disposition": disposition, + "receiptRefs": [], + } + deferred = {"candidateId": "candidate-1", "reason": "Validation remains unresolved."} + coverage = { + "completeness": "partial" if disposition == "needs_follow_up" else "complete", + "surfaces": [surface], + "explicitExclusions": [], + "deferred": [deferred] if disposition == "needs_follow_up" else [], + } + result.write_text( + json.dumps( + {"scanId": scan.scan_id, "complete": True, "findings": [], "coverage": coverage} + ) + ) + source_files.append(result) + prefix = f"{worker_id}-attempt-1" + provenance = {"workerId": worker_id, "attempt": 1, "candidateId": "candidate-1"} + source_coverage["reviews"].append( + {"workerId": worker_id, "attempt": 1, "completeness": coverage["completeness"]} + ) + source_coverage["surfaces"].append( + { + **surface, + "id": f"{prefix}-surface-1", + "provenance": {**provenance, "sourceId": "surface-1"}, + } + ) + if coverage["deferred"]: + source_coverage["deferred"].append( + { + **deferred, + "id": f"{prefix}-deferred-1", + "candidateId": f"{prefix}-candidate-1", + "provenance": provenance, + } + ) + reducer = add_worker(workbench_db, scan) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_workers SET kind = 'dedup', merge_state = 'none' " + "WHERE result_manifest_path = ?", + (str(reducer),), + ) + aggregate = {"scanId": scan.scan_id, "complete": True, "findings": []} + if host_coverage: + aggregate["sourceCoverage"] = copy.deepcopy(source_coverage) + reducer.write_text(json.dumps(aggregate)) + source_files.append(reducer) + saved_bytes = {path: path.read_bytes() for path in source_files} + if not parent_draft: + for filename in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / filename).unlink() + + stopped = workbench_api["fail_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped."), + )["scan"] + + assert stopped["progress"]["status"] == "failed" + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert coverage["completeness"] == "partial" + assert len(coverage["deferred"]) == 2 + assert coverage["deferred"][-1]["id"] == "scan-stopped" + assert len(coverage["surfaces"]) == 2 + if host_coverage: + for field in ("reviews", "surfaces", "deferred"): + assert ( + coverage[field][:-1] if field == "deferred" else coverage[field] + ) == source_coverage[field] + else: + assert coverage["deferred"][0]["candidateId"] == "candidate-1" + manifest = (scan.scan_dir / "scan-manifest.json").read_bytes() + workbench_api["preserve_scan_results"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, claim_token=None, thread_id=None, coordinator_generation=None + ), + ) + assert (scan.scan_dir / "scan-manifest.json").read_bytes() == manifest + assert all(path.read_bytes() == contents for path, contents in saved_bytes.items()) + + +def test_stopped_recovery_keeps_unmerged_coverage_after_accepted_review( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + accepted = add_worker(workbench_db, scan) + accepted.write_text( + json.dumps( + {"scanId": scan.scan_id, "complete": True, "findings": [], "coverage": scan.coverage} + ) + ) + reducer = add_worker(workbench_db, scan) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_workers SET kind = 'dedup', merge_state = 'none' " + "WHERE result_manifest_path = ?", + (str(reducer),), + ) + reviews = [{"workerId": accepted.parent.name, "attempt": 1, "completeness": "complete"}] + reducer.write_text( + json.dumps( + { + "scanId": scan.scan_id, + "complete": True, + "findings": [], + "sourceCoverage": {**scan.coverage, "reviews": reviews}, + } + ) + ) + pending = add_worker(workbench_db, scan, status="canceled") + deferred = {"id": "pending-review", "reason": "The independent review remains unresolved."} + pending.write_text( + json.dumps( + { + "scanId": scan.scan_id, + "complete": False, + "findings": [], + "coverage": {**scan.coverage, "completeness": "partial", "deferred": [deferred]}, + } + ) + ) + + workbench_api["fail_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped."), + ) + + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert coverage["completeness"] == "partial" + assert coverage["reviews"] == reviews + assert deferred in coverage["deferred"] From d62c174415c73fc2f0c74d26a2aeab88ffc5a61e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:11:42 +0000 Subject: [PATCH 025/251] Restore original Deep Scan worker settings after coordinator replacement --- plugins/codex-security/mcp-app/server.ts | 15 ++ .../src/deep-scan/recovery-settings.ts | 141 +++++++++++++++++ .../test_deep_scan_recovery_settings.mjs | 86 +++++++++++ .../tests/test_deep_scan_stdio_lifecycle.mjs | 2 + sdk/typescript/src/api.ts | 133 +--------------- sdk/typescript/src/preflight-config.ts | 142 ++++++++++++++++++ 6 files changed, 389 insertions(+), 130 deletions(-) create mode 100644 plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs create mode 100644 sdk/typescript/src/preflight-config.ts diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 2c35f3c7d9..de40ad38ce 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -22,6 +22,7 @@ import { DeepScanStartLock, startOrJoinDeepScanCoordinator } from "./src/deep-scan/registry.js"; +import { captureDeepScanExecutionSettings, loadOrCaptureDeepScanExecutionSettings, restoredDeepScanWorkerSettings } from "./src/deep-scan/recovery-settings.js"; import { CodexSdkWorkerExecutor } from "./src/deep-scan/executor.js"; import { CODEX_SANDBOX_STATE_META_CAPABILITY, @@ -750,6 +751,20 @@ export function createCodexSecurityServer(): McpServer { registry: deepScanCoordinators, options: { store: deepScanStore, + prepareExecutor: async (run) => new CodexSdkWorkerExecutor({ + ...restoredDeepScanWorkerSettings( + await loadOrCaptureDeepScanExecutionSettings(run.scanDir, () => + captureDeepScanExecutionSettings(run, parentSandbox)), + parentSandbox + ), + artifactContext: { + pluginRoot: PLUGIN_ROOT, + scanRoot: run.scanDir, + repoRoot: run.targetPath, + scanId: run.scanId, + scope: run.scope + } + }), executor: new CodexSdkWorkerExecutor({ ...modelSettings, parentSandbox, diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts new file mode 100644 index 0000000000..e232bd6701 --- /dev/null +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -0,0 +1,141 @@ +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import type { CodexOptions } from "@openai/codex-sdk"; +import { parse as parseToml } from "smol-toml"; +import { scanPreflightCodexConfig } from "../../../../../sdk/typescript/src/preflight-config.js"; +import { resolveCodexProfile, type JsonObject } from "../../../../../sdk/typescript/src/config.js"; +import { writeJsonAtomic } from "./artifacts.js"; +import { resolveCodexPath } from "./executor.js"; +import type { DeepWorkerParentSandbox } from "./parent-sandbox.js"; + +/** Credentials and arbitrary environment/configuration stay with Codex. */ +export interface DeepScanExecutionSettings { + codexPath: string; + codexHome: string; + model?: string; + modelProvider?: string; + reasoningEffort?: string; + reasoningSummary?: string; + serviceTier?: string; + providerConfig?: JsonObject; + parentSandbox?: DeepWorkerParentSandbox; +} + +export async function captureDeepScanExecutionSettings( + original: { model?: string; reasoningEffort?: string }, + parentSandbox: DeepWorkerParentSandbox, + environment: NodeJS.ProcessEnv = process.env +): Promise { + const codexHome = environment.CODEX_HOME || join(homedir(), ".codex"); + const configPath = environment.CODEX_SECURITY_CONFIG_PATH ?? join(codexHome, "config.toml"); + let config: JsonObject; + try { + config = parseToml(await fs.readFile(configPath, "utf8")) as JsonObject; + } catch (error) { + if (environment.CODEX_SECURITY_CONFIG_PATH || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + config = {}; + } + // Reuse the SDK projection: custom provider credentials belong in the native home. + const selected = scanPreflightCodexConfig(resolveCodexProfile(config)); + return executionSettings({ + codexPath: resolveCodexPath(environment, process.platform, process.arch, process.cwd()), + codexHome: isAbsolute(codexHome) ? codexHome : await fs.realpath(codexHome), + model: original.model ?? selected.model as string | undefined, + reasoningEffort: original.reasoningEffort ?? selected.model_reasoning_effort as string | undefined, + modelProvider: selected.model_provider as string | undefined, + reasoningSummary: selected.model_reasoning_summary as string | undefined, + serviceTier: selected.service_tier as string | undefined, + providerConfig: selected.model_providers as JsonObject | undefined, + parentSandbox + }); +} + +/** Called by the acquired coordinator before it starts any worker. */ +export async function loadOrCaptureDeepScanExecutionSettings( + scanDir: string, + capture: () => Promise +): Promise { + const path = join(scanDir, "artifacts", "deep_discovery", "execution-settings.json"); + try { + const saved = JSON.parse(await fs.readFile(path, "utf8")); + if (saved.version !== 1) { + throw new Error("This Deep Scan uses an unsupported execution settings version."); + } + return executionSettings(saved.settings); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const settings = executionSettings(await capture()); + await writeJsonAtomic(path, { version: 1, settings }); + return settings; +} + +export function restoredDeepScanWorkerSettings( + settings: DeepScanExecutionSettings, + currentParentSandbox: DeepWorkerParentSandbox, + environment: () => NodeJS.ProcessEnv = () => process.env +): { + codexOptions: CodexOptions; + model?: string; + reasoningEffort?: string; + parentSandbox: DeepWorkerParentSandbox; +} { + const originalSandbox = settings.parentSandbox; + const depths = [originalSandbox?.globScanMaxDepth, currentParentSandbox.globScanMaxDepth] + .filter((depth): depth is number => depth !== undefined); + return { + model: settings.model, + reasoningEffort: settings.reasoningEffort, + parentSandbox: { + filesystemDenies: [...new Set([ + ...(originalSandbox?.filesystemDenies ?? []), ...currentParentSandbox.filesystemDenies + ])], + ...(depths.length === 0 ? {} : { globScanMaxDepth: Math.max(...depths) }) + }, + codexOptions: { + codexPathOverride: settings.codexPath, + // The executor reads this property for each launch. API keys can refresh; + // only the original account home and non-secret selections are bound. + get env() { + return Object.fromEntries(Object.entries({ ...environment(), CODEX_HOME: settings.codexHome }) + .filter((entry): entry is [string, string] => entry[1] !== undefined)); + }, + config: { + ...(settings.model === undefined ? {} : { model: settings.model }), + ...(settings.reasoningEffort === undefined ? {} : { model_reasoning_effort: settings.reasoningEffort }), + ...(settings.modelProvider === undefined ? {} : { model_provider: settings.modelProvider }), + ...(settings.reasoningSummary === undefined ? {} : { model_reasoning_summary: settings.reasoningSummary }), + ...(settings.serviceTier === undefined ? {} : { service_tier: settings.serviceTier }), + ...(settings.providerConfig === undefined ? {} : { model_providers: settings.providerConfig as NonNullable[string] }) + } + } + }; +} + +function executionSettings(value: DeepScanExecutionSettings): DeepScanExecutionSettings { + const provider = scanPreflightCodexConfig({ + ...(value.modelProvider === undefined ? {} : { model_provider: value.modelProvider }), + ...(value.providerConfig === undefined ? {} : { model_providers: value.providerConfig }) + }).model_providers as JsonObject | undefined; + const settings: DeepScanExecutionSettings = { + codexPath: value.codexPath, + codexHome: value.codexHome, + model: value.model, + modelProvider: value.modelProvider, + reasoningEffort: value.reasoningEffort, + reasoningSummary: value.reasoningSummary, + serviceTier: value.serviceTier, + ...(provider === undefined ? {} : { providerConfig: provider }), + ...(value.parentSandbox === undefined ? {} : { parentSandbox: { + filesystemDenies: [...value.parentSandbox.filesystemDenies], + ...(value.parentSandbox.globScanMaxDepth === undefined ? {} : { + globScanMaxDepth: value.parentSandbox.globScanMaxDepth + }) + } }) + }; + if (typeof settings.codexPath !== "string" || typeof settings.codexHome !== "string") { + throw new Error("Deep Scan execution settings are missing the recorded executable or Codex home."); + } + return settings; +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs new file mode 100644 index 0000000000..439618f3b4 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build } from "esbuild"; + +const bundle = await build({ + bundle: true, + define: { "import.meta.url": JSON.stringify(new URL("../src/deep-scan/recovery-settings.ts", import.meta.url).href) }, + entryPoints: [new URL("../src/deep-scan/recovery-settings.ts", import.meta.url).pathname], + format: "esm", + platform: "node", + write: false +}); +const { captureDeepScanExecutionSettings: captureSettings, restoredDeepScanWorkerSettings: restoreSettings, loadOrCaptureDeepScanExecutionSettings: loadSettings } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` +); +const root = await mkdtemp(join(tmpdir(), "deep-settings-")); +try { + const settings = { + codexPath: "/fixture/runtime/codex", + codexHome: "/fixture/account", + model: "fixture-model", + modelProvider: "fixture-provider", + reasoningEffort: "high", + reasoningSummary: "detailed", + serviceTier: "fast" + }; + const first = await loadSettings(join(root, "one"), async () => ({ + ...settings, + apiKey: "synthetic-do-not-persist", + env: { CODEX_API_KEY: "synthetic-do-not-persist" } + })); + assert.deepEqual(first, settings); + const savedPath = join(root, "one", "artifacts", "deep_discovery", "execution-settings.json"); + const saved = await readFile(savedPath, "utf8"); + assert.equal(saved.includes("synthetic-do-not-persist"), false); + const [recovered, concurrent] = await Promise.all([ + loadSettings(join(root, "one"), async () => assert.fail("recovery recaptured observer settings")), + loadSettings(join(root, "two"), async () => ({ ...settings, model: "other-model" })) + ]); + assert.deepEqual(recovered, settings); + assert.equal(concurrent.model, "other-model"); + assert.equal(await readFile(savedPath, "utf8"), saved); + recovered.model = "caller-mutation"; + assert.deepEqual(await loadSettings(join(root, "one"), async () => assert.fail()), settings); + const configPath = join(root, "runtime.toml"); + await writeFile(configPath, `model = "inherited-model" +model_provider = "custom" +profile = "scan" +[profiles.scan] +model_reasoning_summary = "concise" +service_tier = "flex" +[model_providers.custom] +name = "Fixture" +http_headers = { Authorization = "synthetic-secret" } +`); + const captured = await captureSettings({ model: "original-model", reasoningEffort: "ultra" }, { + filesystemDenies: ["/fixture/original-deny"], globScanMaxDepth: 3 + }, { CODEX_CLI_PATH: process.execPath, CODEX_HOME: root, CODEX_SECURITY_CONFIG_PATH: configPath }); + assert.equal(captured.model, "original-model"); + assert.equal(captured.modelProvider, "custom"); + assert.equal(captured.reasoningSummary, "concise"); + assert.equal(captured.serviceTier, "flex"); + assert.equal(captured.providerConfig, undefined); + assert.equal(JSON.stringify(captured).includes("synthetic-secret"), false); + let credential = "synthetic-first"; + const restored = restoreSettings(captured, { filesystemDenies: ["/fixture/current-deny"] }, () => ({ + CODEX_API_KEY: credential, CODEX_HOME: "/fixture/observer-home" + })); + assert.equal(restored.codexOptions.env.CODEX_API_KEY, "synthetic-first"); + credential = "synthetic-refreshed"; + assert.equal(restored.codexOptions.env.CODEX_API_KEY, "synthetic-refreshed"); + assert.equal(restored.codexOptions.env.CODEX_HOME, root); + assert.deepEqual(restored.parentSandbox.filesystemDenies, ["/fixture/original-deny", "/fixture/current-deny"]); + assert.equal(restored.codexOptions.config.model_reasoning_effort, "ultra"); + await writeFile(join(root, "config.toml"), 'model = "native-home-model"\n'); + const native = await captureSettings({}, { filesystemDenies: [] }, { CODEX_CLI_PATH: process.execPath, CODEX_HOME: root }); + assert.equal(native.model, "native-home-model"); + const unsupported = JSON.stringify({ version: 99, settings }); + await writeFile(savedPath, unsupported); + await assert.rejects(loadSettings(join(root, "one"), async () => assert.fail()), /unsupported/); + assert.equal(await readFile(savedPath, "utf8"), unsupported); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 7d8ca664a1..8067869a7f 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -516,6 +516,8 @@ async function testDeepScanStdioLifecycle() { resumedScanId ]); await writeFile(restartControlPath, "after-restart"); + // A replacement caller's configuration must not replace the original selection. + await writeFile(runtimeConfigPath, 'model_reasoning_summary = "detailed"\n'); const restartedServer = startServer(serverBundlePath, environment); try { diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 8540ec8813..f2a39f7bc3 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1,5 +1,8 @@ /// +import { scanPreflightCodexConfig } from "./preflight-config.js"; +export { scanPreflightCodexConfig } from "./preflight-config.js"; + import { statSync } from "node:fs"; import { chmod, @@ -4560,136 +4563,6 @@ function sharedCredentialCodexConfig( return scanRuntimeCodexConfig(shared, credentialHome); } -export function scanPreflightCodexConfig(config: JsonObject): JsonObject { - const safeString = (value: unknown): value is string => - typeof value === "string" && - value.length > 0 && - !/[\u0000-\u001f\u007f]/u.test(value); - const safeProfileName = (value: unknown): value is string => - safeString(value) && /^[A-Za-z0-9_-]+$/u.test(value); - const safeInteger = (value: unknown): value is number => - typeof value === "number" && Number.isSafeInteger(value) && value >= 0; - const capabilityFeatures = (value: unknown): JsonObject => { - if (!isRecord(value)) return {}; - const result: JsonObject = {}; - for (const key of ["goals", "multi_agent", "enable_fanout"]) { - if (typeof value[key] === "boolean") result[key] = value[key]; - } - const multiAgent = value["multi_agent_v2"]; - if (typeof multiAgent === "boolean") { - result["multi_agent_v2"] = multiAgent; - } else if (isRecord(multiAgent)) { - const sanitized: JsonObject = {}; - if (typeof multiAgent["enabled"] === "boolean") { - sanitized["enabled"] = multiAgent["enabled"]; - } - const capacity = multiAgent["max_concurrent_threads_per_session"]; - if (safeInteger(capacity)) { - sanitized["max_concurrent_threads_per_session"] = capacity; - } - if (Object.keys(sanitized).length > 0) { - result["multi_agent_v2"] = sanitized; - } - } - return result; - }; - const executionConfig = (source: JsonObject): JsonObject => { - const result: JsonObject = {}; - for (const key of [ - "model", - "model_reasoning_effort", - "model_reasoning_summary", - "model_provider", - "service_tier", - ]) { - const value = source[key]; - if (safeString(value)) result[key] = value; - } - const features = capabilityFeatures(source["features"]); - if (Object.keys(features).length > 0) result["features"] = features; - const agents = source["agents"]; - if (isRecord(agents)) { - const sanitized: JsonObject = {}; - for (const key of ["max_threads", "max_depth"]) { - const value = agents[key]; - if (safeInteger(value)) sanitized[key] = value; - } - if (Object.keys(sanitized).length > 0) result["agents"] = sanitized; - } - const multiagent = source["multiagent_config"]; - if (isRecord(multiagent) && safeInteger(multiagent["max_concurrency"])) { - result["multiagent_config"] = { - max_concurrency: multiagent["max_concurrency"], - }; - } - return result; - }; - const result = executionConfig(config); - // Keep the effective summary even when preflight filters the profile name. - const reasoningSummary = - resolveCodexProfile(config)["model_reasoning_summary"]; - if (safeString(reasoningSummary)) { - result["model_reasoning_summary"] = reasoningSummary; - } - const selectedProfile = safeProfileName(config["profile"]) - ? config["profile"] - : undefined; - if (selectedProfile !== undefined) { - result["profile"] = selectedProfile; - } - const profiles = config["profiles"]; - if (isRecord(profiles)) { - const sanitized: JsonObject = {}; - for (const [name, profile] of Object.entries(profiles)) { - if (!safeProfileName(name) || !isRecord(profile)) continue; - const projected = executionConfig(profile as JsonObject); - if (Object.keys(projected).length === 0) continue; - sanitized[name] = projected; - } - if (Object.keys(sanitized).length > 0) result["profiles"] = sanitized; - } - const modelProvider = scanModelProvider(result); - if (isExternalModelProvider(modelProvider)) { - result["model_providers"] = { - [modelProvider]: { ...EXTERNAL_CODEX_PROVIDERS[modelProvider] }, - }; - } else if (modelProvider === "amazon-bedrock") { - const providers = config["model_providers"]; - const provider = isRecord(providers) ? providers[modelProvider] : undefined; - const aws = isRecord(provider) ? provider["aws"] : undefined; - if (isRecord(aws)) { - const sanitized: JsonObject = {}; - for (const key of ["region", "profile"]) { - const value = aws[key]; - if (safeString(value)) sanitized[key] = value; - } - if (Object.keys(sanitized).length > 0) { - result["model_providers"] = { - [modelProvider]: { aws: sanitized }, - }; - } - } - } - const rootMarkers = config["project_root_markers"]; - if (Array.isArray(rootMarkers)) { - result["project_root_markers"] = rootMarkers.filter(safeString); - } - const projects = config["projects"]; - if (isRecord(projects)) { - const sanitized: JsonObject = {}; - for (const [path, project] of Object.entries(projects)) { - if (!safeString(path) || !isAbsolute(path) || !isRecord(project)) { - continue; - } - const trust = project["trust_level"]; - if (trust !== "trusted" && trust !== "untrusted") continue; - sanitized[path] = { trust_level: trust }; - } - if (Object.keys(sanitized).length > 0) result["projects"] = sanitized; - } - return result; -} - async function pluginSupportsIsolatedDeepScanConfig( pluginRoot: string, ): Promise { diff --git a/sdk/typescript/src/preflight-config.ts b/sdk/typescript/src/preflight-config.ts new file mode 100644 index 0000000000..5c1c59cf91 --- /dev/null +++ b/sdk/typescript/src/preflight-config.ts @@ -0,0 +1,142 @@ +import { isAbsolute } from "node:path"; +import { + EXTERNAL_CODEX_PROVIDERS, + isExternalModelProvider, + resolveCodexProfile, + scanModelProvider, + type JsonObject, +} from "./config.js"; + +export function scanPreflightCodexConfig(config: JsonObject): JsonObject { + const safeString = (value: unknown): value is string => + typeof value === "string" && + value.length > 0 && + !/[\u0000-\u001f\u007f]/u.test(value); + const safeProfileName = (value: unknown): value is string => + safeString(value) && /^[A-Za-z0-9_-]+$/u.test(value); + const safeInteger = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + const capabilityFeatures = (value: unknown): JsonObject => { + if (!isRecord(value)) return {}; + const result: JsonObject = {}; + for (const key of ["goals", "multi_agent", "enable_fanout"]) { + if (typeof value[key] === "boolean") result[key] = value[key]; + } + const multiAgent = value["multi_agent_v2"]; + if (typeof multiAgent === "boolean") { + result["multi_agent_v2"] = multiAgent; + } else if (isRecord(multiAgent)) { + const sanitized: JsonObject = {}; + if (typeof multiAgent["enabled"] === "boolean") { + sanitized["enabled"] = multiAgent["enabled"]; + } + const capacity = multiAgent["max_concurrent_threads_per_session"]; + if (safeInteger(capacity)) { + sanitized["max_concurrent_threads_per_session"] = capacity; + } + if (Object.keys(sanitized).length > 0) { + result["multi_agent_v2"] = sanitized; + } + } + return result; + }; + const executionConfig = (source: JsonObject): JsonObject => { + const result: JsonObject = {}; + for (const key of [ + "model", + "model_reasoning_effort", + "model_reasoning_summary", + "model_provider", + "service_tier", + ]) { + const value = source[key]; + if (safeString(value)) result[key] = value; + } + const features = capabilityFeatures(source["features"]); + if (Object.keys(features).length > 0) result["features"] = features; + const agents = source["agents"]; + if (isRecord(agents)) { + const sanitized: JsonObject = {}; + for (const key of ["max_threads", "max_depth"]) { + const value = agents[key]; + if (safeInteger(value)) sanitized[key] = value; + } + if (Object.keys(sanitized).length > 0) result["agents"] = sanitized; + } + const multiagent = source["multiagent_config"]; + if (isRecord(multiagent) && safeInteger(multiagent["max_concurrency"])) { + result["multiagent_config"] = { + max_concurrency: multiagent["max_concurrency"], + }; + } + return result; + }; + const result = executionConfig(config); + // Keep the effective summary even when preflight filters the profile name. + const reasoningSummary = + resolveCodexProfile(config)["model_reasoning_summary"]; + if (safeString(reasoningSummary)) { + result["model_reasoning_summary"] = reasoningSummary; + } + const selectedProfile = safeProfileName(config["profile"]) + ? config["profile"] + : undefined; + if (selectedProfile !== undefined) { + result["profile"] = selectedProfile; + } + const profiles = config["profiles"]; + if (isRecord(profiles)) { + const sanitized: JsonObject = {}; + for (const [name, profile] of Object.entries(profiles)) { + if (!safeProfileName(name) || !isRecord(profile)) continue; + const projected = executionConfig(profile as JsonObject); + if (Object.keys(projected).length === 0) continue; + sanitized[name] = projected; + } + if (Object.keys(sanitized).length > 0) result["profiles"] = sanitized; + } + const modelProvider = scanModelProvider(result); + if (isExternalModelProvider(modelProvider)) { + result["model_providers"] = { + [modelProvider]: { ...EXTERNAL_CODEX_PROVIDERS[modelProvider] }, + }; + } else if (modelProvider === "amazon-bedrock") { + const providers = config["model_providers"]; + const provider = isRecord(providers) ? providers[modelProvider] : undefined; + const aws = isRecord(provider) ? provider["aws"] : undefined; + if (isRecord(aws)) { + const sanitized: JsonObject = {}; + for (const key of ["region", "profile"]) { + const value = aws[key]; + if (safeString(value)) sanitized[key] = value; + } + if (Object.keys(sanitized).length > 0) { + result["model_providers"] = { + [modelProvider]: { aws: sanitized }, + }; + } + } + } + const rootMarkers = config["project_root_markers"]; + if (Array.isArray(rootMarkers)) { + result["project_root_markers"] = rootMarkers.filter(safeString); + } + const projects = config["projects"]; + if (isRecord(projects)) { + const sanitized: JsonObject = {}; + for (const [path, project] of Object.entries(projects)) { + if (!safeString(path) || !isAbsolute(path) || !isRecord(project)) { + continue; + } + const trust = project["trust_level"]; + if (trust !== "trusted" && trust !== "untrusted") continue; + sanitized[path] = { trust_level: trust }; + } + if (Object.keys(sanitized).length > 0) result["projects"] = sanitized; + } + return result; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} From 4606bfd12e0719c99bfd9bc62dda4717314718d7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:39:33 +0000 Subject: [PATCH 026/251] Preserve accepted source coverage through deep scan publication --- .../mcp-app/src/artifact-deep-reducer.ts | 12 +- .../codex-security/mcp-app/src/artifact-io.ts | 1 + .../src/deep-scan/artifact-validation.ts | 92 ++++++++++++- .../mcp-app/src/deep-scan/coordinator.ts | 37 ++++-- .../mcp-app/src/deep-scan/worker-runner.ts | 2 +- .../tests/deep_scan_coverage_fixture.mjs | 124 ++++++++++++++++++ .../tests/deep_scan_publication_cases.mjs | 19 ++- .../tests/test_artifact_deep_reducer.mjs | 20 ++- .../scripts/report_projection.py | 33 +++++ .../tests-ts/deep-scan-coverage.test.ts | 99 ++++++++++++++ 10 files changed, 410 insertions(+), 29 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs create mode 100644 sdk/typescript/tests-ts/deep-scan-coverage.test.ts diff --git a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts index b8e147ed91..ec1d7a77f2 100644 --- a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts +++ b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { dirname, join, relative, sep } from "node:path"; import type { ZodType } from "zod/v4"; import commonSchema from "../../schemas/definitions/artifact-common.schema.json"; import reducerSchema from "../../schemas/tools/deep-reducer.schema.json"; @@ -21,6 +21,7 @@ import { } from "./deep-scan/artifacts.js"; import { parseDeepReduction, + projectDiscoveryCoverage, reconcileDeepReduction, type DeepReductionInput, type DeepReductionSources, @@ -73,8 +74,13 @@ export async function getCodexSecurityDeepReducerInputs( sourceFindingIds: [`${worker.id}:${index}`], }, })); - const { coverage: _coverage, ...reduction } = result; - return { workerId: worker.id, result: reduction }; + const { coverage, ...reduction } = result; + return { + workerId: worker.id, + ...(worker.attempt === undefined ? {} : { attempt: worker.attempt }), + coverage: projectDiscoveryCoverage(coverage, worker, relative(bound.artifacts.scanDir, dirname(worker.resultPath)).split(sep).join("/")), + result: reduction, + }; })); const previous = await readPreviousReduction(bound); const scanId = bound.scanId ?? previous?.scanId ?? discoveries[0]?.result.scanId; diff --git a/plugins/codex-security/mcp-app/src/artifact-io.ts b/plugins/codex-security/mcp-app/src/artifact-io.ts index 3208f6d8a1..a296e40465 100644 --- a/plugins/codex-security/mcp-app/src/artifact-io.ts +++ b/plugins/codex-security/mcp-app/src/artifact-io.ts @@ -5,6 +5,7 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path"; export interface DeepReducerWorkerContext { id: string; resultPath: string; + attempt?: number; } export interface DeepReducerContext { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index 291557a9e6..fe1687fbd4 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -10,10 +10,13 @@ import { import { readJsonObject, requireRegularFile, writeJsonAtomic } from "./artifacts.js"; import type { DeepScanArtifacts } from "./artifacts.js"; -export type DeepReductionInput = Omit; +export type DeepReductionInput = Omit & { + /** Host projection of accepted source coverage; never supplied by the reducer. */ + sourceCoverage?: ScanDraftInput["coverage"]; +}; export interface DeepReductionSources { - discoveries: { workerId: string; result: DeepReductionInput }[]; + discoveries: { workerId: string; attempt?: number; coverage?: ScanDraftInput["coverage"]; result: DeepReductionInput }[]; previous: DeepReductionInput | null; } @@ -22,6 +25,11 @@ export interface ReducerArtifactValidation { result: DeepReductionInput; } +export function deepReductionToScanDraft(result: DeepReductionInput): ScanDraftInput { + const { sourceCoverage, ...draft } = structuredClone(result); + return { ...draft, coverage: sourceCoverage ?? unknownSourceCoverage() }; +} + /** * Check reducer findings with the Standard scan validator. * It requires coverage, so add an empty value and remove it after validation. @@ -30,8 +38,9 @@ export function parseDeepReduction( input: Record, persisted = false, ): DeepReductionInput { + const { sourceCoverage, ...submitted } = input; const standard = { - ...input, + ...submitted, coverage: { completeness: "complete", surfaces: [], @@ -42,6 +51,9 @@ export function parseDeepReduction( const { coverage: _coverage, ...parsed } = persisted ? parsePersistedScanDraft(standard) : parseScanDraft(standard as unknown as ScanDraftInput); + if (persisted && sourceCoverage !== undefined) { + return { ...parsed, sourceCoverage: parsePersistedScanDraft({ ...standard, coverage: sourceCoverage }).coverage }; + } return parsed; } @@ -122,6 +134,7 @@ export function reconcileDeepReduction( previous: DeepReductionInput | null, ): DeepReductionInput { const result = structuredClone(input); + result.sourceCoverage = aggregateSourceCoverage(discoveries, previous); if (result.complete === false) throw new Error("Deep reduction is only a checkpoint, not a complete result."); for (const source of [...discoveries.map((discovery) => discovery.result), ...(previous ? [previous] : [])]) { if (source.scanId !== result.scanId) throw new Error("Deep reduction source belongs to a different scan."); @@ -180,6 +193,79 @@ export function reconcileDeepReduction( return result; } +/** Keep independent reviews separate: matching labels do not resolve another pass's proof gap. */ +export function aggregateSourceCoverage( + discoveries: DeepReductionSources["discoveries"], + previous: DeepReductionInput | null, +): ScanDraftInput["coverage"] { + const sources = [ + ...(previous ? [previous.sourceCoverage ?? unknownSourceCoverage()] : []), + ...discoveries.map((source) => source.coverage ?? unknownSourceCoverage()), + ]; + const result: ScanDraftInput["coverage"] = { + completeness: "complete", surfaces: [], explicitExclusions: [], deferred: [], reviews: [], + }; + for (const field of ["surfaces", "explicitExclusions", "deferred", "openQuestions", "reviews"]) { + const entries = sources.flatMap((source) => (source[field] as unknown[] | undefined) ?? []); + if (entries.length || field !== "openQuestions") result[field] = structuredClone(entries); + } + if (sources.some((source) => source.completeness === "partial") + || (result.deferred as unknown[]).length > 0 + || (result.surfaces as Record[]).some((surface) => surface.disposition === "needs_follow_up")) { + result.completeness = "partial"; + } else if (sources.some((source) => source.completeness === "unknown")) { + result.completeness = "unknown"; + } + return result; +} + +function unknownSourceCoverage(): ScanDraftInput["coverage"] { + return { completeness: "unknown", surfaces: [], explicitExclusions: [], deferred: [] }; +} + +/** Qualify worker-local IDs and receipt paths before combining accepted coverage. */ +export function projectDiscoveryCoverage( + coverage: ScanDraftInput["coverage"], + worker: { id: string; attempt?: number }, + artifactPrefix: string, +): ScanDraftInput["coverage"] { + const provenance = { workerId: worker.id, ...(worker.attempt === undefined ? {} : { attempt: worker.attempt }) }; + const prefix = `${worker.id}-attempt-${worker.attempt ?? "unknown"}`; + const surfaces = coverage.surfaces as Record[]; + const surfaceIds = new Map(surfaces.map((surface, index) => [surface.id, `${prefix}-surface-${index + 1}`])); + const project = (item: Record) => ({ + ...structuredClone(item), + provenance: { + ...provenance, + ...(item.id === undefined ? {} : { sourceId: item.id }), + ...(item.candidateId === undefined ? {} : { candidateId: item.candidateId }), + }, + }); + return { + completeness: coverage.completeness, + reviews: [{ ...provenance, completeness: coverage.completeness }], + surfaces: surfaces.map((surface, index) => ({ + ...project(surface), + id: `${prefix}-surface-${index + 1}`, + receiptRefs: ((surface.receiptRefs as string[] | undefined) ?? []).map((ref) => `${artifactPrefix}/${ref}`), + })), + explicitExclusions: (coverage.explicitExclusions as Record[]).map(project), + deferred: (coverage.deferred as Record[]).map((item, index) => ({ + ...project(item), + id: `${prefix}-deferred-${index + 1}`, + ...(item.candidateId === undefined ? {} : { candidateId: `${prefix}-candidate-${index + 1}` }), + ...(item.surfaceIds === undefined ? {} : { + surfaceIds: (item.surfaceIds as string[]).map((id) => surfaceIds.get(id) ?? id), + }), + })), + ...(coverage.openQuestions === undefined ? {} : { + openQuestions: (coverage.openQuestions as (string | Record)[]).map((question) => ( + project(typeof question === "string" ? { question } : question) + )), + }), + }; +} + function findingSourceIds(finding: Record): string[] { const provenance = finding.provenance as Record; const ids = provenance.sourceFindingIds; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index ffa641d978..3ecac4ced5 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -3,12 +3,15 @@ import { promises as fs } from "node:fs"; import { basename, dirname, join } from "node:path"; import { createDeepScanArtifacts, - ensureDeepScanDirectories + ensureDeepScanDirectories, + writeJsonAtomic } from "./artifacts.js"; -import { validateDiscoveryArtifacts, validateReducerArtifacts, type DeepReductionInput } from "./artifact-validation.js"; +import { aggregateSourceCoverage, deepReductionToScanDraft, validateDiscoveryArtifacts, validateReducerArtifacts, type DeepReductionInput } from "./artifact-validation.js"; +import { getCodexSecurityDeepReducerInputs } from "../artifact-deep-reducer.js"; import { scanDraftInputSchema, type DeepScanPublication, + saveScanDraftCheckpoint, type ScanDraftInput } from "../artifact-scan-draft.js"; import type { DeepScanArtifacts } from "./artifacts.js"; @@ -269,17 +272,7 @@ export class DeepScanCoordinator { if (this.canceled || this.externallyFailed) return; this.phase = "terminal"; const draft = schedulerResult.result - ? { - ...structuredClone(schedulerResult.result), - // Readers require coverage.json. The coordinator has accepted this - // result, so mark it complete and leave review notes empty. - coverage: { - completeness: "complete", - surfaces: [], - explicitExclusions: [], - deferred: [] - } - } + ? deepReductionToScanDraft(schedulerResult.result) : scanDraftInputSchema.parse({ scanId: this.state.scanId, findings: [], @@ -977,6 +970,24 @@ export class DeepScanCoordinator { reducerId: worker.id, previousReducerResultPath: outcomes.at(-1)?.resultPath }, this.state.scanId); + if (result.sourceCoverage === undefined) { + const context = { + root: worker.artifactDir, + repoRoot: this.state.targetPath, + scanId: this.state.scanId, + layout: "reducer" as const, + deepReducer: { + scanRoot: this.artifacts.scanDir, + claimedWorkers: accepted.map((source) => ({ + id: source.id, resultPath: source.resultPath, attempt: source.attempt, + })), + }, + }; + const sources = await getCodexSecurityDeepReducerInputs(context); + result.sourceCoverage = aggregateSourceCoverage(sources.discoveries, latestResult ?? null); + await saveScanDraftCheckpoint(context, result); + await writeJsonAtomic(worker.resultManifestPath, result); + } latestResult = result; noNewStreak = newFindings > 0 ? 0 : noNewStreak + accepted.length; outcomes.push({ diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 51738be8f3..14ba7938d4 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -299,7 +299,7 @@ export class DeepScanWorkerRunner { layout: "reducer" as const, deepReducer: { scanRoot: artifacts.scanDir, - claimedWorkers: consumed.map((worker) => ({ id: worker.id, resultPath: worker.resultPath })), + claimedWorkers: consumed.map((worker) => ({ id: worker.id, resultPath: worker.resultPath, attempt: worker.attempt })), previousReducerResultPath } }; diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs new file mode 100644 index 0000000000..f8c3fa761a --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { build } from "esbuild"; + +const pluginRoot = fileURLToPath(new URL("../../", import.meta.url)); +const exec = promisify(execFile); +const bundled = await build({ + bundle: true, + stdin: { + contents: [ + 'export { DeepScanCoordinator } from "./src/deep-scan/coordinator.ts";', + 'export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts";', + 'export { createScanArtifactContext } from "./src/artifact-context.ts";', + 'export { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.ts";', + ].join("\n"), + resolveDir: path.join(pluginRoot, "mcp-app"), + }, + format: "esm", platform: "node", loader: { ".md": "text" }, write: false, +}); +export async function publishCoverageFixture(root, completeness, { resume = false } = {}) { + const runtimePath = path.join(root, "fixture-runtime.mjs"); + await writeFile(runtimePath, bundled.outputFiles[0].contents); + const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench } = await import(pathToFileURL(runtimePath).href); + const targetPath = path.join(root, "target"); + const codexHome = path.join(root, "codex-home"); + const scanRoot = path.join(root, "scans"); + const threadId = "coverage-fixture-owner"; + const statuses = completeness === "partial" ? ["partial", "complete", "unknown"] + : completeness === "unknown" ? ["unknown", "complete"] : ["complete"]; + await mkdir(scanRoot, { mode: 0o700 }); + await mkdir(targetPath, { recursive: true }); + await mkdir(path.join(codexHome, "codex-security"), { recursive: true }); + await writeFile(path.join(targetPath, "source.py"), "# Synthetic source\n"); + await writeFile(path.join(codexHome, "codex-security", "config.toml"), + `[deep_scan]\nworkers = 1\nsubagents = 0\nstop_after_no_new = ${statuses.length}\nmax_discovery_runs = ${statuses.length}\n`); + const runWorkbench = async (args) => { + const { stdout } = await exec(process.env.PYTHON || "python3", [path.join(pluginRoot, "scripts", "workbench_db.py"), ...args], { + env: { ...process.env, CODEX_HOME: codexHome, CODEX_SECURITY_STATE_DIR: path.join(root, "state") }, + }); + return JSON.parse(stdout); + }; + const store = new WorkbenchDeepScanStore(runWorkbench); + let { run } = await store.begin({ targetPath, scope: ".", threadId, scanRoot }); + const context = await createScanArtifactContext(run.scanId, runWorkbench, { requireRunning: true }); + const rawSources = new Map(); + const writeDiscovery = async (artifactDir, index) => { + const status = statuses[index]; + const pending = completeness === "partial" && status !== "complete"; + const coverage = { + completeness: status, + surfaces: [{ id: "shared-surface", label: "Archive route", disposition: pending ? "needs_follow_up" : "no_issue_found", receiptRefs: ["artifacts/review.md"] }], + explicitExclusions: [{ pattern: "vendor/", reason: "External dependency." }], + deferred: pending ? [{ id: "same-id", candidateId: "candidate-1", reason: index === 0 ? "Verify entry boundaries." : "Verify symbolic links.", paths: ["source.py"], surfaceIds: ["shared-surface"] }] : [], + openQuestions: pending ? [{ question: `Deployment question ${index + 1}.` }] : [], + }; + await mkdir(path.join(artifactDir, "artifacts"), { recursive: true }); + await writeFile(path.join(artifactDir, "artifacts", "review.md"), "Synthetic review evidence.\n"); + const resultPath = path.join(artifactDir, "result.json"); + const bytes = JSON.stringify({ scanId: run.scanId, complete: true, findings: [], coverage }); + await writeFile(resultPath, bytes); + rawSources.set(resultPath, bytes); + }; + if (resume) { + const workers = []; + for (const index of statuses.keys()) { + const workerRoot = path.join(run.scanDir, "artifacts", "deep_discovery", "workers", `discovery-${String(index + 1).padStart(4, "0")}`); + const artifactDir = path.join(workerRoot, "output"); + const worker = { id: randomUUID(), scanId: run.scanId, kind: "discovery", promptPath: path.join(workerRoot, "prompt.md"), artifactDir, attempt: index === 0 ? 2 : 1 }; + await writeDiscovery(artifactDir, index); + await writeFile(worker.promptPath, "Synthetic discovery prompt.\n"); + for (const status of ["queued", "running", "succeeded"]) { + await store.updateWorker({ ...worker, status, ...(status === "succeeded" ? { resultManifestPath: path.join(artifactDir, "result.json") } : {}) }); + } + workers.push(worker); + } + const artifactDir = path.join(run.scanDir, "artifacts", "deep_discovery", "dedup", "dedup-0001", "output"); + const promptPath = path.join(path.dirname(artifactDir), "prompt.md"); + await mkdir(artifactDir, { recursive: true }); + await writeFile(promptPath, "Synthetic reducer prompt.\n"); + const id = randomUUID(); + await store.claimDedup({ id, scanId: run.scanId, workerIds: workers.map((worker) => worker.id), artifactDir, promptPath }); + const resultManifestPath = path.join(artifactDir, "result.json"); + // Legacy accepted reducers omitted coverage entirely. + await writeFile(resultManifestPath, JSON.stringify({ scanId: run.scanId, findings: [] })); + await store.commitDedup({ id, scanId: run.scanId, newFindings: 0, resultManifestPath }); + run = await store.get(run.scanId, threadId); + } + let discoveryCalls = 0; + const executor = { + async run(request) { + assert.equal(resume, false, "accepted legacy sources should resume without new model work"); + const thread = request.resumeThreadId ?? randomUUID(); + await request.onThreadStarted?.(thread); + if (request.kind === "discovery") { + discoveryCalls++; + const index = Number(path.basename(path.dirname(request.promptPath)).split("-").at(-1)) - 1; + if (index === 0 && !request.resumeThreadId) return { threadId: thread, finalResponse: "Continue the unfinished audit." }; + await writeDiscovery(request.artifactContext.root, index); + } else { + await writeFile(path.join(request.artifactContext.root, "result.json"), JSON.stringify({ scanId: run.scanId, findings: [] })); + } + return { threadId: thread, finalResponse: "Audit finished." }; + }, + }; + const coordinator = new DeepScanCoordinator({ + run, store, executor, pluginRoot, retryDelaysMs: [1], + onComplete: async (draft, signal) => { + await recordCodexSecurityScanDraftViaWorkbench(context, draft, runWorkbench, signal); + }, + }); + coordinator.start(); + const terminal = await coordinator.wait(undefined, 30_000); + assert.equal(terminal?.status, "succeeded", terminal?.error); + assert.equal(terminal.noNewStreak, statuses.length, "source coverage must not change stopping policy"); + assert.equal(discoveryCalls, resume ? 0 : statuses.length + 1); + await runWorkbench(["complete-scan", "--scan-id", run.scanId]); + for (const [file, bytes] of rawSources) assert.equal(await readFile(file, "utf8"), bytes); + return { scanDir: run.scanDir, threadId, terminal }; +} diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs index 4221c95ee4..e0a12fd2eb 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs @@ -55,7 +55,7 @@ export async function testDeepScanPublication({ assert.deepEqual(completed[0].findings, [], "late worker findings are not appended to the saturated aggregate"); } - async function testSuccessfulDeepCoverageIgnoresWorkerAndReducerReviewStatus() { + async function testSuccessfulDeepCoveragePreservesWorkerReviewStatus() { const fixture = await fixtureRun({ workers: 2, subagents: 0, stopAfterNoNew: 2, maxDiscoveryRuns: 2 }); const store = new FakeStore(fixture.run); const executor = new FakeExecutor(); @@ -87,9 +87,16 @@ export async function testDeepScanPublication({ const terminal = await coordinator.wait(undefined, 5_000); assert.equal(terminal?.status, "succeeded", terminal?.error); assert.equal(completed.length, 1); - assert.deepEqual(completed[0].coverage, { - completeness: "complete", surfaces: [], explicitExclusions: [], deferred: [], - }); + assert.equal(completed[0].coverage.completeness, "partial"); + assert.equal(completed[0].coverage.deferred.length, 2); + assert.equal(completed[0].coverage.surfaces.length, 4); + assert.equal(completed[0].coverage.reviews.length, 2); + assert.deepEqual(new Set(completed[0].coverage.reviews.map((review) => review.completeness)), + new Set(["partial", "unknown"])); + for (const item of completed[0].coverage.deferred) { + assert.equal(item.provenance.attempt, 1); + assert.ok(store.workers.has(item.provenance.workerId)); + } for (const worker of store.workers.values()) { if (worker.kind !== "discovery") continue; const draft = JSON.parse(await readFile(worker.resultManifestPath, "utf8")); @@ -142,7 +149,7 @@ export async function testDeepScanPublication({ )); const { coverage, ...publishedReduction } = completed[0]; assert.deepEqual( - publishedReduction, + { ...publishedReduction, sourceCoverage: coverage }, JSON.parse(await readFile(acceptedReducer.resultManifestPath, "utf8")), "the accepted aggregate still reaches publication when redundant cancellation writes fail", ); @@ -187,7 +194,7 @@ export async function testDeepScanPublication({ } await testSaturationOmitsWorkerAcceptedDuringCancellation(); - await testSuccessfulDeepCoverageIgnoresWorkerAndReducerReviewStatus(); + await testSuccessfulDeepCoveragePreservesWorkerReviewStatus(); await testSaturationIgnoresDiscoveryCancellationWriteFailure(); await testPublicationUsesAcceptedReducerSnapshot(); } diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs index 5466999cc2..035a499f91 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs @@ -127,13 +127,19 @@ try { /evidenceRefs must refer/, "live reducer submissions reject unknown evidence references instead of silently removing them", ); - assert.deepEqual(inputs, { + assert.deepEqual({ ...inputs, discoveries: inputs.discoveries.map(({ coverage, ...source }) => source) }, { discoveries: [ { workerId: first.id, result: withSourceRefs(first) }, { workerId: second.id, result: withSourceRefs(second) } ], previous: null }); + assert.equal(inputs.discoveries[0].coverage.completeness, "partial"); + assert.equal(inputs.discoveries[1].coverage.completeness, "unknown"); + assert.deepEqual(inputs.discoveries[0].coverage.deferred[0].provenance, + { workerId: first.id, candidateId: "candidate-upload" }); + assert.deepEqual(inputs.discoveries[0].coverage.surfaces[0].receiptRefs, + ["artifacts/deep_discovery/workers/discovery-0001/output/artifacts/missing-worker-receipt.md"]); assert.equal(JSON.stringify(inputs).includes(root), false); assert.equal(JSON.stringify(inputs).includes("result.json"), false); @@ -158,6 +164,10 @@ try { const outcome = await recordCodexSecurityDeepReduction(context, merged); const mergedWithSources = { ...merged, + sourceCoverage: { + ...inputs.discoveries[0].coverage, + reviews: [...inputs.discoveries[0].coverage.reviews, ...inputs.discoveries[1].coverage.reviews], + }, findings: [ retainedFinding(shared, [{ id: "worker-001:0", finding: shared }, { id: "worker-002:0", finding: shared }]), retainedFinding(independent, [{ id: "worker-002:1", finding: independent }]), @@ -176,7 +186,7 @@ try { assert.deepEqual( JSON.parse(await readFile(path.join(outputRoot, "checkpoints", checkpointNames[0]), "utf8")), mergedWithSources, - "reducer checkpoints retain the accepted findings and scope without coverage", + "reducer checkpoints retain accepted findings, scope and source coverage", ); assert.deepEqual( @@ -239,7 +249,7 @@ try { } }; const nextInputs = await getCodexSecurityDeepReducerInputs(nextContext); - assert.deepEqual(nextInputs, { + assert.deepEqual({ ...nextInputs, discoveries: nextInputs.discoveries.map(({ coverage, ...source }) => source) }, { discoveries: [{ workerId: third.id, result: withSourceRefs(third) }], previous: mergedWithSources }); @@ -255,6 +265,10 @@ try { JSON.parse(await readFile(path.join(nextOutputRoot, "result.json"), "utf8")), { ...mergedWithSources, + sourceCoverage: { + ...mergedWithSources.sourceCoverage, + reviews: [...mergedWithSources.sourceCoverage.reviews, ...nextInputs.discoveries[0].coverage.reviews], + }, findings: [ retainedFinding(shared, [ { id: "worker-003:0", finding: shared }, diff --git a/plugins/codex-security/scripts/report_projection.py b/plugins/codex-security/scripts/report_projection.py index b98b052e34..cc404e5f8f 100644 --- a/plugins/codex-security/scripts/report_projection.py +++ b/plugins/codex-security/scripts/report_projection.py @@ -514,6 +514,9 @@ def _target_scope_lines(target: dict[str, Any]) -> list[str]: def _surface_notes(surface: dict[str, Any]) -> str: notes = surface.get("notes", "No additional canonical notes were recorded.") + source = _coverage_source(surface) + if source: + notes = f"{source}. {notes}" receipt_refs = surface.get("receiptRefs", []) if not isinstance(receipt_refs, list) or not receipt_refs: return _cell(notes) @@ -523,6 +526,16 @@ def _surface_notes(surface: dict[str, Any]) -> str: return _cell(f"{notes} Evidence: {evidence}") +def _coverage_source(item: dict[str, Any]) -> str: + provenance = item.get("provenance", {}) + if not isinstance(provenance, dict) or not provenance.get("workerId"): + return "" + source = f"Review {provenance['workerId']}" + if provenance.get("attempt") is not None: + source += f", attempt {provenance['attempt']}" + return source + + def _remediation_section(finding: dict[str, Any]) -> list[str]: remediation = _text(finding.get("remediation"), "No canonical remediation was recorded.") lines = ["", "#### Remediation", "", remediation] @@ -1033,6 +1046,22 @@ def build_report_markdown( f"[Open the structural hardening portfolio]({hardening_portfolio_path})", ] ) + reviews = coverage.get("reviews", []) + if reviews: + lines.extend( + [ + "", + "## Source Review Coverage", + "", + "| Review | Attempt | Coverage |", + "| --- | --- | --- |", + ] + ) + for review in reviews: + if isinstance(review, dict): + lines.append( + f"| {_cell(review.get('workerId'))} | {_cell(str(review.get('attempt', 'unknown')))} | {_cell(review.get('completeness'))} |" + ) surfaces = coverage.get("surfaces", []) if surfaces: lines.extend( @@ -1070,6 +1099,7 @@ def build_report_markdown( questions.extend( { "question": item.get("reason", "Deferred review requires follow-up."), + "provenance": item.get("provenance", {}), "followUpPrompt": " ".join( ( f"Review deferred unit {item.get('id', 'unknown')} and close its stated proof gap.", @@ -1091,6 +1121,9 @@ def build_report_markdown( if not isinstance(question, dict): continue lines.append(f"- {_text(question.get('question'), 'Unspecified open question.')}") + source = _coverage_source(question) + if source: + lines.append(f" - {_text(source, '')}.") prompt = _text(question.get("followUpPrompt"), "") if prompt: lines.append(f" - Follow-up prompt: {prompt}") diff --git a/sdk/typescript/tests-ts/deep-scan-coverage.test.ts b/sdk/typescript/tests-ts/deep-scan-coverage.test.ts new file mode 100644 index 0000000000..5ddcb502b9 --- /dev/null +++ b/sdk/typescript/tests-ts/deep-scan-coverage.test.ts @@ -0,0 +1,99 @@ +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "bun:test"; +import { main } from "../src/cli.js"; +import { loadContract } from "../src/contract.js"; +import { ScanResult } from "../src/result.js"; +import { capture, dependencies } from "./cli-fixtures.js"; + +const fixtureUrl = new URL( + "../../../plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs", + import.meta.url, +); +const { publishCoverageFixture } = await import(fixtureUrl.href); + +test.each([ + ["partial", false], + ["unknown", false], + ["complete", false], + ["partial", true], +] as const)( + "publishes %s source coverage through CLI results (resume: %p)", + async (completeness, resume) => { + const root = await mkdtemp(join(tmpdir(), "deep-coverage-publication-")); + try { + await mkdir(join(root, "fixture"), { mode: 0o700 }); + const { scanDir, threadId, terminal } = await publishCoverageFixture( + join(root, "fixture"), + completeness, + { resume }, + ); + const contract = await loadContract(scanDir, { + pluginRoot: fileURLToPath( + new URL("../../../plugins/codex-security/", import.meta.url), + ), + }); + const result = new ScanResult({ + ...contract, + scanDir, + threadId, + turnResult: { status: "completed" }, + }); + expect(result.coverage.completeness).toBe(completeness); + const coverage = JSON.parse( + await readFile(join(scanDir, "coverage.json"), "utf8"), + ); + expect(coverage.reviews[0].attempt).toBe(2); + const report = await readFile(join(scanDir, "report.md"), "utf8"); + expect(report).toContain(`| Coverage | ${completeness} |`); + expect(coverage.explicitExclusions).toHaveLength(coverage.reviews.length); + for (const review of coverage.reviews) expect(report).toContain(review.workerId); + if (completeness === "partial") { + expect( + coverage.deferred.map((item: { reason: string }) => item.reason), + ).toEqual(["Verify entry boundaries.", "Verify symbolic links."]); + expect( + new Set(coverage.deferred.map((item: { id: string }) => item.id)) + .size, + ).toBe(2); + expect( + coverage.reviews.map( + (review: { completeness: string }) => review.completeness, + ), + ).toEqual(["partial", "complete", "unknown"]); + for (const item of coverage.deferred) { + expect(item.provenance.candidateId).toBe("candidate-1"); + expect(report).toContain(item.reason); + expect( + coverage.surfaces.some((surface: { id: string }) => + item.surfaceIds.includes(surface.id), + ), + ).toBe(true); + } + } + for (const surface of coverage.surfaces) { + expect( + await readFile(join(scanDir, surface.receiptRefs[0]), "utf8"), + ).toContain("Synthetic review evidence."); + } + const stdout = capture(); + const stderr = capture(); + expect( + await main( + ["scan", "--mode", "deep", "--json"], + stdout.stream, + stderr.stream, + dependencies({ result, onWorkbench: () => ({ deepScan: terminal }) }), + ), + ).toBe(completeness === "complete" ? 0 : 2); + expect(JSON.parse(stdout.text()).coverage).toEqual(coverage); + if (completeness !== "complete") + expect(stderr.text()).toContain("STOPPED"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 60_000, +); From a6be9e586af10063acb3f49bc73267792b2ed075 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:42:42 +0000 Subject: [PATCH 027/251] Keep coverage accounting outside reducer model inputs --- .../mcp-app/src/artifact-deep-reducer.ts | 14 +++++++++++++- .../mcp-app/src/deep-scan/coordinator.ts | 4 ++-- .../mcp-app/src/deep-scan/worker-runner.ts | 4 ++-- .../mcp-app/tests/test_artifact_deep_reducer.mjs | 10 +++++++++- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts index ec1d7a77f2..7e1604e8ea 100644 --- a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts +++ b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts @@ -55,6 +55,18 @@ interface BoundReducer { /** Read the findings and scan context assigned to this reducer. */ export async function getCodexSecurityDeepReducerInputs( context: ArtifactContext +): Promise { + const inputs = await readDeepReductionSources(context); + const { sourceCoverage: _coverage, ...previous } = inputs.previous ?? {}; + return { + discoveries: inputs.discoveries.map(({ workerId, result }) => ({ workerId, result })), + previous: inputs.previous === null ? null : previous as DeepReductionInput, + }; +} + +/** Capture host coverage alongside the reducer's immutable finding inputs. */ +export async function readDeepReductionSources( + context: ArtifactContext ): Promise { return withLogicalReducerErrors(context, async () => { const bound = bindDeepReducer(context); @@ -113,7 +125,7 @@ export async function recordCodexSecurityDeepReduction( const submitted = deepReductionInputSchema.parse(input); let reduction = parseDeepReduction(submitted); if (reduction.complete === false) throw new Error("Deep reduction is only a checkpoint, not a complete result."); - const inputs = await getCodexSecurityDeepReducerInputs(context); + const inputs = await readDeepReductionSources(context); const expectedScanId = bound.scanId ?? inputs.previous?.scanId ?? inputs.discoveries[0]?.result.scanId; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 3ecac4ced5..07fce30f11 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -7,7 +7,7 @@ import { writeJsonAtomic } from "./artifacts.js"; import { aggregateSourceCoverage, deepReductionToScanDraft, validateDiscoveryArtifacts, validateReducerArtifacts, type DeepReductionInput } from "./artifact-validation.js"; -import { getCodexSecurityDeepReducerInputs } from "../artifact-deep-reducer.js"; +import { readDeepReductionSources } from "../artifact-deep-reducer.js"; import { scanDraftInputSchema, type DeepScanPublication, @@ -983,7 +983,7 @@ export class DeepScanCoordinator { })), }, }; - const sources = await getCodexSecurityDeepReducerInputs(context); + const sources = await readDeepReductionSources(context); result.sourceCoverage = aggregateSourceCoverage(sources.discoveries, latestResult ?? null); await saveScanDraftCheckpoint(context, result); await writeJsonAtomic(worker.resultManifestPath, result); diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 14ba7938d4..c977fa79db 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; import { dirname, join } from "node:path"; -import { getCodexSecurityDeepReducerInputs } from "../artifact-deep-reducer.js"; +import { readDeepReductionSources } from "../artifact-deep-reducer.js"; import { validateDiscoveryArtifacts, validateReducerArtifacts @@ -305,7 +305,7 @@ export class DeepScanWorkerRunner { }; // Snapshot inputs before execution: direct file output has the same // conservation checks as the MCP writer without rereading consumed sources. - const sources = await getCodexSecurityDeepReducerInputs(artifactContext); + const sources = await readDeepReductionSources(artifactContext); let reducerValidation: ReducerArtifactValidation | undefined; let outcome = await this.runWorkerWithRetries({ workerId: reducerId, diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs index 035a499f91..d2e424c327 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs @@ -14,7 +14,8 @@ const bundled = await build({ const { deepReducerInputsInputSchema, deepReductionInputSchema, - getCodexSecurityDeepReducerInputs, + getCodexSecurityDeepReducerInputs: getModelInputs, + readDeepReductionSources: getCodexSecurityDeepReducerInputs, recordCodexSecurityDeepReduction } = await import( "data:text/javascript;base64," @@ -136,6 +137,10 @@ try { }); assert.equal(inputs.discoveries[0].coverage.completeness, "partial"); assert.equal(inputs.discoveries[1].coverage.completeness, "unknown"); + assert.deepEqual(await getModelInputs(context), { + discoveries: inputs.discoveries.map(({ workerId, result }) => ({ workerId, result })), + previous: null, + }, "coverage accounting does not change reducer model inputs"); assert.deepEqual(inputs.discoveries[0].coverage.deferred[0].provenance, { workerId: first.id, candidateId: "candidate-upload" }); assert.deepEqual(inputs.discoveries[0].coverage.surfaces[0].receiptRefs, @@ -249,6 +254,9 @@ try { } }; const nextInputs = await getCodexSecurityDeepReducerInputs(nextContext); + const { sourceCoverage, ...previousModelInput } = mergedWithSources; + assert.deepEqual((await getModelInputs(nextContext)).previous, previousModelInput, + "host coverage metadata is excluded from the previous model input too"); assert.deepEqual({ ...nextInputs, discoveries: nextInputs.discoveries.map(({ coverage, ...source }) => source) }, { discoveries: [{ workerId: third.id, result: withSourceRefs(third) }], previous: mergedWithSources From 07e1b7266ed19738cbd7f7b287ea26821afcf268 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:48:41 +0000 Subject: [PATCH 028/251] Isolate real coverage publication fixtures from module mocks --- .../tests/deep_scan_coverage_fixture.mjs | 5 +++ .../tests-ts/deep-scan-coverage.test.ts | 31 +++++++++++++------ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index f8c3fa761a..7da03f017e 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -122,3 +122,8 @@ export async function publishCoverageFixture(root, completeness, { resume = fals for (const [file, bytes] of rawSources) assert.equal(await readFile(file, "utf8"), bytes); return { scanDir: run.scanDir, threadId, terminal }; } + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const result = await publishCoverageFixture(process.argv[2], process.argv[3], { resume: process.argv[4] === "true" }); + process.stdout.write(JSON.stringify(result)); +} diff --git a/sdk/typescript/tests-ts/deep-scan-coverage.test.ts b/sdk/typescript/tests-ts/deep-scan-coverage.test.ts index 5ddcb502b9..64fc85603f 100644 --- a/sdk/typescript/tests-ts/deep-scan-coverage.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-coverage.test.ts @@ -12,7 +12,6 @@ const fixtureUrl = new URL( "../../../plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs", import.meta.url, ); -const { publishCoverageFixture } = await import(fixtureUrl.href); test.each([ ["partial", false], @@ -24,12 +23,25 @@ test.each([ async (completeness, resume) => { const root = await mkdtemp(join(tmpdir(), "deep-coverage-publication-")); try { - await mkdir(join(root, "fixture"), { mode: 0o700 }); - const { scanDir, threadId, terminal } = await publishCoverageFixture( - join(root, "fixture"), - completeness, - { resume }, + await mkdir(join(root, "fixture"), { mode: 0o700 }); + // Keep the real workbench outside other suites' persistent module mocks. + const child = Bun.spawn( + [ + Bun.which("node")!, + fileURLToPath(fixtureUrl), + join(root, "fixture"), + completeness, + String(resume), + ], + { stdout: "pipe", stderr: "pipe" }, ); + const [output, errors, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(exitCode, errors).toBe(0); + const { scanDir, threadId, terminal } = JSON.parse(output); const contract = await loadContract(scanDir, { pluginRoot: fileURLToPath( new URL("../../../plugins/codex-security/", import.meta.url), @@ -47,9 +59,10 @@ test.each([ ); expect(coverage.reviews[0].attempt).toBe(2); const report = await readFile(join(scanDir, "report.md"), "utf8"); - expect(report).toContain(`| Coverage | ${completeness} |`); - expect(coverage.explicitExclusions).toHaveLength(coverage.reviews.length); - for (const review of coverage.reviews) expect(report).toContain(review.workerId); + expect(report).toContain(`| Coverage | ${completeness} |`); + expect(coverage.explicitExclusions).toHaveLength(coverage.reviews.length); + for (const review of coverage.reviews) + expect(report).toContain(review.workerId); if (completeness === "partial") { expect( coverage.deferred.map((item: { reason: string }) => item.reason), From b45c1be89c049dc5c33daa526dea2de77e4072e2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 03:56:45 +0000 Subject: [PATCH 029/251] Preserve accepted reducer bytes during coverage recovery --- .../mcp-app/src/deep-scan/coordinator.ts | 9 +++------ .../mcp-app/src/deep-scan/worker-runner.ts | 7 ++++++- .../tests/deep_scan_coverage_fixture.mjs | 17 ++++++++++------- .../tests-ts/deep-scan-coverage.test.ts | 14 ++++++++------ 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 07fce30f11..7a20c4077e 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -3,15 +3,13 @@ import { promises as fs } from "node:fs"; import { basename, dirname, join } from "node:path"; import { createDeepScanArtifacts, - ensureDeepScanDirectories, - writeJsonAtomic + ensureDeepScanDirectories } from "./artifacts.js"; import { aggregateSourceCoverage, deepReductionToScanDraft, validateDiscoveryArtifacts, validateReducerArtifacts, type DeepReductionInput } from "./artifact-validation.js"; import { readDeepReductionSources } from "../artifact-deep-reducer.js"; import { scanDraftInputSchema, type DeepScanPublication, - saveScanDraftCheckpoint, type ScanDraftInput } from "../artifact-scan-draft.js"; import type { DeepScanArtifacts } from "./artifacts.js"; @@ -767,7 +765,8 @@ export class DeepScanCoordinator { id: randomUUID(), label: `dedup-${String(reducerSequence).padStart(4, "0")}`, consumed, - previousReducerResultPath + previousReducerResultPath, + previousSourceCoverage: latestResult?.sourceCoverage, })); observe(reducer); } @@ -985,8 +984,6 @@ export class DeepScanCoordinator { }; const sources = await readDeepReductionSources(context); result.sourceCoverage = aggregateSourceCoverage(sources.discoveries, latestResult ?? null); - await saveScanDraftCheckpoint(context, result); - await writeJsonAtomic(worker.resultManifestPath, result); } latestResult = result; noNewStreak = newFindings > 0 ? 0 : noNewStreak + accepted.length; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index c977fa79db..0a60a174bd 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -81,6 +81,7 @@ export interface ReducerRequest { label: string; consumed: AcceptedDiscovery[]; previousReducerResultPath?: string; + previousSourceCoverage?: DeepReductionInput["sourceCoverage"]; } export interface DeepScanWorkerRunnerOptions { @@ -261,7 +262,8 @@ export class DeepScanWorkerRunner { id: reducerId, label: reducerLabel, consumed, - previousReducerResultPath + previousReducerResultPath, + previousSourceCoverage } = request; const { artifacts, run } = this.options; const reducerRoot = join(artifacts.dedupRoot, reducerLabel); @@ -306,6 +308,9 @@ export class DeepScanWorkerRunner { // Snapshot inputs before execution: direct file output has the same // conservation checks as the MCP writer without rereading consumed sources. const sources = await readDeepReductionSources(artifactContext); + if (sources.previous && previousSourceCoverage !== undefined) { + sources.previous.sourceCoverage = structuredClone(previousSourceCoverage); + } let reducerValidation: ReducerArtifactValidation | undefined; let outcome = await this.runWorkerWithRetries({ workerId: reducerId, diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index 7da03f017e..dca951ff9d 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -17,15 +17,16 @@ const bundled = await build({ 'export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts";', 'export { createScanArtifactContext } from "./src/artifact-context.ts";', 'export { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.ts";', + 'export { recordCodexSecurityDeepReduction } from "./src/artifact-deep-reducer.ts";', ].join("\n"), resolveDir: path.join(pluginRoot, "mcp-app"), }, format: "esm", platform: "node", loader: { ".md": "text" }, write: false, }); -export async function publishCoverageFixture(root, completeness, { resume = false } = {}) { +export async function publishCoverageFixture(root, completeness, { resume = false, continueAfterResume = false } = {}) { const runtimePath = path.join(root, "fixture-runtime.mjs"); await writeFile(runtimePath, bundled.outputFiles[0].contents); - const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench } = await import(pathToFileURL(runtimePath).href); + const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityDeepReduction } = await import(pathToFileURL(runtimePath).href); const targetPath = path.join(root, "target"); const codexHome = path.join(root, "codex-home"); const scanRoot = path.join(root, "scans"); @@ -67,7 +68,8 @@ export async function publishCoverageFixture(root, completeness, { resume = fals }; if (resume) { const workers = []; - for (const index of statuses.keys()) { + const seeded = continueAfterResume ? statuses.slice(0, -1) : statuses; + for (const index of seeded.keys()) { const workerRoot = path.join(run.scanDir, "artifacts", "deep_discovery", "workers", `discovery-${String(index + 1).padStart(4, "0")}`); const artifactDir = path.join(workerRoot, "output"); const worker = { id: randomUUID(), scanId: run.scanId, kind: "discovery", promptPath: path.join(workerRoot, "prompt.md"), artifactDir, attempt: index === 0 ? 2 : 1 }; @@ -87,13 +89,14 @@ export async function publishCoverageFixture(root, completeness, { resume = fals const resultManifestPath = path.join(artifactDir, "result.json"); // Legacy accepted reducers omitted coverage entirely. await writeFile(resultManifestPath, JSON.stringify({ scanId: run.scanId, findings: [] })); + rawSources.set(resultManifestPath, await readFile(resultManifestPath, "utf8")); await store.commitDedup({ id, scanId: run.scanId, newFindings: 0, resultManifestPath }); run = await store.get(run.scanId, threadId); } let discoveryCalls = 0; const executor = { async run(request) { - assert.equal(resume, false, "accepted legacy sources should resume without new model work"); + assert.equal(resume && !continueAfterResume, false, "accepted legacy sources should resume without new model work"); const thread = request.resumeThreadId ?? randomUUID(); await request.onThreadStarted?.(thread); if (request.kind === "discovery") { @@ -102,7 +105,7 @@ export async function publishCoverageFixture(root, completeness, { resume = fals if (index === 0 && !request.resumeThreadId) return { threadId: thread, finalResponse: "Continue the unfinished audit." }; await writeDiscovery(request.artifactContext.root, index); } else { - await writeFile(path.join(request.artifactContext.root, "result.json"), JSON.stringify({ scanId: run.scanId, findings: [] })); + await recordCodexSecurityDeepReduction({ ...request.artifactContext, repoRoot: targetPath, scanId: run.scanId }, { scanId: run.scanId, findings: [] }); } return { threadId: thread, finalResponse: "Audit finished." }; }, @@ -117,13 +120,13 @@ export async function publishCoverageFixture(root, completeness, { resume = fals const terminal = await coordinator.wait(undefined, 30_000); assert.equal(terminal?.status, "succeeded", terminal?.error); assert.equal(terminal.noNewStreak, statuses.length, "source coverage must not change stopping policy"); - assert.equal(discoveryCalls, resume ? 0 : statuses.length + 1); + assert.equal(discoveryCalls, resume ? (continueAfterResume ? 1 : 0) : statuses.length + 1); await runWorkbench(["complete-scan", "--scan-id", run.scanId]); for (const [file, bytes] of rawSources) assert.equal(await readFile(file, "utf8"), bytes); return { scanDir: run.scanDir, threadId, terminal }; } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const result = await publishCoverageFixture(process.argv[2], process.argv[3], { resume: process.argv[4] === "true" }); + const result = await publishCoverageFixture(process.argv[2], process.argv[3], { resume: process.argv[4] === "true", continueAfterResume: process.argv[5] === "true" }); process.stdout.write(JSON.stringify(result)); } diff --git a/sdk/typescript/tests-ts/deep-scan-coverage.test.ts b/sdk/typescript/tests-ts/deep-scan-coverage.test.ts index 64fc85603f..4d4996bad1 100644 --- a/sdk/typescript/tests-ts/deep-scan-coverage.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-coverage.test.ts @@ -14,13 +14,14 @@ const fixtureUrl = new URL( ); test.each([ - ["partial", false], - ["unknown", false], - ["complete", false], - ["partial", true], + ["partial", false, false], + ["unknown", false, false], + ["complete", false, false], + ["partial", true, false], + ["partial", true, true], ] as const)( - "publishes %s source coverage through CLI results (resume: %p)", - async (completeness, resume) => { + "publishes %s source coverage through CLI results (resume: %p, continued: %p)", + async (completeness, resume, continueAfterResume) => { const root = await mkdtemp(join(tmpdir(), "deep-coverage-publication-")); try { await mkdir(join(root, "fixture"), { mode: 0o700 }); @@ -32,6 +33,7 @@ test.each([ join(root, "fixture"), completeness, String(resume), + String(continueAfterResume), ], { stdout: "pipe", stderr: "pipe" }, ); From b1fab6b3f3d8975debaeefaf6089c8658f587e08 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:10:48 +0000 Subject: [PATCH 030/251] Gate persisted source coverage by workflow compatibility --- .../mcp-app/src/artifact-deep-reducer.ts | 6 ++- .../codex-security/mcp-app/src/artifact-io.ts | 1 + .../src/deep-scan/artifact-validation.ts | 16 +++++++- .../mcp-app/src/deep-scan/worker-runner.ts | 5 ++- .../tests/deep_scan_coverage_fixture.mjs | 13 ++++++- .../tests/deep_scan_publication_cases.mjs | 1 + .../tests/test_artifact_deep_reducer.mjs | 2 + .../mcp-app/tests/test_deep_scan_executor.mjs | 38 +++++++++++++++++++ 8 files changed, 76 insertions(+), 6 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts index 7e1604e8ea..06d0efa7f2 100644 --- a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts +++ b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts @@ -20,6 +20,7 @@ import { type DeepScanArtifacts } from "./deep-scan/artifacts.js"; import { + deepReductionForPersistence, parseDeepReduction, projectDiscoveryCoverage, reconcileDeepReduction, @@ -134,8 +135,9 @@ export async function recordCodexSecurityDeepReduction( } reduction = reconcileDeepReduction(reduction, inputs.discoveries, inputs.previous); - await saveScanDraftCheckpoint(context, reduction); - await writeJsonAtomic(bound.resultPath, reduction); + const persisted = deepReductionForPersistence(reduction, bound.state.persistSourceCoverage); + await saveScanDraftCheckpoint(context, persisted); + await writeJsonAtomic(bound.resultPath, persisted); return { findingCount: reduction.findings.length, consumedWorkerIds: bound.state.claimedWorkers.map((worker) => worker.id) diff --git a/plugins/codex-security/mcp-app/src/artifact-io.ts b/plugins/codex-security/mcp-app/src/artifact-io.ts index a296e40465..0a4299467f 100644 --- a/plugins/codex-security/mcp-app/src/artifact-io.ts +++ b/plugins/codex-security/mcp-app/src/artifact-io.ts @@ -12,6 +12,7 @@ export interface DeepReducerContext { scanRoot: string; claimedWorkers: DeepReducerWorkerContext[]; previousReducerResultPath?: string; + persistSourceCoverage?: boolean; } /** diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index fe1687fbd4..4daaafc998 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -30,6 +30,16 @@ export function deepReductionToScanDraft(result: DeepReductionInput): ScanDraftI return { ...draft, coverage: sourceCoverage ?? unknownSourceCoverage() }; } +/** Older workflow readers reject the host field; retain their persisted shape. */ +export function deepReductionForPersistence( + result: DeepReductionInput, + persistSourceCoverage = false, +): DeepReductionInput { + if (persistSourceCoverage) return result; + const { sourceCoverage: _coverage, ...legacy } = result; + return legacy; +} + /** * Check reducer findings with the Standard scan validator. * It requires coverage, so add an empty value and remove it after validation. @@ -82,6 +92,7 @@ export async function validateReducerArtifacts(input: { reducerId: string; previousReducerResultPath?: string; sources?: DeepReductionSources; + persistSourceCoverage?: boolean; }, expectedScanId?: string): Promise { const { artifacts, @@ -113,8 +124,9 @@ export async function validateReducerArtifacts(input: { if (input.sources) { result = reconcileDeepReduction(result, input.sources.discoveries, input.sources.previous); - await saveScanDraftCheckpoint({ root: artifactDir, repoRoot: artifacts.scanDir, layout: "reducer" }, result); - await writeJsonAtomic(resultPath, result); + const persisted = deepReductionForPersistence(result, input.persistSourceCoverage); + await saveScanDraftCheckpoint({ root: artifactDir, repoRoot: artifacts.scanDir, layout: "reducer" }, persisted); + await writeJsonAtomic(resultPath, persisted); } else { validateRetainedFindings(result, [], previous); } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 0a60a174bd..cafef67449 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -294,6 +294,7 @@ export class DeepScanWorkerRunner { count: consumed.length }); + const persistSourceCoverage = "workflowVersion" in run && run.workflowVersion === "deep-security-scan/v2"; const artifactContext = { root: artifactDir, repoRoot: run.targetPath, @@ -301,6 +302,7 @@ export class DeepScanWorkerRunner { layout: "reducer" as const, deepReducer: { scanRoot: artifacts.scanDir, + persistSourceCoverage, claimedWorkers: consumed.map((worker) => ({ id: worker.id, resultPath: worker.resultPath, attempt: worker.attempt })), previousReducerResultPath } @@ -327,7 +329,8 @@ export class DeepScanWorkerRunner { resultPath, reducerId, previousReducerResultPath, - sources + sources, + persistSourceCoverage }, run.scanId); }, beforeRetry: async (attempt) => { diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index dca951ff9d..e5bab7e473 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; @@ -121,6 +121,17 @@ export async function publishCoverageFixture(root, completeness, { resume = fals assert.equal(terminal?.status, "succeeded", terminal?.error); assert.equal(terminal.noNewStreak, statuses.length, "source coverage must not change stopping policy"); assert.equal(discoveryCalls, resume ? (continueAfterResume ? 1 : 0) : statuses.length + 1); + const accepted = await store.get(run.scanId, threadId); + for (const worker of accepted.persistedWorkers.filter((worker) => worker.kind === "dedup")) { + const result = JSON.parse(await readFile(worker.resultManifestPath, "utf8")); + assert.equal(Object.hasOwn(result, "sourceCoverage"), false, "v1 reducers remain readable by earlier binaries"); + if (!rawSources.has(worker.resultManifestPath)) { + for (const name of await readdir(path.join(worker.artifactDir, "checkpoints"))) { + const checkpoint = JSON.parse(await readFile(path.join(worker.artifactDir, "checkpoints", name), "utf8")); + assert.equal(Object.hasOwn(checkpoint, "sourceCoverage"), false, "v1 checkpoints remain readable by earlier binaries"); + } + } + } await runWorkbench(["complete-scan", "--scan-id", run.scanId]); for (const [file, bytes] of rawSources) assert.equal(await readFile(file, "utf8"), bytes); return { scanDir: run.scanDir, threadId, terminal }; diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs index e0a12fd2eb..ede7a814a9 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs @@ -108,6 +108,7 @@ export async function testDeepScanPublication({ async function testSaturationIgnoresDiscoveryCancellationWriteFailure() { const fixture = await fixtureRun({ workers: 2, subagents: 0, stopAfterNoNew: 2, maxDiscoveryRuns: 6 }); + fixture.run.workflowVersion = "deep-security-scan/v2"; const store = new FakeStore(fixture.run); const executor = new FakeExecutor({ blockDedup: true, blockDiscoveryAfterCalls: 2 }); const updateWorker = store.updateWorker.bind(store); diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs index d2e424c327..c9cb7533d4 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.mjs @@ -100,6 +100,7 @@ try { layout: "reducer", deepReducer: { scanRoot, + persistSourceCoverage: true, claimedWorkers: [first, second] } }; @@ -249,6 +250,7 @@ try { layout: "reducer", deepReducer: { scanRoot, + persistSourceCoverage: true, claimedWorkers: [third], previousReducerResultPath: path.join(outputRoot, "result.json") } diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 946e362b94..896a136a8c 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -86,6 +86,7 @@ try { await testSdkInvocationAndThreadCapture(); await testBedrockCredentialsReachWorker(); await testArtifactServerUsesExtendedStartupTimeout(); + await testReducerCoveragePersistenceBinding(); await testZeroSubagentsPreservesHostRestrictions(); await testSdkResumesExistingThread(); await testRetryNotificationDoesNotInterruptTurn(); @@ -1099,6 +1100,43 @@ async function testArtifactServerUsesExtendedStartupTimeout() { } } +async function testReducerCoveragePersistenceBinding() { + const fixture = await fakeCodexFixture(); + const previousPath = process.env.CODEX_CLI_PATH; + process.env.CODEX_CLI_PATH = fixture.executablePath; + try { + const promptPath = path.join(fixture.root, "prompt.md"); + const workingDirectory = path.join(fixture.root, "artifacts"); + await mkdir(workingDirectory); + await writeFile(promptPath, "fixture reducer prompt\n"); + for (const resume of [false, true]) { + for (const persistSourceCoverage of [false, true]) { + const deepReducer = { + scanRoot: path.join(fixture.root, "scans"), + claimedWorkers: [{ id: "worker-1", resultPath: path.join(fixture.root, "worker", "result.json"), attempt: 2 }], + persistSourceCoverage, + }; + await new CodexSdkWorkerExecutor({ + parentSandbox: trustedParentSandbox, + artifactContext: { pluginRoot: fixture.root, scanRoot: deepReducer.scanRoot, repoRoot: fixture.root, scanId: "fixture-scan-id" }, + }).run({ + kind: "dedup", promptPath, workingDirectory, subagents: 0, + signal: new AbortController().signal, + ...(resume ? { resumeThreadId: "fixture-existing-thread", continuationPrompt: "continue the reducer\n" } : {}), + artifactContext: { root: workingDirectory, layout: "reducer", deepReducer }, + }); + const invocation = JSON.parse(await readFile(fixture.markerPath, "utf8")); + const prefix = "mcp_servers.cs_artifacts.env.CODEX_SECURITY_REDUCER_CONTEXT_JSON="; + const encoded = invocation.argv.find((arg) => arg.startsWith(prefix)); + assert.ok(encoded, "the launched reducer receives its host-bound artifact context"); + assert.deepEqual(JSON.parse(JSON.parse(encoded.slice(prefix.length))), deepReducer); + } + } + } finally { + restoreEnv("CODEX_CLI_PATH", previousPath); + } +} + async function testSdkResumesExistingThread() { const fixture = await fakeCodexFixture(); const previousPath = process.env.CODEX_CLI_PATH; From b8bd35f1dd01588034f7bd188a7ec70e36dc61ad Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:22:55 +0000 Subject: [PATCH 031/251] test: cover persisted worker settings and packaged config helper --- .../mcp-app/tests/test_deep_scan_executor.mjs | 31 ++++++++++++++----- sdk/typescript/scripts/check-package.mjs | 1 + 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 896a136a8c..4ca19fc54e 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -15,7 +15,7 @@ const bundle = await build({ }, stdin: { // Test the environment snapshot without adding a production export. - contents: `${await readFile(executorSource, "utf8")}\nexport { snapshotWorkerEnvironment };`, + contents: `${await readFile(executorSource, "utf8")}\nexport { snapshotWorkerEnvironment };\nexport { captureDeepScanExecutionSettings, loadOrCaptureDeepScanExecutionSettings, restoredDeepScanWorkerSettings } from "./recovery-settings.js";`, loader: "ts", resolveDir: path.dirname(fileURLToPath(executorSource)), sourcefile: fileURLToPath(executorSource) @@ -24,7 +24,7 @@ const bundle = await build({ platform: "node", write: false }); -const { CodexSdkWorkerExecutor, resolveCodexPath, snapshotWorkerEnvironment } = await import( +const { CodexSdkWorkerExecutor, resolveCodexPath, snapshotWorkerEnvironment, captureDeepScanExecutionSettings, loadOrCaptureDeepScanExecutionSettings, restoredDeepScanWorkerSettings } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); const errorsBundle = await build({ @@ -782,7 +782,16 @@ async function testIsolatedReconstructedWorkers() { reasoningEffort: "ultra", parentSandbox: trustedParentSandboxWithDenials }; - scans.push({ name, fixture, config, configPath, promptPath, settings, executor: new CodexSdkWorkerExecutor(settings) }); + const saved = await loadOrCaptureDeepScanExecutionSettings(fixture.root, () => + captureDeepScanExecutionSettings(settings, settings.parentSandbox, { ...codexOptions.env, CODEX_CLI_PATH: executable })); + const snapshotPath = path.join(fixture.root, "artifacts", "deep_discovery", "execution-settings.json"); + const snapshot = await readFile(snapshotPath, "utf8"); + assert.equal(snapshot.includes("synthetic-"), false); + const runtimeEnvironment = { ...codexOptions.env }; + const restored = restoredDeepScanWorkerSettings(saved, settings.parentSandbox, () => runtimeEnvironment); + restored.codexOptions.baseUrl = codexOptions.baseUrl; + scans.push({ name, fixture, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, + executor: new CodexSdkWorkerExecutor(restored) }); } childProcess.spawn = (command, args, options) => { const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); @@ -796,12 +805,18 @@ async function testIsolatedReconstructedWorkers() { // The caller restores recorded selections. Its old config file need // not exist; current credentials still come from the selected home/env. await rm(scan.configPath); - scan.executor = new CodexSdkWorkerExecutor({ - ...scan.settings, - codexOptions: { ...scan.settings.codexOptions, config: scan.config } - }); + const recorded = await loadOrCaptureDeepScanExecutionSettings(scan.fixture.root, () => + assert.fail("reconstruction must not recapture current settings")); + const restored = restoredDeepScanWorkerSettings(recorded, scan.settings.parentSandbox, () => scan.runtimeEnvironment); + restored.codexOptions.baseUrl = scan.settings.codexOptions.baseUrl; + scan.executor = new CodexSdkWorkerExecutor(restored); + assert.equal(await readFile(scan.snapshotPath, "utf8"), scan.snapshot); } } + for (const scan of scans) { + scan.runtimeEnvironment.CODEX_API_KEY = `synthetic-${scan.name}-${phase}`; + scan.runtimeEnvironment.CODEX_HOME = path.join(scan.fixture.root, "observer-home"); + } for (const kind of ["discovery", "dedup"]) { await Promise.all(scans.map(async (scan) => { const resumeThreadId = phase === "fresh" ? undefined : `fixture-${scan.name}-resumed`; @@ -819,7 +834,7 @@ async function testIsolatedReconstructedWorkers() { assert.equal(preflight.codexHome, child.codexHome); assert.equal(child.scanValue, scan.name); assert.equal(child.configPath, scan.configPath); - assert.deepEqual(child.openaiAuthentication, { CODEX_API_KEY: `synthetic-${scan.name}-credential` }); + assert.deepEqual(child.openaiAuthentication, { CODEX_API_KEY: `synthetic-${scan.name}-${phase}` }); assertFlagPair(child.argv, "--model", scan.settings.model); for (const key of ["model_provider", "model_reasoning_summary", "service_tier"]) { const override = `${key}=${JSON.stringify(scan.config[key])}`; diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 8fd657e584..f58c832479 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -176,6 +176,7 @@ const distFiles = new Set( "cloud-publish", "codex-prompt", "codex-session", + "preflight-config", "component-plan", "component-scan", "config", From 6cb32c69287ad68274054f8b806167a89e433df1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:13:34 +0000 Subject: [PATCH 032/251] refactor: remove the pass-through Codex session wrapper --- .../mcp-app/src/deep-scan/executor.ts | 4 +- sdk/typescript/src/api.ts | 48 +++++++++---------- sdk/typescript/src/codex-session.ts | 22 --------- 3 files changed, 24 insertions(+), 50 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index 1f3147c172..51db8dedde 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -2,7 +2,7 @@ import { accessSync, constants as fsConstants, existsSync, promises as fs, readd import { createRequire } from "node:module"; import { delimiter, dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { - CodexSession, + createCodexClient, readCodexSessionTurn } from "../../../../../sdk/typescript/src/codex-session.js"; import type { CodexOptions } from "@openai/codex-sdk"; @@ -104,7 +104,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { signal: request.signal }); const prompt = await fs.readFile(request.promptPath, "utf8"); - const codex = new CodexSession({ + const codex = createCodexClient({ ...resolved, codexPathOverride: executablePathForSpawn(codexPath), env: childEnv, diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index f2a39f7bc3..9a46638e00 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -29,7 +29,6 @@ import { import { type CodexOptions, type ThreadOptions } from "@openai/codex-sdk"; import { z } from "incur"; import { - CodexSession, createCodexClient, readCodexSessionTurn, type CodexSessionClient as CodexClientLike, @@ -2673,33 +2672,30 @@ export class CodexSecurity { sdkEnvironment, ); } - const codex = new CodexSession( - { - ...(codexPathOverride === undefined - ? {} - : { codexPathOverride: executablePathForSpawn(codexPathOverride) }), - ...(externalProvider !== null || apiKey === null ? {} : { apiKey }), - ...(commandAuth || configOverrides.length > 0 - ? { - configOverrides: [ - ...(commandAuth - ? modelProviderConfigOverride(sessionConfig) - : []), - ...configOverrides, - ], - } - : {}), - env: sdkEnvironment, - config: { - ...(sdkCodexConfig as NonNullable), - responses_api_metadata: { - ...configuredResponsesMetadata, - codex_security_surface: this.#surface, - }, + const codex = this.#dependencies.createCodex({ + ...(codexPathOverride === undefined + ? {} + : { codexPathOverride: executablePathForSpawn(codexPathOverride) }), + ...(externalProvider !== null || apiKey === null ? {} : { apiKey }), + ...(commandAuth || configOverrides.length > 0 + ? { + configOverrides: [ + ...(commandAuth + ? modelProviderConfigOverride(sessionConfig) + : []), + ...configOverrides, + ], + } + : {}), + env: sdkEnvironment, + config: { + ...(sdkCodexConfig as NonNullable), + responses_api_metadata: { + ...configuredResponsesMetadata, + codex_security_surface: this.#surface, }, }, - this.#dependencies.createCodex, - ); + }); return { codex, environment }; } diff --git a/sdk/typescript/src/codex-session.ts b/sdk/typescript/src/codex-session.ts index 0112681ed0..630df36254 100644 --- a/sdk/typescript/src/codex-session.ts +++ b/sdk/typescript/src/codex-session.ts @@ -26,28 +26,6 @@ export interface CodexSessionClient { export const createCodexClient = (options: CodexOptions): CodexSessionClient => new Codex(options); -/** Resource resolution and role-specific configuration belong to the caller. */ -export class CodexSession { - private readonly client: CodexSessionClient; - - constructor( - options: CodexOptions, - createClient: ( - options: CodexOptions, - ) => CodexSessionClient = createCodexClient, - ) { - this.client = createClient(options); - } - - startThread(options: ThreadOptions): CodexSessionThread { - return this.client.startThread(options); - } - - get resumeThread(): CodexSessionClient["resumeThread"] { - return this.client.resumeThread?.bind(this.client); - } -} - /** Reduce a single stream; callers retain error, retry and acceptance policy. */ export async function readCodexSessionTurn(options: { thread: CodexSessionThread; From 7501517578a5ee559c6819a7f23797e2dd7a8db8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:14:26 +0000 Subject: [PATCH 033/251] Verify versioned reducer snapshots and concurrent bindings --- .../test_deep_scan_artifact_validation.mjs | 11 +++++++--- .../mcp-app/tests/test_deep_scan_executor.mjs | 20 +++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.mjs index 2b0bd4391c..74ad7e7554 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.mjs @@ -217,8 +217,9 @@ async function testReducerValidation(root) { discoveries: [{ workerId: first.id, result: draft([firstFinding, secondFinding]) }], previous: null, }; - const validateSnapshot = () => validateReducerArtifacts({ + const validateSnapshot = (persistSourceCoverage = false) => validateReducerArtifacts({ artifacts, artifactDir, resultPath, reducerId: "dedup-0001", sources, + persistSourceCoverage, }, scanId); await assert.rejects(validateSnapshot(), /unaccounted source findings/); await writeResult(resultPath, draft([firstFinding, secondFinding])); @@ -226,11 +227,15 @@ async function testReducerValidation(root) { const validatedSnapshot = await validateSnapshot(); assert.equal(validatedSnapshot.newFindings, 2); const admitted = JSON.parse(await readFile(resultPath, "utf8")); + const { sourceCoverage, ...legacySnapshot } = validatedSnapshot.result; + assert.equal(sourceCoverage.completeness, "unknown"); assert.deepEqual( - validatedSnapshot.result, + legacySnapshot, admitted, - "validation returns the same reconciled result that was accepted on disk", + "v1 preserves host coverage in memory while retaining the legacy persisted shape", ); + const versionedSnapshot = await validateSnapshot(true); + assert.deepEqual(versionedSnapshot.result, JSON.parse(await readFile(resultPath, "utf8")), "v2 persists the full host projection"); assert.equal(Object.hasOwn(admitted, "coverage"), false); assert.deepEqual(admitted.findings[1].provenance.sourceFindingIds, ["worker-001:1"]); sources.previous = structuredClone(admitted); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 4ca19fc54e..7aae76435b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -1118,20 +1118,24 @@ async function testArtifactServerUsesExtendedStartupTimeout() { async function testReducerCoveragePersistenceBinding() { const fixture = await fakeCodexFixture(); const previousPath = process.env.CODEX_CLI_PATH; + const previousMarker = process.env.FAKE_CODEX_MARKER; process.env.CODEX_CLI_PATH = fixture.executablePath; try { const promptPath = path.join(fixture.root, "prompt.md"); const workingDirectory = path.join(fixture.root, "artifacts"); await mkdir(workingDirectory); await writeFile(promptPath, "fixture reducer prompt\n"); + const launches = []; for (const resume of [false, true]) { for (const persistSourceCoverage of [false, true]) { + const markerPath = path.join(fixture.root, `coverage-${resume}-${persistSourceCoverage}.json`); + process.env.FAKE_CODEX_MARKER = markerPath; const deepReducer = { scanRoot: path.join(fixture.root, "scans"), claimedWorkers: [{ id: "worker-1", resultPath: path.join(fixture.root, "worker", "result.json"), attempt: 2 }], persistSourceCoverage, }; - await new CodexSdkWorkerExecutor({ + const launch = new CodexSdkWorkerExecutor({ parentSandbox: trustedParentSandbox, artifactContext: { pluginRoot: fixture.root, scanRoot: deepReducer.scanRoot, repoRoot: fixture.root, scanId: "fixture-scan-id" }, }).run({ @@ -1140,15 +1144,19 @@ async function testReducerCoveragePersistenceBinding() { ...(resume ? { resumeThreadId: "fixture-existing-thread", continuationPrompt: "continue the reducer\n" } : {}), artifactContext: { root: workingDirectory, layout: "reducer", deepReducer }, }); - const invocation = JSON.parse(await readFile(fixture.markerPath, "utf8")); - const prefix = "mcp_servers.cs_artifacts.env.CODEX_SECURITY_REDUCER_CONTEXT_JSON="; - const encoded = invocation.argv.find((arg) => arg.startsWith(prefix)); - assert.ok(encoded, "the launched reducer receives its host-bound artifact context"); - assert.deepEqual(JSON.parse(JSON.parse(encoded.slice(prefix.length))), deepReducer); + launches.push(launch.then(async () => { + const invocation = JSON.parse(await readFile(markerPath, "utf8")); + const prefix = "mcp_servers.cs_artifacts.env.CODEX_SECURITY_REDUCER_CONTEXT_JSON="; + const encoded = invocation.argv.find((arg) => arg.startsWith(prefix)); + assert.ok(encoded, "the launched reducer receives its host-bound artifact context"); + assert.deepEqual(JSON.parse(JSON.parse(encoded.slice(prefix.length))), deepReducer); + })); } } + await Promise.all(launches); } finally { restoreEnv("CODEX_CLI_PATH", previousPath); + restoreEnv("FAKE_CODEX_MARKER", previousMarker); } } From d15f27bb443029a25037974011ee3ed7ad658f0c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:16:01 +0000 Subject: [PATCH 034/251] test: verify refreshed worker auth and resumed permission checks --- .../mcp-app/tests/test_deep_scan_executor.mjs | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 7aae76435b..9e9958a147 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -815,6 +815,7 @@ async function testIsolatedReconstructedWorkers() { } for (const scan of scans) { scan.runtimeEnvironment.CODEX_API_KEY = `synthetic-${scan.name}-${phase}`; + scan.runtimeEnvironment.FAKE_CODEX_SCAN_VALUE = `${scan.name}-${phase}`; scan.runtimeEnvironment.CODEX_HOME = path.join(scan.fixture.root, "observer-home"); } for (const kind of ["discovery", "dedup"]) { @@ -832,7 +833,7 @@ async function testIsolatedReconstructedWorkers() { assert.equal(child.executable, scan.settings.codexOptions.codexPathOverride); assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); assert.equal(preflight.codexHome, child.codexHome); - assert.equal(child.scanValue, scan.name); + assert.equal(child.scanValue, `${scan.name}-${phase}`); assert.equal(child.configPath, scan.configPath); assert.deepEqual(child.openaiAuthentication, { CODEX_API_KEY: `synthetic-${scan.name}-${phase}` }); assertFlagPair(child.argv, "--model", scan.settings.model); @@ -1638,22 +1639,27 @@ async function testDisallowedWorkerProfileFailsBeforeWorkerLaunch() { await mkdir(workingDirectory); await writeFile(promptPath, "fixture blocked worker prompt\n"); - await assert.rejects( - new CodexSdkWorkerExecutor({ - parentSandbox: trustedParentSandbox - }).run({ - kind: "discovery", - promptPath, - workingDirectory, - subagents: 0, - signal: new AbortController().signal - }), - (error) => error?.name === "DeepScanNonRetryableError" - && error.message.includes("codex_security_deep_scan_worker") - && error.message.includes("[allowed_permission_profiles]") - && error.message.includes("codex_security_deep_scan_worker = true") - && error.message.includes("Deep Scan did not run.") - ); + for (const kind of ["discovery", "dedup"]) { + for (const resumeThreadId of [undefined, "fixture-resumed-worker"]) { + await assert.rejects( + new CodexSdkWorkerExecutor({ + parentSandbox: trustedParentSandbox + }).run({ + kind, + resumeThreadId, + promptPath, + workingDirectory, + subagents: 0, + signal: new AbortController().signal + }), + (error) => error?.name === "DeepScanNonRetryableError" + && error.message.includes("codex_security_deep_scan_worker") + && error.message.includes("[allowed_permission_profiles]") + && error.message.includes("codex_security_deep_scan_worker = true") + && error.message.includes("Deep Scan did not run.") + ); + } + } await assert.rejects( readFile(fixture.markerPath, "utf8"), (error) => error?.code === "ENOENT" From c8b3bccd5a5135e3925f0cb2cedd0a2f257024e2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:06:23 +0000 Subject: [PATCH 035/251] refactor: share accepted audit attempts across managed scans --- .../src/deep-scan/artifact-validation.ts | 11 +- .../mcp-app/src/deep-scan/worker-runner.ts | 55 +++-- .../tests/test_audit_acceptance_contract.mjs | 33 ++- sdk/typescript/src/accepted-audit.ts | 64 +++++ sdk/typescript/src/api.ts | 223 +++++++++--------- 5 files changed, 258 insertions(+), 128 deletions(-) create mode 100644 sdk/typescript/src/accepted-audit.ts diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index 4daaafc998..099add31bd 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -72,6 +72,16 @@ export async function validateDiscoveryArtifacts( artifacts: DeepScanArtifacts, resultPath: string, expectedScanId: string +): Promise { + const result = await readDiscoveryAuditDraft(artifacts, resultPath, expectedScanId); + if (result.complete === false) throw new Error("Standard scan worker wrote only a checkpoint; its audit is not complete."); + return result; +} + +export async function readDiscoveryAuditDraft( + artifacts: DeepScanArtifacts, + resultPath: string, + expectedScanId: string, ): Promise { await requireRegularFile(resultPath, artifacts.workersRoot); const result = parseStoredScanDraft( @@ -80,7 +90,6 @@ export async function validateDiscoveryArtifacts( expectedScanId, parsePersistedScanDraft ); - if (result.complete === false) throw new Error("Standard scan worker wrote only a checkpoint; its audit is not complete."); return result; } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index cafef67449..36a39effdb 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -1,8 +1,10 @@ +import type { ScanDraftInput } from "../artifact-scan-draft.js"; +import { auditEvidence, runAcceptedAudit } from "../../../../../sdk/typescript/src/accepted-audit.js"; import { promises as fs } from "node:fs"; import { dirname, join } from "node:path"; import { readDeepReductionSources } from "../artifact-deep-reducer.js"; import { - validateDiscoveryArtifacts, + readDiscoveryAuditDraft, validateReducerArtifacts } from "./artifact-validation.js"; import type { DeepReductionInput, ReducerArtifactValidation } from "./artifact-validation.js"; @@ -163,8 +165,9 @@ export class DeepScanWorkerRunner { artifactContext: { root: artifactDir, layout: "worker" }, subagents: run.config.subagents, validate: async () => { - await validateDiscoveryArtifacts(artifacts, files.resultPath, run.scanId); - discoveryValidated = true; + const draft = await readDiscoveryAuditDraft(artifacts, files.resultPath, run.scanId); + discoveryValidated = draft.complete !== false; + return draft; }, beforeRetry: async (attempt) => { await archiveDirectory( @@ -424,7 +427,7 @@ export class DeepScanWorkerRunner { artifactDir: string; artifactContext?: CodexWorkerArtifactContext; subagents: number; - validate: () => Promise; + validate: () => Promise; beforeRetry: (attempt: number) => Promise; }): Promise { const { run, signal } = this.options; @@ -459,7 +462,7 @@ export class DeepScanWorkerRunner { attempt }); try { - const result = await this.options.executor.run({ + const execute = () => this.options.executor.run({ kind: input.kind, promptPath: executionPromptPath, // Discovery workers write only to their isolated directory. Setup and @@ -488,19 +491,37 @@ export class DeepScanWorkerRunner { }); } }); - if (signal.aborted) { - return await this.cancelAttempt(input, attempt, activeThreadId); - } - validationStarted = true; - try { - await input.validate(); - } catch (validationError) { - throw withWorkerDiagnostics(validationError, result.diagnostics); - } - validationCompleted = true; - if (signal.aborted) { - return await this.cancelAttempt(input, attempt, activeThreadId); + const accept = async (result: Awaited>) => { + validationStarted = true; + let accepted: ScanDraftInput | void; + try { + accepted = await input.validate(); + } catch (validationError) { + throw withWorkerDiagnostics(validationError, result.diagnostics); + } + validationCompleted = accepted?.complete !== false; + return accepted === undefined ? {} : auditEvidence(accepted); + }; + // Reducers keep their aggregate contract; discovery uses the shared audit. + const audit = input.kind === "discovery" + ? await runAcceptedAudit({ signal, execute, accept }) + : undefined; + let result: Awaited>; + if (audit) { + if (audit.status === "checkpoint") { + throw withWorkerDiagnostics( + new Error("Standard scan worker wrote only a checkpoint; its audit is not complete."), + audit.execution.diagnostics, + ); + } + if (audit.status === "accepted") result = audit.execution; + else throw audit.error; + } else { + result = await execute(); + if (signal.aborted) return await this.cancelAttempt(input, attempt, activeThreadId); + await accept(result); } + if (signal.aborted) return await this.cancelAttempt(input, attempt, activeThreadId); this.options.log({ event: "worker_succeeded", scanId: run.scanId, diff --git a/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs index 01ca056993..138fd0176f 100644 --- a/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs +++ b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs @@ -9,14 +9,17 @@ const bundle = await build({ stdin: { contents: `export * from "./src/artifact-scan-draft.ts"; export * from "./src/deep-scan/artifact-validation.ts"; - export * from "./src/deep-scan/artifacts.ts";`, + export * from "./src/deep-scan/artifacts.ts"; + export * from "../../../sdk/typescript/src/accepted-audit.ts";`, resolveDir: path.resolve(import.meta.dirname, ".."), }, bundle: true, format: "esm", platform: "node", write: false, + footer: { js: "//# sourceURL=audit-acceptance-contract.js" }, }); const { createDeepScanArtifacts, recordCodexSecurityScanDraft, recordCodexSecurityWorkerScanDraft, validateDiscoveryArtifacts, + readDiscoveryAuditDraft, auditEvidence, runAcceptedAudit, } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`); const scanId = "811aef98-3709-4c2d-8b7a-742977521865"; @@ -67,6 +70,16 @@ for (const completeness of ["complete", "partial", "unknown"]) { await recordCodexSecurityWorkerScanDraft(worker, checkpoint); await assert.rejects(validateDiscoveryArtifacts(artifacts, path.join(workerRoot, "result.json"), scanId), /checkpoint/); assert.equal(JSON.parse(await readFile(path.join(scanDir, "scan-manifest.json"))).scan.complete, false); + const controller = new AbortController(); + const execute = async () => ({ threadId: "audit-conversation", usage: null }); + const accept = async () => auditEvidence(await readDiscoveryAuditDraft( + artifacts, path.join(workerRoot, "result.json"), scanId, + )); + const unfinished = await runAcceptedAudit({ signal: controller.signal, execute, accept }); + assert.equal(unfinished.status, "checkpoint"); + assert.equal(unfinished.checkpoint.complete, false); + assert.equal(unfinished.accepted, undefined); + assert.equal(unfinished.execution.usage, null); const standardWrite = await recordCodexSecurityScanDraft(standard, semantic); const deepWrite = await recordCodexSecurityWorkerScanDraft(worker, semantic); assert.equal(standardWrite.status, "draft_written"); @@ -85,6 +98,24 @@ for (const completeness of ["complete", "partial", "unknown"]) { assert.deepEqual(coverage[field], accepted.coverage[field]); } assert.equal(accepted.scanId, scanId); + const audit = await runAcceptedAudit({ signal: controller.signal, execute, accept }); + assert.equal(audit.status, "accepted"); + assert.deepEqual(audit.accepted, accepted); + assert.deepEqual(audit.checkpoint, accepted); + const failure = new Error("Synthetic execution failure"); + const failed = await runAcceptedAudit({ signal: controller.signal, + execute: async () => { throw failure; }, + accept: async () => { assert.fail("An execution failure cannot accept old output"); }, + }); + assert.equal(failed.status, "failed"); + assert.equal(failed.stage, "execution"); + assert.equal(failed.error, failure); + assert.equal(failed.accepted, undefined); + const canceled = await runAcceptedAudit({ signal: controller.signal, execute, + accept: async () => { const evidence = await accept(); controller.abort("user canceled"); return evidence; }, + }); + assert.equal(canceled.status, "canceled"); + assert.deepEqual(canceled.checkpoint, accepted); assert.equal(manifest.scan.sealedAt, undefined); assert.equal(manifest.scan.artifacts, undefined); assert.equal((await readdir(workerRoot)).includes("scan-manifest.json"), false); diff --git a/sdk/typescript/src/accepted-audit.ts b/sdk/typescript/src/accepted-audit.ts new file mode 100644 index 0000000000..6d540361fb --- /dev/null +++ b/sdk/typescript/src/accepted-audit.ts @@ -0,0 +1,64 @@ +/** Result acceptance is independent of process completion and source coverage. */ +export interface AuditEvidence { + checkpoint?: Result; + accepted?: Result; +} + +export type AuditOutcome = AuditEvidence & + ( + | { status: "accepted"; execution: Execution; accepted: Result } + | { status: "checkpoint"; execution: Execution } + | { + status: "failed" | "canceled"; + execution?: Execution; + stage: "execution" | "acceptance"; + error: unknown; + } + ); + +/** + * Run one audit attempt under its parent scan. Callers bind the execution and + * artifact adapters; retries, registration, matching and sealing stay outside. + */ +export async function runAcceptedAudit(input: { + signal: AbortSignal; + execute: () => Promise; + accept: (execution: Execution) => Promise>; +}): Promise> { + let execution: Execution | undefined; + let evidence: AuditEvidence = {}; + let stage: "execution" | "acceptance" = "execution"; + try { + input.signal.throwIfAborted(); + execution = await input.execute(); + input.signal.throwIfAborted(); + stage = "acceptance"; + evidence = await input.accept(execution); + input.signal.throwIfAborted(); + return evidence.accepted === undefined + ? { ...evidence, execution, status: "checkpoint" } + : { + ...evidence, + execution, + accepted: evidence.accepted, + status: "accepted", + }; + } catch (error) { + return { + ...evidence, + execution, + stage, + error, + status: input.signal.aborted ? "canceled" : "failed", + }; + } +} + +/** A completed audit may still report partial or unknown source coverage. */ +export function auditEvidence( + checkpoint: Result, +): AuditEvidence { + return checkpoint.complete === false + ? { checkpoint } + : { checkpoint, accepted: checkpoint }; +} diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 9a46638e00..5d0f2e8435 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -3,6 +3,7 @@ import { scanPreflightCodexConfig } from "./preflight-config.js"; export { scanPreflightCodexConfig } from "./preflight-config.js"; +import { runAcceptedAudit } from "./accepted-audit.js"; import { statSync } from "node:fs"; import { chmod, @@ -3632,132 +3633,136 @@ export async function runScanEvents( let scanStarted = false; let tacStatusReported = false; try { - const turn = await readCodexTurn({ - thread: options.thread, - events: options.events, - onEvent: async (event) => { - if (!tacStatusReported) { - const tacStatus = trustedAccessStatusFromEvent(event); - if (tacStatus !== null) { - tacStatusReported = true; - notifyObserver( - "onTrustedAccessStatus", - options.onTrustedAccessStatus, - options.onObserverError, - tacStatus, - ); - if (tacStatus !== "granted") { + const execute = async () => { + const turn = await readCodexTurn({ + thread: options.thread, + events: options.events, + onEvent: async (event) => { + if (!tacStatusReported) { + const tacStatus = trustedAccessStatusFromEvent(event); + if (tacStatus !== null) { + tacStatusReported = true; notifyObserver( - "onWarning", - options.onWarning, + "onTrustedAccessStatus", + options.onTrustedAccessStatus, options.onObserverError, - trustedAccessWarning(tacStatus, options.authentication), + tacStatus, ); + if (tacStatus !== "granted") { + notifyObserver( + "onWarning", + options.onWarning, + options.onObserverError, + trustedAccessWarning(tacStatus, options.authentication), + ); + } } } - } - for (const activity of scanActivitiesFromEvent( - event, - options.expectation.repository, - )) { - notifyObserver( - "onActivity", - options.onActivity, - options.onObserverError, - activity, - ); - } - for (const progress of scanProgressUpdatesFromEvent(event)) { - if ( - options.expectedFilesTotal !== undefined && - progress.filesTotal !== options.expectedFilesTotal - ) { - continue; + for (const activity of scanActivitiesFromEvent( + event, + options.expectation.repository, + )) { + notifyObserver( + "onActivity", + options.onActivity, + options.onObserverError, + activity, + ); } - notifyObserver( - "onProgress", - options.onProgress, - options.onObserverError, - progress, - ); - } - const workerStatus = workerStatusFromEvent(event); - if (workerStatus !== null) { - notifyObserver( - "onWorkerStatus", - options.onWorkerStatus, - options.onObserverError, - workerStatus, - ); - } - if (event.type === "thread.started") { - const startedThreadId = event["thread_id"]; - if (typeof startedThreadId === "string") { - await options.onThreadStarted?.(startedThreadId); + for (const progress of scanProgressUpdatesFromEvent(event)) { + if ( + options.expectedFilesTotal !== undefined && + progress.filesTotal !== options.expectedFilesTotal + ) { + continue; + } + notifyObserver( + "onProgress", + options.onProgress, + options.onObserverError, + progress, + ); } - if (!scanStarted) { - scanStarted = true; + const workerStatus = workerStatusFromEvent(event); + if (workerStatus !== null) { notifyObserver( - "onScanStarted", - options.onScanStarted, + "onWorkerStatus", + options.onWorkerStatus, options.onObserverError, + workerStatus, ); } + if (event.type === "thread.started") { + const startedThreadId = event["thread_id"]; + if (typeof startedThreadId === "string") { + await options.onThreadStarted?.(startedThreadId); + } + if (!scanStarted) { + scanStarted = true; + notifyObserver( + "onScanStarted", + options.onScanStarted, + options.onObserverError, + ); + } + } + }, + onReconnect: (message, reconnect) => { + notifyObserver( + "onReconnect", + options.onReconnect, + options.onObserverError, + ...reconnect, + reconnectDetails(message), + ); + }, + }); + const { status, threadId, lastStreamError } = turn; + if (status !== "completed") { + throw new IncompleteScanError( + lastStreamError ?? + "Codex Security event stream ended before the turn completed.", + ); + } + if (threadId === null) { + throw new IncompleteScanError( + "Codex Security did not report a thread ID.", + ); + } + return { ...turn, threadId, status }; + }; + const audit = await runAcceptedAudit({ + signal: options.signal, + execute, + accept: async (turn) => { + const { status, threadId, finalResponse } = turn; + let { usage } = turn; + if (options.onFinalize !== undefined) { + usage = (await options.onFinalize(usage)) ?? usage; } - }, - onReconnect: (message, reconnect) => { - notifyObserver( - "onReconnect", - options.onReconnect, - options.onObserverError, - ...reconnect, - reconnectDetails(message), + const result = await collectResult( + { + status, + finalResponse, + usage, + ...(options.model === undefined ? {} : { model: options.model }), + }, + threadId, + options.scanDir, + options.pluginRoot, + options.expectation, + options.signal, + options.workbenchValidated, ); + return { checkpoint: result, accepted: result }; }, }); - const { status, threadId, finalResponse, lastStreamError } = turn; - let { usage } = turn; - if (options.signal.aborted) { - throw new ScanInterruptedError( - `Codex Security scan was interrupted; partial output remains at ${options.scanDir}.`, - options.scanDir, - ); - } - if (status !== "completed") { + if (audit.status === "accepted") return audit.accepted; + if (audit.status === "checkpoint") throw new IncompleteScanError( - lastStreamError ?? - "Codex Security event stream ended before the turn completed.", + "Codex Security produced only an unfinished audit checkpoint.", ); - } - if (threadId === null) { - throw new IncompleteScanError( - "Codex Security did not report a thread ID.", - ); - } - if (options.onFinalize !== undefined) { - usage = (await options.onFinalize(usage)) ?? usage; - } - const result = await collectResult( - { - status, - finalResponse, - usage, - ...(options.model === undefined ? {} : { model: options.model }), - }, - threadId, - options.scanDir, - options.pluginRoot, - options.expectation, - options.signal, - options.workbenchValidated, - ); - if (options.signal.aborted) { - throw new ScanInterruptedError( - `Codex Security scan was interrupted; partial output remains at ${options.scanDir}.`, - options.scanDir, - ); - } - return result; + throw audit.error; } catch (error) { if (options.signal.reason instanceof ScanCostLimitExceededError) { throw options.signal.reason; From 2c249d7af8767b6b8a61c468a5c6be8edb7e28c1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:37:21 +0000 Subject: [PATCH 036/251] test(sdk): require shared audit module in installed package --- sdk/typescript/scripts/check-package.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index f58c832479..12ce6dffb3 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -166,6 +166,7 @@ const allowedRoot = new Set([ ]); const distFiles = new Set( [ + "accepted-audit", "api", "auth", "bulk-scan-discovery", From 6d3d75df276cf242c07b335f60cfc6f91f4d4bea Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:26:15 +0000 Subject: [PATCH 037/251] Keep restored worker executable and home paths consistent --- .../mcp-app/src/deep-scan/recovery-settings.ts | 8 +++++--- .../mcp-app/tests/test_deep_scan_recovery_settings.mjs | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index e232bd6701..4fea692430 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; import { homedir } from "node:os"; -import { isAbsolute, join } from "node:path"; +import { isAbsolute, join, win32 } from "node:path"; import type { CodexOptions } from "@openai/codex-sdk"; import { parse as parseToml } from "smol-toml"; import { scanPreflightCodexConfig } from "../../../../../sdk/typescript/src/preflight-config.js"; @@ -40,7 +40,9 @@ export async function captureDeepScanExecutionSettings( const selected = scanPreflightCodexConfig(resolveCodexProfile(config)); return executionSettings({ codexPath: resolveCodexPath(environment, process.platform, process.arch, process.cwd()), - codexHome: isAbsolute(codexHome) ? codexHome : await fs.realpath(codexHome), + codexHome: !isAbsolute(codexHome) + || (process.platform === "win32" && ["\\", "/"].includes(win32.parse(codexHome).root)) + ? await fs.realpath(codexHome) : codexHome, model: original.model ?? selected.model as string | undefined, reasoningEffort: original.reasoningEffort ?? selected.model_reasoning_effort as string | undefined, modelProvider: selected.model_provider as string | undefined, @@ -98,7 +100,7 @@ export function restoredDeepScanWorkerSettings( // The executor reads this property for each launch. API keys can refresh; // only the original account home and non-secret selections are bound. get env() { - return Object.fromEntries(Object.entries({ ...environment(), CODEX_HOME: settings.codexHome }) + return Object.fromEntries(Object.entries({ ...environment(), CODEX_CLI_PATH: settings.codexPath, CODEX_HOME: settings.codexHome }) .filter((entry): entry is [string, string] => entry[1] !== undefined)); }, config: { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 439618f3b4..5b1864714d 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -66,12 +66,13 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(JSON.stringify(captured).includes("synthetic-secret"), false); let credential = "synthetic-first"; const restored = restoreSettings(captured, { filesystemDenies: ["/fixture/current-deny"] }, () => ({ - CODEX_API_KEY: credential, CODEX_HOME: "/fixture/observer-home" + CODEX_API_KEY: credential, CODEX_HOME: "/fixture/observer-home", CODEX_CLI_PATH: "/fixture/observer-codex" })); assert.equal(restored.codexOptions.env.CODEX_API_KEY, "synthetic-first"); credential = "synthetic-refreshed"; assert.equal(restored.codexOptions.env.CODEX_API_KEY, "synthetic-refreshed"); assert.equal(restored.codexOptions.env.CODEX_HOME, root); + assert.equal(restored.codexOptions.env.CODEX_CLI_PATH, captured.codexPath); assert.deepEqual(restored.parentSandbox.filesystemDenies, ["/fixture/original-deny", "/fixture/current-deny"]); assert.equal(restored.codexOptions.config.model_reasoning_effort, "ultra"); await writeFile(join(root, "config.toml"), 'model = "native-home-model"\n'); From 75aa3d472bb47040b70a007dccc19a68ec6a1193 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:20:59 +0000 Subject: [PATCH 038/251] test(sdk): load shared session code in worker shutdown fixtures --- .../deep-scan-worker-shutdown.test.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts index c23adfb4d6..a2a74d36d1 100644 --- a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts @@ -31,6 +31,18 @@ async function bundledWorkerExecutor( if (source === undefined) { throw new Error("Bundled Deep Scan worker executor was not found."); } + const sessionSource = + /\n\/\/ [^\n]*\/codex-session\.ts\n([\s\S]*?)(?=\n\/\/)/u.exec( + runtime, + )?.[1]; + expect(sessionSource).toBeDefined(); + const recordFunction = /\b(isRecord\d*)\(/u.exec(source)?.[1]; + expect(recordFunction).toBeDefined(); + const recordSource = new RegExp( + `function ${recordFunction}\\([^\\n]*\\) \\{[\\s\\S]*?\\n\\}`, + "u", + ).exec(runtime)?.[0]; + expect(recordSource).toBeDefined(); const fileSystemImport = /\b(import_node_fs\d*)\.promises\.readFile\(/u.exec( source, )?.[1]; @@ -54,7 +66,8 @@ async function bundledWorkerExecutor( "workerPermissionProfile", "workerPermissionProfileConfigOverrides", "snapshotWorkerEnvironment", - "workerReasoningSummary", + "workerModelConfig", + "workerModelSelection", "environmentVariable", "preflightDeepScanWorkerPermissionProfile", "DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID", @@ -64,14 +77,15 @@ async function bundledWorkerExecutor( "workerSubagentConfig", "appendSafeItemDiagnostic", "classifyCodexWorkerError", - `${source}\nreturn CodexSdkWorkerExecutor;`, + `${sessionSource}\n${recordSource}\n${source}\nreturn CodexSdkWorkerExecutor;`, )( FakeCodex, { promises: { readFile: async () => "fixture worker prompt" } }, () => ({}), () => [], async () => ({}), - async () => undefined, + async () => ({}), + () => ({}), () => undefined, preflight, "codex_security_deep_scan_worker", From 1e90097cbdfc185a71ad0ceee1b9c86f7c4956ef Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:42:33 +0000 Subject: [PATCH 039/251] Recover omitted worker selections from the original native parent --- plugins/codex-security/mcp-app/server.ts | 2 +- .../src/deep-scan/recovery-settings.ts | 49 +++++++++++++++++-- .../test_deep_scan_recovery_settings.mjs | 19 ++++++- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index de40ad38ce..ea0d56917e 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -754,7 +754,7 @@ export function createCodexSecurityServer(): McpServer { prepareExecutor: async (run) => new CodexSdkWorkerExecutor({ ...restoredDeepScanWorkerSettings( await loadOrCaptureDeepScanExecutionSettings(run.scanDir, () => - captureDeepScanExecutionSettings(run, parentSandbox)), + captureDeepScanExecutionSettings(run, parentSandbox, process.env, { threadId, startedAt: run.createdAt })), parentSandbox ), artifactContext: { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 4fea692430..5554250664 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -5,6 +5,7 @@ import type { CodexOptions } from "@openai/codex-sdk"; import { parse as parseToml } from "smol-toml"; import { scanPreflightCodexConfig } from "../../../../../sdk/typescript/src/preflight-config.js"; import { resolveCodexProfile, type JsonObject } from "../../../../../sdk/typescript/src/config.js"; +import { readScanLogs } from "../../../../../sdk/typescript/src/scan-logs.js"; import { writeJsonAtomic } from "./artifacts.js"; import { resolveCodexPath } from "./executor.js"; import type { DeepWorkerParentSandbox } from "./parent-sandbox.js"; @@ -25,7 +26,8 @@ export interface DeepScanExecutionSettings { export async function captureDeepScanExecutionSettings( original: { model?: string; reasoningEffort?: string }, parentSandbox: DeepWorkerParentSandbox, - environment: NodeJS.ProcessEnv = process.env + environment: NodeJS.ProcessEnv = process.env, + parent?: { threadId: string; startedAt?: string } ): Promise { const codexHome = environment.CODEX_HOME || join(homedir(), ".codex"); const configPath = environment.CODEX_SECURITY_CONFIG_PATH ?? join(codexHome, "config.toml"); @@ -38,21 +40,58 @@ export async function captureDeepScanExecutionSettings( } // Reuse the SDK projection: custom provider credentials belong in the native home. const selected = scanPreflightCodexConfig(resolveCodexProfile(config)); + const native = parent === undefined ? {} : await originalParentSettings(codexHome, parent); return executionSettings({ codexPath: resolveCodexPath(environment, process.platform, process.arch, process.cwd()), codexHome: !isAbsolute(codexHome) || (process.platform === "win32" && ["\\", "/"].includes(win32.parse(codexHome).root)) ? await fs.realpath(codexHome) : codexHome, - model: original.model ?? selected.model as string | undefined, - reasoningEffort: original.reasoningEffort ?? selected.model_reasoning_effort as string | undefined, - modelProvider: selected.model_provider as string | undefined, - reasoningSummary: selected.model_reasoning_summary as string | undefined, + model: original.model ?? (selected.model as string | undefined) ?? native.model, + reasoningEffort: original.reasoningEffort ?? (selected.model_reasoning_effort as string | undefined) ?? native.reasoningEffort, + modelProvider: (selected.model_provider as string | undefined) ?? native.modelProvider, + reasoningSummary: (selected.model_reasoning_summary as string | undefined) ?? native.reasoningSummary, serviceTier: selected.service_tier as string | undefined, providerConfig: selected.model_providers as JsonObject | undefined, parentSandbox }); } +async function originalParentSettings( + codexHome: string, + parent: { threadId: string; startedAt?: string } +): Promise> { + // Native config/read represents omitted selections as null. The existing + // parent record contains the provider and summary actually used by that turn. + // History can be disabled or unavailable; configured selections still work. + try { + const log = await readScanLogs({ + scanId: parent.threadId, threadId: parent.threadId, executionThreadIds: [], + codexHome, allowMissingRoot: true + }); + const settings: Partial = {}; + const cutoff = parent.startedAt === undefined ? Infinity : Date.parse(parent.startedAt); + for (const entry of log.events) { + const event = entry.event as Record; + const timestamp = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : undefined; + if (timestamp !== undefined && timestamp > cutoff) continue; + const payload = event.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue; + const context = payload as Record; + if (event.type === "session_meta" && typeof context.model_provider === "string") { + settings.modelProvider = context.model_provider; + } + if (event.type === "turn_context") { + if (typeof context.model === "string") settings.model = context.model; + if (typeof context.effort === "string") settings.reasoningEffort = context.effort; + if (typeof context.summary === "string") settings.reasoningSummary = context.summary; + } + } + return settings; + } catch { + return {}; + } +} + /** Called by the acquired coordinator before it starts any worker. */ export async function loadOrCaptureDeepScanExecutionSettings( scanDir: string, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 5b1864714d..7e242d9904 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { build } from "esbuild"; @@ -78,6 +78,23 @@ http_headers = { Authorization = "synthetic-secret" } await writeFile(join(root, "config.toml"), 'model = "native-home-model"\n'); const native = await captureSettings({}, { filesystemDenies: [] }, { CODEX_CLI_PATH: process.execPath, CODEX_HOME: root }); assert.equal(native.model, "native-home-model"); + const sessionDirectory = join(root, "sessions"); + await mkdir(sessionDirectory); + await writeFile(join(sessionDirectory, "parent.jsonl"), [ + { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: "fixture-parent", model_provider: "openai" } }, + { type: "turn_context", timestamp: "2026-01-01T00:00:01Z", payload: { model: "parent-model", effort: "high", summary: "none" } }, + { type: "turn_context", timestamp: "2026-01-01T00:02:00Z", payload: { model: "later-model", effort: "low", summary: "detailed" } } + ].map(JSON.stringify).join("\n") + "\n"); + await writeFile(join(sessionDirectory, "other.jsonl"), JSON.stringify({ + type: "session_meta", payload: { id: "fixture-other", model_provider: "other-provider" } + }) + "\n"); + const parentSettings = await captureSettings({}, { filesystemDenies: [] }, { + CODEX_CLI_PATH: process.execPath, CODEX_HOME: root + }, { threadId: "fixture-parent", startedAt: "2026-01-01T00:01:00Z" }); + assert.equal(parentSettings.model, "native-home-model", "explicit config retains precedence"); + assert.equal(parentSettings.modelProvider, "openai"); + assert.equal(parentSettings.reasoningSummary, "none", "later owner turns are not original discovery settings"); + assert.equal(parentSettings.reasoningEffort, "high"); const unsupported = JSON.stringify({ version: 99, settings }); await writeFile(savedPath, unsupported); await assert.rejects(loadSettings(join(root, "one"), async () => assert.fail()), /unsupported/); From adda0496a48f565f48d58fff9948fc131aee297f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:38:54 +0000 Subject: [PATCH 040/251] Preserve receipt ownership for immutable discovery inputs --- .../mcp-app/src/artifact-deep-reducer.ts | 2 +- .../codex-security/mcp-app/src/artifact-io.ts | 2 ++ .../mcp-app/src/deep-scan/coordinator.ts | 2 +- .../mcp-app/src/deep-scan/worker-runner.ts | 2 +- .../tests/deep_scan_coverage_fixture.mjs | 26 ++++++++++++--- .../test_deep_scan_checkpoint_coverage.mjs | 33 +++++++++++++++++++ .../mcp-app/tests/test_deep_scan_executor.mjs | 6 +++- 7 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_checkpoint_coverage.mjs diff --git a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts index 06d0efa7f2..7749d0ceb1 100644 --- a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts +++ b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts @@ -91,7 +91,7 @@ export async function readDeepReductionSources( return { workerId: worker.id, ...(worker.attempt === undefined ? {} : { attempt: worker.attempt }), - coverage: projectDiscoveryCoverage(coverage, worker, relative(bound.artifacts.scanDir, dirname(worker.resultPath)).split(sep).join("/")), + coverage: projectDiscoveryCoverage(coverage, worker, relative(bound.artifacts.scanDir, worker.artifactDir ?? dirname(worker.resultPath)).split(sep).join("/")), result: reduction, }; })); diff --git a/plugins/codex-security/mcp-app/src/artifact-io.ts b/plugins/codex-security/mcp-app/src/artifact-io.ts index 0a4299467f..66a7e8f38f 100644 --- a/plugins/codex-security/mcp-app/src/artifact-io.ts +++ b/plugins/codex-security/mcp-app/src/artifact-io.ts @@ -5,6 +5,8 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path"; export interface DeepReducerWorkerContext { id: string; resultPath: string; + /** Original output owner for relative evidence, including accepted checkpoints. */ + artifactDir?: string; attempt?: number; } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 7a20c4077e..e14f11df97 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -978,7 +978,7 @@ export class DeepScanCoordinator { deepReducer: { scanRoot: this.artifacts.scanDir, claimedWorkers: accepted.map((source) => ({ - id: source.id, resultPath: source.resultPath, attempt: source.attempt, + id: source.id, resultPath: source.resultPath, artifactDir: source.artifactDir, attempt: source.attempt, })), }, }; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 36a39effdb..cac40ad802 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -306,7 +306,7 @@ export class DeepScanWorkerRunner { deepReducer: { scanRoot: artifacts.scanDir, persistSourceCoverage, - claimedWorkers: consumed.map((worker) => ({ id: worker.id, resultPath: worker.resultPath, attempt: worker.attempt })), + claimedWorkers: consumed.map((worker) => ({ id: worker.id, resultPath: worker.resultPath, artifactDir: worker.artifactDir, attempt: worker.attempt })), previousReducerResultPath } }; diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index e5bab7e473..f6c164b5b5 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -16,17 +16,17 @@ const bundled = await build({ 'export { DeepScanCoordinator } from "./src/deep-scan/coordinator.ts";', 'export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts";', 'export { createScanArtifactContext } from "./src/artifact-context.ts";', - 'export { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.ts";', + 'export { recordCodexSecurityScanDraftViaWorkbench, saveScanDraftCheckpoint } from "./src/artifact-scan-draft.ts";', 'export { recordCodexSecurityDeepReduction } from "./src/artifact-deep-reducer.ts";', ].join("\n"), resolveDir: path.join(pluginRoot, "mcp-app"), }, format: "esm", platform: "node", loader: { ".md": "text" }, write: false, }); -export async function publishCoverageFixture(root, completeness, { resume = false, continueAfterResume = false } = {}) { +export async function publishCoverageFixture(root, completeness, { resume = false, continueAfterResume = false, immutableInputs = false } = {}) { const runtimePath = path.join(root, "fixture-runtime.mjs"); await writeFile(runtimePath, bundled.outputFiles[0].contents); - const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityDeepReduction } = await import(pathToFileURL(runtimePath).href); + const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityDeepReduction, saveScanDraftCheckpoint } = await import(pathToFileURL(runtimePath).href); const targetPath = path.join(root, "target"); const codexHome = path.join(root, "codex-home"); const scanRoot = path.join(root, "scans"); @@ -65,6 +65,14 @@ export async function publishCoverageFixture(root, completeness, { resume = fals const bytes = JSON.stringify({ scanId: run.scanId, complete: true, findings: [], coverage }); await writeFile(resultPath, bytes); rawSources.set(resultPath, bytes); + if (immutableInputs) { + await saveScanDraftCheckpoint({ root: artifactDir, repoRoot: targetPath, layout: "worker" }, JSON.parse(bytes)); + const head = JSON.parse(await readFile(path.join(artifactDir, "checkpoint-head.json"), "utf8")); + const acceptedPath = path.join(artifactDir, "checkpoints", head.checkpoint); + rawSources.set(acceptedPath, await readFile(acceptedPath, "utf8")); + return acceptedPath; + } + return resultPath; }; if (resume) { const workers = []; @@ -73,10 +81,10 @@ export async function publishCoverageFixture(root, completeness, { resume = fals const workerRoot = path.join(run.scanDir, "artifacts", "deep_discovery", "workers", `discovery-${String(index + 1).padStart(4, "0")}`); const artifactDir = path.join(workerRoot, "output"); const worker = { id: randomUUID(), scanId: run.scanId, kind: "discovery", promptPath: path.join(workerRoot, "prompt.md"), artifactDir, attempt: index === 0 ? 2 : 1 }; - await writeDiscovery(artifactDir, index); + const resultManifestPath = await writeDiscovery(artifactDir, index); await writeFile(worker.promptPath, "Synthetic discovery prompt.\n"); for (const status of ["queued", "running", "succeeded"]) { - await store.updateWorker({ ...worker, status, ...(status === "succeeded" ? { resultManifestPath: path.join(artifactDir, "result.json") } : {}) }); + await store.updateWorker({ ...worker, status, ...(status === "succeeded" ? { resultManifestPath } : {}) }); } workers.push(worker); } @@ -105,6 +113,14 @@ export async function publishCoverageFixture(root, completeness, { resume = fals if (index === 0 && !request.resumeThreadId) return { threadId: thread, finalResponse: "Continue the unfinished audit." }; await writeDiscovery(request.artifactContext.root, index); } else { + if (immutableInputs) { + const current = await store.get(run.scanId, threadId); + for (const claimed of request.artifactContext.deepReducer.claimedWorkers) { + const accepted = current.persistedWorkers.find((worker) => worker.id === claimed.id); + assert.equal(claimed.resultPath, accepted.resultManifestPath, "the reducer uses the exact accepted input"); + assert.equal(claimed.artifactDir, accepted.artifactDir, "receipts retain their original output owner"); + } + } await recordCodexSecurityDeepReduction({ ...request.artifactContext, repoRoot: targetPath, scanId: run.scanId }, { scanId: run.scanId, findings: [] }); } return { threadId: thread, finalResponse: "Audit finished." }; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_checkpoint_coverage.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_checkpoint_coverage.mjs new file mode 100644 index 0000000000..1df47d525f --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_checkpoint_coverage.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { publishCoverageFixture } from "./deep_scan_coverage_fixture.mjs"; + +for (const continueAfterResume of [false, true]) { + test(`immutable discovery receipts survive resumed publication (continued: ${continueAfterResume})`, async () => { + const root = await mkdtemp(path.join(tmpdir(), "deep-checkpoint-coverage-")); + try { + const fixture = path.join(root, "fixture"); + await mkdir(fixture, { mode: 0o700 }); + const { scanDir } = await publishCoverageFixture(fixture, "partial", { + resume: true, continueAfterResume, immutableInputs: true, + }); + const coverage = JSON.parse(await readFile(path.join(scanDir, "coverage.json"), "utf8")); + assert.equal(coverage.completeness, "partial"); + assert.deepEqual(coverage.reviews.map((review) => review.completeness), ["partial", "complete", "unknown"]); + assert.equal(coverage.reviews[0].attempt, 2); + assert.equal(new Set(coverage.deferred.map((item) => item.candidateId)).size, 2); + const report = await readFile(path.join(scanDir, "report.md"), "utf8"); + for (const item of coverage.deferred) assert.ok(report.includes(item.reason)); + for (const surface of coverage.surfaces) { + for (const receipt of surface.receiptRefs) { + assert.equal(await readFile(path.join(scanDir, receipt), "utf8"), "Synthetic review evidence.\n"); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 9e9958a147..a51a053be7 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -1133,7 +1133,11 @@ async function testReducerCoveragePersistenceBinding() { process.env.FAKE_CODEX_MARKER = markerPath; const deepReducer = { scanRoot: path.join(fixture.root, "scans"), - claimedWorkers: [{ id: "worker-1", resultPath: path.join(fixture.root, "worker", "result.json"), attempt: 2 }], + claimedWorkers: [{ + id: "worker-1", attempt: 2, + resultPath: path.join(fixture.root, "worker", "checkpoints", "accepted.json"), + artifactDir: path.join(fixture.root, "worker"), + }], persistSourceCoverage, }; const launch = new CodexSdkWorkerExecutor({ From 92ccb9c29801d0cd6e1c8a9191ef5e81356c07f3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:52:53 +0000 Subject: [PATCH 041/251] Verify material fixes alongside recovered source coverage --- .../tests/deep_scan_coverage_fixture.mjs | 59 ++++++++++++++++--- .../mcp-app/tests/test_deep_scan_executor.mjs | 7 ++- .../test_deep_scan_material_coverage.mjs | 39 ++++++++++++ 3 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_material_coverage.mjs diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index f6c164b5b5..e88449336f 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -17,16 +17,16 @@ const bundled = await build({ 'export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts";', 'export { createScanArtifactContext } from "./src/artifact-context.ts";', 'export { recordCodexSecurityScanDraftViaWorkbench, saveScanDraftCheckpoint } from "./src/artifact-scan-draft.ts";', - 'export { recordCodexSecurityDeepReduction } from "./src/artifact-deep-reducer.ts";', + 'export { recordCodexSecurityDeepReduction, getCodexSecurityDeepReducerInputs } from "./src/artifact-deep-reducer.ts";', ].join("\n"), resolveDir: path.join(pluginRoot, "mcp-app"), }, format: "esm", platform: "node", loader: { ".md": "text" }, write: false, }); -export async function publishCoverageFixture(root, completeness, { resume = false, continueAfterResume = false, immutableInputs = false } = {}) { +export async function publishCoverageFixture(root, completeness, { resume = false, continueAfterResume = false, immutableInputs = false, materialFindings = false } = {}) { const runtimePath = path.join(root, "fixture-runtime.mjs"); await writeFile(runtimePath, bundled.outputFiles[0].contents); - const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityDeepReduction, saveScanDraftCheckpoint } = await import(pathToFileURL(runtimePath).href); + const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityDeepReduction, getCodexSecurityDeepReducerInputs, saveScanDraftCheckpoint } = await import(pathToFileURL(runtimePath).href); const targetPath = path.join(root, "target"); const codexHome = path.join(root, "codex-home"); const scanRoot = path.join(root, "scans"); @@ -49,6 +49,18 @@ export async function publishCoverageFixture(root, completeness, { resume = fals let { run } = await store.begin({ targetPath, scope: ".", threadId, scanRoot }); const context = await createScanArtifactContext(run.scanId, runWorkbench, { requireRunning: true }); const rawSources = new Map(); + const writeReduction = async (context) => { + const inputs = await getCodexSecurityDeepReducerInputs(context); + const sources = [...(inputs.previous?.findings ?? []), ...inputs.discoveries.flatMap((source) => source.result.findings)]; + const findings = []; + if (sources.length) { + const finding = structuredClone(sources[0]); + finding.provenance.sourceFindingIds = [...new Set(sources.flatMap((source) => source.provenance.sourceFindingIds))]; + delete finding.provenance.sourceFindings; + findings.push(finding); + } + await recordCodexSecurityDeepReduction(context, { scanId: run.scanId, findings }); + }; const writeDiscovery = async (artifactDir, index) => { const status = statuses[index]; const pending = completeness === "partial" && status !== "complete"; @@ -62,7 +74,19 @@ export async function publishCoverageFixture(root, completeness, { resume = fals await mkdir(path.join(artifactDir, "artifacts"), { recursive: true }); await writeFile(path.join(artifactDir, "artifacts", "review.md"), "Synthetic review evidence.\n"); const resultPath = path.join(artifactDir, "result.json"); - const bytes = JSON.stringify({ scanId: run.scanId, complete: true, findings: [], coverage }); + const findings = materialFindings && index < 2 ? [{ + ruleId: "archive-extraction", identity: { anchor: "archive-destination" }, + title: "Archive entries can escape the destination", + summary: "Archive extraction requires both entry containment and symbolic-link handling.", + severity: { level: "high" }, + confidence: { level: "high", rationale: "Synthetic accepted source evidence." }, + taxonomy: { category: "path-traversal", cwe: ["CWE-22"] }, + locations: [{ path: "source.py", startLine: 1, endLine: 1 }], + remediation: materialRemediations[index], + remediationTests: [materialRemediationTests[index]], + provenance: { source: "local_plugin" }, + }] : []; + const bytes = JSON.stringify({ scanId: run.scanId, complete: true, findings, coverage }); await writeFile(resultPath, bytes); rawSources.set(resultPath, bytes); if (immutableInputs) { @@ -86,7 +110,7 @@ export async function publishCoverageFixture(root, completeness, { resume = fals for (const status of ["queued", "running", "succeeded"]) { await store.updateWorker({ ...worker, status, ...(status === "succeeded" ? { resultManifestPath } : {}) }); } - workers.push(worker); + workers.push({ ...worker, resultPath: resultManifestPath }); } const artifactDir = path.join(run.scanDir, "artifacts", "deep_discovery", "dedup", "dedup-0001", "output"); const promptPath = path.join(path.dirname(artifactDir), "prompt.md"); @@ -96,9 +120,16 @@ export async function publishCoverageFixture(root, completeness, { resume = fals await store.claimDedup({ id, scanId: run.scanId, workerIds: workers.map((worker) => worker.id), artifactDir, promptPath }); const resultManifestPath = path.join(artifactDir, "result.json"); // Legacy accepted reducers omitted coverage entirely. - await writeFile(resultManifestPath, JSON.stringify({ scanId: run.scanId, findings: [] })); + if (materialFindings) { + await writeReduction({ + root: artifactDir, repoRoot: targetPath, scanId: run.scanId, layout: "reducer", + deepReducer: { scanRoot: run.scanDir, claimedWorkers: workers }, + }); + } else { + await writeFile(resultManifestPath, JSON.stringify({ scanId: run.scanId, findings: [] })); + } rawSources.set(resultManifestPath, await readFile(resultManifestPath, "utf8")); - await store.commitDedup({ id, scanId: run.scanId, newFindings: 0, resultManifestPath }); + await store.commitDedup({ id, scanId: run.scanId, newFindings: materialFindings ? 1 : 0, resultManifestPath }); run = await store.get(run.scanId, threadId); } let discoveryCalls = 0; @@ -121,7 +152,7 @@ export async function publishCoverageFixture(root, completeness, { resume = fals assert.equal(claimed.artifactDir, accepted.artifactDir, "receipts retain their original output owner"); } } - await recordCodexSecurityDeepReduction({ ...request.artifactContext, repoRoot: targetPath, scanId: run.scanId }, { scanId: run.scanId, findings: [] }); + await writeReduction({ ...request.artifactContext, repoRoot: targetPath, scanId: run.scanId }); } return { threadId: thread, finalResponse: "Audit finished." }; }, @@ -135,7 +166,8 @@ export async function publishCoverageFixture(root, completeness, { resume = fals coordinator.start(); const terminal = await coordinator.wait(undefined, 30_000); assert.equal(terminal?.status, "succeeded", terminal?.error); - assert.equal(terminal.noNewStreak, statuses.length, "source coverage must not change stopping policy"); + assert.equal(terminal.noNewStreak, materialFindings ? (resume && !continueAfterResume ? 0 : 1) : statuses.length, + "source coverage must not change stopping policy"); assert.equal(discoveryCalls, resume ? (continueAfterResume ? 1 : 0) : statuses.length + 1); const accepted = await store.get(run.scanId, threadId); for (const worker of accepted.persistedWorkers.filter((worker) => worker.kind === "dedup")) { @@ -153,6 +185,15 @@ export async function publishCoverageFixture(root, completeness, { resume = fals return { scanDir: run.scanDir, threadId, terminal }; } +export const materialRemediations = [ + "Check the destination before writing the archive entry.", + "Reject symbolic links before opening the destination.", +]; +export const materialRemediationTests = [ + "Reject an archive entry outside the destination.", + "Reject a symbolic link inside the destination.", +]; + if (process.argv[1] === fileURLToPath(import.meta.url)) { const result = await publishCoverageFixture(process.argv[2], process.argv[3], { resume: process.argv[4] === "true", continueAfterResume: process.argv[5] === "true" }); process.stdout.write(JSON.stringify(result)); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index a51a053be7..ac7db4fff0 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -1131,12 +1131,13 @@ async function testReducerCoveragePersistenceBinding() { for (const persistSourceCoverage of [false, true]) { const markerPath = path.join(fixture.root, `coverage-${resume}-${persistSourceCoverage}.json`); process.env.FAKE_CODEX_MARKER = markerPath; + const scanRoot = path.join(fixture.root, `scan-${resume}-${persistSourceCoverage}`); const deepReducer = { - scanRoot: path.join(fixture.root, "scans"), + scanRoot, claimedWorkers: [{ id: "worker-1", attempt: 2, - resultPath: path.join(fixture.root, "worker", "checkpoints", "accepted.json"), - artifactDir: path.join(fixture.root, "worker"), + resultPath: path.join(scanRoot, "worker", "checkpoints", "accepted.json"), + artifactDir: path.join(scanRoot, "worker"), }], persistSourceCoverage, }; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_material_coverage.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_material_coverage.mjs new file mode 100644 index 0000000000..71ea1af4fc --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_material_coverage.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { materialRemediations, materialRemediationTests, publishCoverageFixture } from "./deep_scan_coverage_fixture.mjs"; + +for (const [resume, continueAfterResume] of [[false, false], [true, false], [true, true]]) { + test(`material fixes and unresolved coverage survive canonical publication (resume: ${resume}, continued: ${continueAfterResume})`, async () => { + const root = await mkdtemp(path.join(tmpdir(), "deep-material-coverage-")); + try { + const fixture = path.join(root, "fixture"); + await mkdir(fixture, { mode: 0o700 }); + const { scanDir } = await publishCoverageFixture(fixture, "partial", { + resume, continueAfterResume, immutableInputs: true, materialFindings: true, + }); + const { findings } = JSON.parse(await readFile(path.join(scanDir, "findings.json"), "utf8")); + assert.equal(findings.length, 1); + const sources = findings[0].provenance.sourceFindings; + assert.deepEqual(sources.map((source) => source.finding.remediation), materialRemediations); + assert.equal(new Set(sources.map((source) => source.id)).size, 2); + const report = await readFile(path.join(scanDir, "report.md"), "utf8"); + for (const text of [...materialRemediations, ...materialRemediationTests]) { + assert.equal(report.split(text).length - 1, 1, `canonical report retains ${text}`); + } + const coverage = JSON.parse(await readFile(path.join(scanDir, "coverage.json"), "utf8")); + assert.equal(coverage.completeness, "partial"); + assert.deepEqual(coverage.reviews.map((review) => review.completeness), ["partial", "complete", "unknown"]); + assert.equal(coverage.deferred.length, 2, "a finding does not discharge independent unresolved work"); + assert.equal(new Set(coverage.deferred.map((item) => item.candidateId)).size, 2); + for (const item of coverage.deferred) assert.ok(report.includes(item.reason)); + for (const surface of coverage.surfaces) { + assert.equal(await readFile(path.join(scanDir, surface.receiptRefs[0]), "utf8"), "Synthetic review evidence.\n"); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} From ad90b17a3dc4e41b8714d1666a708cd5097afb61 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:51:33 +0000 Subject: [PATCH 042/251] fix: replay Deep publication from its selected accepted input --- .../codex-security/mcp-app/helpers-main.ts | 61 +++--- .../mcp-app/scripts/build_mcp_app.mjs | 1 + plugins/codex-security/mcp-app/server.ts | 48 +++-- .../mcp-app/src/deep-scan/coordinator.ts | 82 ++++---- .../mcp-app/src/deep-scan/finalization.ts | 183 ++++++++++++++++++ .../mcp-app/src/deep-scan/registry.ts | 11 +- .../mcp-app/src/deep-scan/store.ts | 42 +++- .../mcp-app/src/deep-scan/types.ts | 9 + .../tests/deep_scan_publication_cases.mjs | 16 +- .../tests/test_deep_scan_coordinator.mjs | 8 +- .../tests/test_deep_scan_finalization.mjs | 109 +++++++++++ .../tests/test_deep_scan_selected_replay.mjs | 92 +++++++++ .../tests/test_deep_scan_selection_store.mjs | 42 ++++ .../test_deep_scan_store_integration.mjs | 9 + .../scripts/deep_scan_workbench.py | 96 ++++++++- .../codex-security/scripts/workbench_db.py | 4 +- .../scripts/workbench_saved_results.py | 5 + 17 files changed, 724 insertions(+), 94 deletions(-) create mode 100644 plugins/codex-security/mcp-app/src/deep-scan/finalization.ts create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_selected_replay.mjs create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_selection_store.mjs diff --git a/plugins/codex-security/mcp-app/helpers-main.ts b/plugins/codex-security/mcp-app/helpers-main.ts index 18c8611bee..db7c7e9636 100644 --- a/plugins/codex-security/mcp-app/helpers-main.ts +++ b/plugins/codex-security/mcp-app/helpers-main.ts @@ -1,34 +1,43 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +export { resumeSelectedDeepScan } from "./src/deep-scan/finalization.js"; import { resolveSecurityMdCommand } from "./src/helpers/resolve-security-md"; import { decodePosixBytes } from "./src/helpers/posix-path"; import { windowsBinding } from "./src/native"; -let commandLine = process.argv.slice(2); -if (process.platform === "win32") { - const original = windowsBinding().windowsArguments(); - commandLine = original - .slice(original.length - commandLine.length) - .map((argument) => argument.toString("utf16le")); -} -let posixHome = process.env.HOME; -if (commandLine[0] === "--helper") { +// Importing the bundled helper from the SDK does not invoke its CLI adapter. +const entryPath = import.meta.url.startsWith("file:") ? fileURLToPath(import.meta.url) : import.meta.url; +if (process.argv[1] && resolve(process.argv[1]) === entryPath) runHelper(); + +function runHelper(): void { + let commandLine = process.argv.slice(2); if (process.platform === "win32") { - commandLine = commandLine.slice(1); + const original = windowsBinding().windowsArguments(); + commandLine = original + .slice(original.length - commandLine.length) + .map((argument) => argument.toString("utf16le")); + } + let posixHome = process.env.HOME; + if (commandLine[0] === "--helper") { + if (process.platform === "win32") { + commandLine = commandLine.slice(1); + } else { + const [homeSet, home, ...args] = decodePosixBytes( + Buffer.from(commandLine[1] ?? "", "hex"), + ) + .split("\0") + .slice(0, -1); + posixHome = homeSet ? home : undefined; + commandLine = args; + } + } + const [command, ...args] = commandLine; + if (command === "resolve-security-md") { + process.exitCode = resolveSecurityMdCommand(args, posixHome); } else { - const [homeSet, home, ...args] = decodePosixBytes( - Buffer.from(commandLine[1] ?? "", "hex"), - ) - .split("\0") - .slice(0, -1); - posixHome = homeSet ? home : undefined; - commandLine = args; + console.error( + "Usage: launch_codex_security_mcp[.cmd] --helper resolve-security-md [options]", + ); + process.exitCode = 2; } } -const [command, ...args] = commandLine; -if (command === "resolve-security-md") { - process.exitCode = resolveSecurityMdCommand(args, posixHome); -} else { - console.error( - "Usage: launch_codex_security_mcp[.cmd] --helper resolve-security-md [options]", - ); - process.exitCode = 2; -} diff --git a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs index 94022da4fe..6b15baa7ab 100644 --- a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs +++ b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs @@ -108,5 +108,6 @@ const runtimeModule = new Module(loaderPath); runtimeModule.filename = loaderPath; runtimeModule.paths = Module._nodeModulePaths(dirname(loaderPath)); runtimeModule._compile(runtimeSource, loaderPath); +export default runtimeModule.exports; `; } diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index ea0d56917e..51e77e4f2b 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -811,12 +811,26 @@ export function createCodexSecurityServer(): McpServer { invocationFailure: toolErrorResult(deepScanInvocationFailureMessage(error)) })); if ("invocationFailure" in preparation) return preparation.invocationFailure; - if (preparation.immediate) return preparation.immediate; + const completeSelectedParent = async (run: DeepScanRunState) => { + if (run.finalizationInput && run.status === "succeeded") { + await runWorkbench([ + "complete-scan", "--scan-id", run.scanId, "--thread-id", threadId, + ...optionalArg("--claim-token", handoffClaimToken), + ]); + } + }; + if (preparation.immediate) { + try { await completeSelectedParent(preparation.begun.run); } + catch (error) { return toolErrorResult(deepScanInvocationFailureMessage(error)); } + return preparation.immediate; + } const { begun, coordinator, joined } = preparation; if (joined) { logDeepScanEvent({ event: "coordinator_joined", scanId: begun.run.scanId }); } const terminal = await coordinator.wait(abortSignalFromExtra(extra)); + try { await completeSelectedParent(terminal); } + catch (error) { return toolErrorResult(deepScanInvocationFailureMessage(error)); } const result = deepScanTerminalResult(terminal); if (!result) { return toolErrorResult(deepScanInvocationFailureMessage( @@ -1647,12 +1661,13 @@ function logDeepScanEvent(event: { async function runWorkbench( args: string[], - input?: string | Buffer + input?: string | Buffer, + selectFinalization = false, ): Promise { let pythonCommand: string | undefined; try { pythonCommand = await resolvePythonCommand(); - return await executeWorkbenchWithStateSelection(pythonCommand, args, input); + return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization); } catch (error) { const launchError = pythonCommand ? missingPythonHelperMessage(error, pythonCommand) @@ -1670,36 +1685,37 @@ async function runWorkbench( async function executeWorkbenchWithStateSelection( pythonCommand: string, args: string[], - input?: string | Buffer + input?: string | Buffer, + selectFinalization = false, ): Promise { if (WORKBENCH_COMMANDS_WITHOUT_DATABASE.has(args[0] ?? "")) { - return await executeWorkbench(pythonCommand, args, undefined, input); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); } if (CONFIGURED_WORKBENCH_STATE_DIR) { - return await executeWorkbench(pythonCommand, args, undefined, input); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); } if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); } return await withWorkbenchStateSelectionLock(async () => { if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); } try { - const result = await executeWorkbench(pythonCommand, args, undefined, input); + const result = await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); persistentWorkbenchStateSucceeded = true; return result; } catch (error) { if (!isUnwritableSqliteOpenError(error)) throw error; const fallbackStateDir = await pinFallbackWorkbenchStateDir(); logWorkbenchStateFallback(); - return await executeWorkbench(pythonCommand, args, fallbackStateDir, input); + return await executeWorkbench(pythonCommand, args, fallbackStateDir, input, selectFinalization); } }); } @@ -1722,7 +1738,8 @@ async function executeWorkbench( pythonCommand: string, args: string[], stateDir?: string, - input?: string | Buffer + input?: string | Buffer, + selectFinalization = false, ): Promise { const userContextIndex = args.indexOf("--user-context"); const userContext = userContextIndex === -1 ? undefined : args[userContextIndex + 1]; @@ -1731,7 +1748,10 @@ async function executeWorkbench( workbenchArgs.splice(userContextIndex, 2, "--user-context-stdin"); } const workbenchInput = input ?? userContext; - const execution = execFileAsync(pythonCommand, [workbenchScriptPath(), ...workbenchArgs], { + const pythonArgs = selectFinalization + ? ["-c", "import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](select_finalization=True)", workbenchScriptPath(), ...workbenchArgs] + : [workbenchScriptPath(), ...workbenchArgs]; + const execution = execFileAsync(pythonCommand, pythonArgs, { cwd: PLUGIN_ROOT, env: stateDir ? { ...process.env, CODEX_SECURITY_STATE_DIR: stateDir } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index e14f11df97..864131c2d9 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -1,3 +1,4 @@ +import { publishSelectedDeepScan } from "./finalization.js"; import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import { basename, dirname, join } from "node:path"; @@ -152,7 +153,7 @@ export class DeepScanCoordinator { if (this.started) return; this.started = true; this.log({ event: "coordinator_started", scanId: this.state.scanId }); - this.scheduleDiscoveryDeadline(); + if (!this.state.finalizationInput) this.scheduleDiscoveryDeadline(); this.scheduleHeartbeat(); void this.run().catch((error: unknown) => { this.log({ @@ -265,10 +266,27 @@ export class DeepScanCoordinator { await ensureDeepScanDirectories(this.artifacts); if (this.canceled || this.externallyFailed) return; + if (this.state.finalizationInput) { + this.phase = "terminal"; + await this.completeSelectedFinalization(); + return; + } this.phase = "discovery"; const schedulerResult = await this.runScheduler(); if (this.canceled || this.externallyFailed) return; this.phase = "terminal"; + if (this.state.workflowVersion === "deep-security-scan/v2") { + if (!this.options.store.selectFinalization) throw new Error("The Deep Scan store cannot select finalization input."); + this.state = await this.options.store.selectFinalization({ + scanId: this.state.scanId, + reason: schedulerResult.reason, + manifestPath: join(this.state.scanDir, "scan-manifest.json"), + resultPath: schedulerResult.resultPath, + omittedWorkerIds: schedulerResult.omittedWorkerIds, + }); + await this.completeSelectedFinalization(); + return; + } const draft = schedulerResult.result ? deepReductionToScanDraft(schedulerResult.result) : scanDraftInputSchema.parse({ @@ -291,7 +309,12 @@ export class DeepScanCoordinator { resultPath: schedulerResult.resultPath ?? null, }); if (this.canceled || this.externallyFailed) return; - this.state = await this.finishWithReplay(schedulerResult); + this.state = await this.options.store.finish({ + scanId: this.state.scanId, + reason: schedulerResult.reason, + manifestPath: join(this.state.scanDir, "scan-manifest.json"), + omittedWorkerIds: schedulerResult.omittedWorkerIds, + }); if (this.canceled || this.externallyFailed) return; this.log({ event: "coordinator_terminal", @@ -311,6 +334,12 @@ export class DeepScanCoordinator { await this.settleSchedulerWork(); return; } + if (this.state.finalizationInput) { + // Publication can be retried from the committed input without model work. + this.log({ event: "coordinator_publication_pending", scanId: this.state.scanId, reason: errorKind(error) }); + this.failLocally(error); + return; + } const message = errorMessage(error); const persistedMessage = boundedDeepScanErrorMessage(error); if (this.phase === "setup") { @@ -407,6 +436,17 @@ export class DeepScanCoordinator { } } + private async completeSelectedFinalization(): Promise { + this.state = await publishSelectedDeepScan({ + run: this.state, + artifacts: this.artifacts, + signal: this.publicationAbortController.signal, + publish: async (...args) => { await this.options.onComplete?.(...args); }, + finish: (input) => this.options.store.finish(input), + }); + this.finishLocally(this.state); + } + private finishLocally(state: DeepScanRunState): void { if (this.terminal) return; this.terminal = true; @@ -530,7 +570,12 @@ export class DeepScanCoordinator { && this.state.coordinatorGeneration !== undefined && current.coordinatorGeneration > this.state.coordinatorGeneration ); - if (current.status === "running" && !replacementConfirmed) return false; + if (current.status === "running" && !replacementConfirmed) { + // A selection response can be lost after its transaction commits. + if (current.finalizationInput) this.state = { ...this.state, + finalizationInput: current.finalizationInput, terminalReason: current.terminalReason }; + return false; + } this.externallyFailed = true; this.abortController.abort("deep_scan_coordinator_lease_lost"); @@ -1039,36 +1084,7 @@ export class DeepScanCoordinator { }); } - /** - * A workbench process can commit SQLite and still lose its stdout response. - * Replay the exact idempotent finish once before treating the run as failed; - * otherwise we could overwrite a successful terminal state after durable success. - */ - private async finishWithReplay(result: SchedulerResult): Promise { - const input = { - scanId: this.state.scanId, - reason: result.reason, - manifestPath: join(this.state.scanDir, "scan-manifest.json"), - omittedWorkerIds: result.omittedWorkerIds - }; - try { - return await this.options.store.finish(input); - } catch (firstError) { - this.log({ - event: "coordinator_finish_replay", - scanId: this.state.scanId, - reason: errorKind(firstError) - }); - try { - return await this.options.store.finish(input); - } catch (replayError) { - throw new Error( - `Deep Scan terminal persistence replay failed: ${errorMessage(replayError)}`, - { cause: firstError } - ); - } - } - } + } const systemClock: DeepScanClock = { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts b/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts new file mode 100644 index 0000000000..8e2fb6f4b6 --- /dev/null +++ b/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts @@ -0,0 +1,183 @@ +import { + deepReductionToScanDraft, + parseDeepReduction, +} from "./artifact-validation.js"; +import { + createScanArtifactContext, + type RunArtifactWorkbench, +} from "../artifact-context.js"; +import { + recordCodexSecurityScanDraftViaWorkbench, + type DeepScanPublication, +} from "../artifact-scan-draft.js"; +import { WorkbenchDeepScanStore } from "./store.js"; +import { createDeepScanArtifacts } from "./artifacts.js"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { type ScanDraftInput } from "../artifact-scan-draft.js"; +import { requireRegularFile, type DeepScanArtifacts } from "./artifacts.js"; +import type { + DeepScanFinalizationInput, + DeepScanRunState, + DeepScanStore, +} from "./types.js"; + +export type { DeepScanFinalizationInput } from "./types.js"; + +/** Publish exactly the saved aggregate; the enclosing scan owns public completion. */ +export async function publishSelectedDeepScan(input: { + run: DeepScanRunState; + artifacts: DeepScanArtifacts; + signal: AbortSignal; + publish: ( + draft: ScanDraftInput, + signal: AbortSignal, + publication: DeepScanPublication, + ) => Promise; + finish: DeepScanStore["finish"]; +}): Promise { + const { run, artifacts, signal } = input; + const selection = selectedInput(run); + if (run.status === "succeeded") return run; + if (run.status !== "running") + throw new Error("Stopped Deep Scan finalization cannot become successful."); + signal.throwIfAborted(); + const draft = await readSelectedDeepScanDraft( + artifacts, + run.scanId, + selection, + ); + await input.publish(draft, signal, { + coordinatorGeneration: run.coordinatorGeneration, + resultPath: + selection.resultPath === null + ? null + : join(run.scanDir, selection.resultPath), + }); + signal.throwIfAborted(); + return input.finish({ + scanId: run.scanId, + reason: selection.terminalReason, + manifestPath: join(run.scanDir, "scan-manifest.json"), + omittedWorkerIds: selection.omittedWorkerIds, + }); +} + +/** Recreate the chosen draft without scheduling discovery or reducer work. */ +export async function readSelectedDeepScanDraft( + artifacts: DeepScanArtifacts, + scanId: string, + selection: DeepScanFinalizationInput, +): Promise { + if (selection.version !== 1) + throw new Error("Unsupported Deep Scan finalization input version."); + if (selection.resultPath === null) { + if ( + selection.terminalReason !== "capped" || + selection.resultSha256 !== null + ) { + throw new Error( + "An empty Deep Scan finalization requires the recorded discovery deadline.", + ); + } + return { + scanId, + findings: [], + coverage: { + completeness: "partial", + surfaces: [], + explicitExclusions: [], + deferred: [ + { + reason: + "The configured discovery time limit elapsed before any source review completed.", + }, + ], + }, + }; + } + const resultPath = join(artifacts.scanDir, selection.resultPath); + await requireRegularFile(resultPath, artifacts.scanDir); + const contents = await readFile(resultPath); + if ( + createHash("sha256").update(contents).digest("hex") !== + selection.resultSha256 + ) { + throw new Error( + "The selected Deep Scan finalization input changed after acceptance.", + ); + } + const stored = JSON.parse(contents.toString("utf8")); + const draft = deepReductionToScanDraft(parseDeepReduction(stored, true)); + if (draft.scanId !== scanId || draft.complete === false) { + throw new Error( + "Deep Scan finalization requires the selected complete result for this scan.", + ); + } + return draft; +} + +/** SDK recovery uses the installed plugin's publisher and the original parent scan. */ +export async function resumeSelectedDeepScan(input: { + scanId: string; + threadId: string; + pluginRoot: string; + runWorkbench: RunArtifactWorkbench; + signal: AbortSignal; + handoffClaimToken?: string; +}): Promise { + const store = new WorkbenchDeepScanStore(input.runWorkbench); + const run = await store.get(input.scanId, input.threadId); + selectedInput(run); + try { + await publishSelectedDeepScan({ + run, + artifacts: createDeepScanArtifacts(run.scanDir), + signal: input.signal, + publish: async (draft, signal, publication) => { + const context = await createScanArtifactContext( + input.scanId, + input.runWorkbench, + { + requireRunning: true, + requireClaim: true, + handoffClaimToken: input.handoffClaimToken, + pluginRoot: input.pluginRoot, + }, + ); + await recordCodexSecurityScanDraftViaWorkbench( + context, + draft, + input.runWorkbench, + signal, + publication, + ); + }, + finish: (selection) => + store.finish({ + ...selection, + coordinatorGeneration: run.coordinatorGeneration, + }), + }); + } catch (error) { + // The original coordinator may publish while the SDK recovers its parent turn. + const committed = await store + .get(input.scanId, input.threadId) + .catch(() => null); + if (committed?.status !== "succeeded") throw error; + } +} + +function selectedInput(run: DeepScanRunState): DeepScanFinalizationInput { + const selection = run.finalizationInput; + if (!selection) + throw new Error("Deep Scan has no selected finalization input."); + if ( + run.workflowVersion !== "deep-security-scan/v2" || + selection.version !== 1 + ) { + throw new Error("Unsupported Deep Scan finalization input version."); + } + return selection; +} diff --git a/plugins/codex-security/mcp-app/src/deep-scan/registry.ts b/plugins/codex-security/mcp-app/src/deep-scan/registry.ts index f8127c2f26..3bbac0a2a4 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/registry.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/registry.ts @@ -231,22 +231,25 @@ async function startClaimedCoordinator( run: DeepScanRunState ): Promise { requireSupportedDeepScan(run); - const executor = options.prepareExecutor + const executor = options.prepareExecutor && !run.finalizationInput ? await options.prepareExecutor(run) : options.executor; return registry.start({ ...options, executor, run }); } function requireSupportedDeepScan(run: DeepScanRunState): void { - if (run.finalizationInput !== undefined) { - throw new Error("This executor does not support resuming selected Deep Scan finalization."); + if (run.finalizationInput !== undefined && ( + run.workflowVersion !== "deep-security-scan/v2" || run.finalizationInput.version !== 1 + )) { + throw new Error("This executor does not support this Deep Scan finalization input version."); } // Missing versions are supported for older adapters that did not project them. if ( (run.schemaVersion !== undefined && run.schemaVersion !== 1) || (run.workflowVersion !== undefined && run.workflowVersion !== "deep-security-scan/v1" - && run.workflowVersion !== "deep-scan-mcp/v1") + && run.workflowVersion !== "deep-scan-mcp/v1" + && run.workflowVersion !== "deep-security-scan/v2") ) { throw new Error( "This Deep Scan uses an unsupported workflow or schema version. " diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index 5a71591ed7..a9f87734c1 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -28,7 +28,8 @@ import type { type JsonObject = Record; export type WorkbenchRunner = ( args: string[], - input?: string + input?: string, + selectFinalization?: boolean, ) => Promise; const WORKFLOW_VERSION = "deep-scan-mcp/v1"; @@ -303,8 +304,32 @@ export class WorkbenchDeepScanStore implements DeepScanStore { ], true)); } + async selectFinalization(input: { + scanId: string; + coordinatorGeneration?: number; + reason: DeepScanTerminalReason; + manifestPath: string; + resultPath?: string; + omittedWorkerIds: string[]; + }): Promise { + return parseDeepScan(await this.enqueueWrite([ + "finish-deep-scan", + "--scan-id", + input.scanId, + ...(input.coordinatorGeneration === undefined + ? this.coordinatorLeaseArgs(input.scanId) + : ["--coordinator-generation", String(input.coordinatorGeneration)]), + "--terminal-reason", + input.reason, + "--manifest-path", + input.manifestPath, + ...input.omittedWorkerIds.flatMap((workerId) => ["--omitted-worker-id", workerId]) + ], true, JSON.stringify({ resultPath: input.resultPath ?? null }), true)); + } + async finish(input: { scanId: string; + coordinatorGeneration?: number; reason: DeepScanTerminalReason; manifestPath: string; stagedManifestPath?: string; @@ -314,7 +339,9 @@ export class WorkbenchDeepScanStore implements DeepScanStore { "finish-deep-scan", "--scan-id", input.scanId, - ...this.coordinatorLeaseArgs(input.scanId), + ...(input.coordinatorGeneration === undefined + ? this.coordinatorLeaseArgs(input.scanId) + : ["--coordinator-generation", String(input.coordinatorGeneration)]), "--terminal-reason", input.reason, "--manifest-path", @@ -407,13 +434,14 @@ export class WorkbenchDeepScanStore implements DeepScanStore { private enqueueWrite( args: string[], retryTransientFailure = false, - input?: string + input?: string, + selectFinalization = false, ): Promise { const operation = this.writeTail.then(async () => { try { return retryTransientFailure - ? await this.runIdempotentPersistence(args) - : await this.runWorkbench(args, input); + ? await this.runIdempotentPersistence(args, input, selectFinalization) + : await this.runWorkbench(args, input, selectFinalization); } catch (error) { const scanId = argumentValue(args, "--scan-id"); if (scanId && isStaleCoordinatorGenerationError(error)) { @@ -430,11 +458,11 @@ export class WorkbenchDeepScanStore implements DeepScanStore { } /** Replay only existing, same-identity workbench mutations after transient failures. */ - private async runIdempotentPersistence(args: string[]): Promise { + private async runIdempotentPersistence(args: string[], input?: string, selectFinalization = false): Promise { const startedAt = Date.now(); for (let attempt = 1; attempt <= MAX_IDEMPOTENT_PERSISTENCE_ATTEMPTS; attempt += 1) { try { - return await this.runWorkbench(args); + return await this.runWorkbench(args, input, selectFinalization); } catch (error) { if (!isTransientPersistenceError(error)) { throw error; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/types.ts b/plugins/codex-security/mcp-app/src/deep-scan/types.ts index 52d427159a..34af68ff69 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/types.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/types.ts @@ -167,8 +167,17 @@ export interface DeepScanStore { artifactDir: string; }): Promise; commitDedup(commit: DedupCommit): Promise; + selectFinalization?(input: { + scanId: string; + coordinatorGeneration?: number; + reason: DeepScanTerminalReason; + manifestPath: string; + resultPath?: string; + omittedWorkerIds: string[]; + }): Promise; finish(input: { scanId: string; + coordinatorGeneration?: number; reason: DeepScanTerminalReason; manifestPath: string; stagedManifestPath?: string; diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs index ede7a814a9..9afb20ddfc 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { readFile, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; export async function testDeepScanPublication({ @@ -110,6 +111,19 @@ export async function testDeepScanPublication({ const fixture = await fixtureRun({ workers: 2, subagents: 0, stopAfterNoNew: 2, maxDiscoveryRuns: 6 }); fixture.run.workflowVersion = "deep-security-scan/v2"; const store = new FakeStore(fixture.run); + store.selectFinalization = async (input) => { + const checkpointRoot = path.join(path.dirname(input.resultPath), "checkpoints"); + const [name] = await readdir(checkpointRoot); + const checkpoint = path.join(checkpointRoot, name); + const bytes = await readFile(checkpoint); + store.run.finalizationInput = { + version: 1, resultPath: path.relative(fixture.run.scanDir, checkpoint), + resultSha256: createHash("sha256").update(bytes).digest("hex"), + terminalReason: input.reason, omittedWorkerIds: input.omittedWorkerIds, + selectedAt: "2026-01-01T00:00:00Z", + }; + return structuredClone(store.run); + }; const executor = new FakeExecutor({ blockDedup: true, blockDiscoveryAfterCalls: 2 }); const updateWorker = store.updateWorker.bind(store); const rejectedCancellations = new Set(); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs index 01c37dbaaa..199685cd03 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs @@ -1362,12 +1362,13 @@ async function testFinishPersistenceFailureRewritesManifestAsFailure() { await assertFailureManifest(terminal, "terminal"); } -async function testLostFinishResponseReplaysWithoutOverwritingSuccessManifest() { +async function testLostFinishResponseObservesCommitWithoutOverwritingSuccessManifest() { const fixture = await fixtureRun({ workers: 1, subagents: 0, stopAfterNoNew: 1, maxDiscoveryRuns: 1 }); const store = new FakeStore(fixture.run); store.loseFirstFinishResponseAfterCommit = true; const coordinator = new DeepScanCoordinator({ run: fixture.run, + threadId: "original-parent-thread", store, executor: new FakeExecutor({ dedupNewFindings: [0] }), pluginRoot: fixture.pluginRoot, @@ -1377,8 +1378,7 @@ async function testLostFinishResponseReplaysWithoutOverwritingSuccessManifest() const terminal = await coordinator.wait(undefined, 5_000); assert.equal(terminal?.status, "succeeded"); - assert.equal(store.finishCalls.length, 2); - assert.deepEqual(store.finishCalls[1], store.finishCalls[0]); + assert.equal(store.finishCalls.length, 1); assert.equal(store.failCalls, 0); const manifest = JSON.parse(await readFile(terminal.manifestPath, "utf8")); assert.equal(manifest.scan.scanId, fixture.run.scanId); @@ -3997,7 +3997,7 @@ try { await testConfigurationFailureDoesNotRetry(); await testFailureManifestWriteDoesNotMaskOriginalError(); await testFinishPersistenceFailureRewritesManifestAsFailure(); - await testLostFinishResponseReplaysWithoutOverwritingSuccessManifest(); + await testLostFinishResponseObservesCommitWithoutOverwritingSuccessManifest(); await testLostWorkerCommitResponsesReplayIdempotently(); await testCommittedReducerIsReconciledBeforeDiscoveryFailureManifest(); await testLongWorkerErrorIsBoundedOnlyAtPersistenceBoundary(); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs new file mode 100644 index 0000000000..8d725ef84d --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + stdin: { + contents: `export * from "./src/deep-scan/finalization.ts"; + export * from "./src/deep-scan/artifacts.ts"; + export * from "./src/artifact-scan-draft.ts";`, + resolveDir: path.resolve(import.meta.dirname, ".."), + }, + bundle: true, format: "esm", platform: "node", write: false, + footer: { js: "//# sourceURL=deep-scan-finalization-contract.js" }, +}); +const { publishSelectedDeepScan, readSelectedDeepScanDraft, createDeepScanArtifacts, saveScanDraftCheckpoint } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`, +); +const scanId = "4e7b4acb-ac80-4d68-98cd-3d5ac5581cd1"; + +for (const terminalReason of ["capped", "saturated"]) { + test(`replays the selected ${terminalReason} input after replaceable results change`, async () => { + const scanDir = await realpath(await mkdtemp(path.join(tmpdir(), "selected-finalization-"))); + try { + const artifacts = createDeepScanArtifacts(scanDir); + const root = path.join(artifacts.dedupRoot, "dedup-0001", "output"); + await mkdir(root, { recursive: true }); + const draft = { + scanId, complete: true, findings: [], + coverage: { + completeness: "partial", surfaces: [], explicitExclusions: [], + deferred: [{ id: "review", reason: "A dependency remains unreviewed." }], + }, + }; + const { coverage, ...reduction } = draft; + await saveScanDraftCheckpoint({ root, repoRoot: scanDir, layout: "reducer" }, { + ...reduction, sourceCoverage: coverage, + }); + const [name] = await readdir(path.join(root, "checkpoints")); + const checkpoint = path.join(root, "checkpoints", name); + const contents = await readFile(checkpoint); + const selection = { + version: 1, + resultPath: path.relative(scanDir, checkpoint), + resultSha256: createHash("sha256").update(contents).digest("hex"), + terminalReason, omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z", + }; + // A replacement result is not a new finalization selection. + await writeFile(path.join(root, "result.json"), JSON.stringify({ ...draft, complete: false })); + assert.deepEqual(await readSelectedDeepScanDraft(artifacts, scanId, selection), draft); + assert.deepEqual(await readSelectedDeepScanDraft(artifacts, scanId, selection), draft); + await assert.rejects( + readSelectedDeepScanDraft(artifacts, "e14e9229-653a-4385-bec0-8745f0b037cb", selection), + /complete result for this scan/, + ); + await writeFile(checkpoint, JSON.stringify({ ...draft, findings: [] })); + await assert.rejects(readSelectedDeepScanDraft(artifacts, scanId, selection), /changed after acceptance/); + } finally { + await rm(scanDir, { recursive: true, force: true }); + } + }); +} + +test("recreates only partial coverage for a persisted zero-success deadline selection", async () => { + const selection = { + version: 1, resultPath: null, resultSha256: null, terminalReason: "capped", + omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z", + }; + const result = await readSelectedDeepScanDraft(createDeepScanArtifacts("unused"), scanId, selection); + assert.equal(result.scanId, scanId); + assert.deepEqual(result.findings, []); + assert.equal(result.coverage.completeness, "partial"); + assert.equal(result.coverage.deferred.length, 1); + await assert.rejects( + readSelectedDeepScanDraft(createDeepScanArtifacts("unused"), scanId, { ...selection, terminalReason: "saturated" }), + /recorded discovery deadline/, + ); +}); + +for (const status of ["failed", "canceled", "interrupted"]) { + test(`saved selection does not turn a ${status} scan into success`, async () => { + await assert.rejects(publishSelectedDeepScan({ + run: { scanId, scanDir: "unused", workflowVersion: "deep-security-scan/v2", status, finalizationInput: { + version: 1, resultPath: null, resultSha256: null, terminalReason: "capped", + omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z", + } }, + artifacts: createDeepScanArtifacts("unused"), signal: new AbortController().signal, + publish: async () => assert.fail("Stopped work cannot publish successful results"), + finish: async () => assert.fail("Stopped work cannot finish successfully"), + }), /Stopped Deep Scan/); + }); +} + +test("cancellation prevents selected publication and preserves its input", async () => { + const controller = new AbortController(); + controller.abort("cost limit or user cancellation"); + const selection = { version: 1, resultPath: null, resultSha256: null, terminalReason: "capped", + omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z" }; + await assert.rejects(publishSelectedDeepScan({ + run: { scanId, scanDir: "unused", workflowVersion: "deep-security-scan/v2", status: "running", finalizationInput: selection }, + artifacts: createDeepScanArtifacts("unused"), signal: controller.signal, + publish: async () => assert.fail("Canceled work cannot publish"), + finish: async () => assert.fail("Canceled work cannot finish"), + }), (error) => error === controller.signal.reason); + assert.equal(selection.terminalReason, "capped"); +}); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_replay.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_replay.mjs new file mode 100644 index 0000000000..f982f021fc --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_replay.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + entryPoints: [path.resolve(import.meta.dirname, "../src/deep-scan/coordinator.ts")], + loader: { ".md": "text" }, + bundle: true, format: "esm", platform: "node", write: false, + footer: { js: "//# sourceURL=deep-scan-selected-replay.js" }, +}); +const { DeepScanCoordinator } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`, +); + +for (const terminalReason of ["saturated", "capped"]) { + test(`restart publishes the selected ${terminalReason} result after an output failure`, async () => { + const scanDir = await realpath(await mkdtemp(path.join(tmpdir(), "selected-replay-"))); + try { + const scanId = "4e7b4acb-ac80-4d68-98cd-3d5ac5581cd1"; + const draft = { + scanId, complete: true, findings: [], + coverage: { completeness: "partial", surfaces: [], explicitExclusions: [], + deferred: [{ id: "review", reason: "A dependency remains unreviewed." }] }, + }; + const { coverage, ...reduction } = draft; + const bytes = JSON.stringify({ ...reduction, sourceCoverage: coverage }); + const digest = createHash("sha256").update(bytes).digest("hex"); + const resultPath = `artifacts/deep_discovery/dedup/dedup-0001/output/checkpoints/${digest}.json`; + await mkdir(path.dirname(path.join(scanDir, resultPath)), { recursive: true }); + await writeFile(path.join(scanDir, resultPath), bytes); + const selection = { version: 1, resultPath, resultSha256: digest, terminalReason, + omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z" }; + let run = { + scanId, scanDir, targetPath: scanDir, scope: ".", workflowVersion: "deep-security-scan/v2", + status: "running", phase: "terminal", coordinatorGeneration: 3, + finalizationInput: selection, terminalReason, createdAt: "2026-01-01T00:00:00Z", + config: { workers: 2, subagents: 0, stopAfterNoNew: 2, stopAfterConsecutiveErrors: 2, + maxDiscoveryRuns: 4, maxTimeHours: 1 }, + dispatchedCount: 4, noNewStreak: 2, consecutiveErrors: 0, + }; + const mutations = []; + const store = new Proxy({ + get: async () => structuredClone(run), + finish: async (input) => { + mutations.push("finish"); + assert.equal(input.reason, terminalReason); + assert.deepEqual(input.omittedWorkerIds, selection.omittedWorkerIds); + run = { ...run, status: "succeeded", manifestPath: input.manifestPath }; + return structuredClone(run); + }, + fail: async () => { mutations.push("fail"); run = { ...run, status: "failed" }; return run; }, + }, { get: (target, key) => key in target ? target[key] : async () => { + mutations.push(key); throw new Error(`Unexpected scheduler operation: ${String(key)}`); + } }); + let executions = 0; + let publications = 0; + const options = { + store, executor: { run: async () => { executions++; throw new Error("Unexpected model work"); } }, + pluginRoot: scanDir, threadId: "original-result-conversation", retryDelaysMs: [], + // Already expired: replay must not start a discovery deadline timer. + discoveryTimeoutMs: 1, + onComplete: async (actual, _signal, publication) => { + publications++; + assert.deepEqual(actual, draft); + assert.equal(publication.coordinatorGeneration, 3); + assert.equal(publication.resultPath, path.join(scanDir, resultPath)); + if (publications === 1) throw new Error("Synthetic publication write failure"); + }, + }; + const first = new DeepScanCoordinator({ ...options, run: structuredClone(run) }); + first.start(); + await assert.rejects(first.wait(), /Synthetic publication write failure/); + assert.equal(run.status, "running"); + assert.equal(run.terminalReason, terminalReason); + assert.deepEqual(run.finalizationInput, selection); + const restarted = new DeepScanCoordinator({ ...options, run: structuredClone(run) }); + restarted.start(); + const completed = await restarted.wait(); + assert.equal(completed.status, "succeeded"); + assert.equal(completed.terminalReason, terminalReason); + assert.equal(executions, 0); + assert.deepEqual(mutations, ["finish"]); + assert.equal(publications, 2); + } finally { + await rm(scanDir, { recursive: true, force: true }); + } + }); +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_selection_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_selection_store.mjs new file mode 100644 index 0000000000..4f93002331 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_selection_store.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + entryPoints: [path.resolve(import.meta.dirname, "../src/deep-scan/store.ts")], + bundle: true, format: "esm", platform: "node", write: false, + footer: { js: "//# sourceURL=deep-scan-selection-store.js" }, +}); +const { WorkbenchDeepScanStore } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`); + +test("selection uses the dedicated function bridge and the store's existing replay policy", async () => { + const scanId = "ed8ff2da-01d9-4338-aeba-8bcfd4b530a9"; + const selection = { + version: 1, resultPath: "artifacts/merge/checkpoints/aggregate.json", resultSha256: "a".repeat(64), + terminalReason: "saturated", omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z", + }; + const response = { deepScan: { + scanId, targetPath: "/target", scope: ".", scanDir: "/scan", status: "running", + schemaVersion: 1, workflowVersion: "deep-security-scan/v2", coordinatorGeneration: 2, + config: { workers: 2, subagents: 0, stopAfterNoNew: 2, stopAfterConsecutiveErrors: 2, maxDiscoveryRuns: 4 }, + dispatchedCount: 2, noNewStreak: 2, consecutiveErrors: 0, finalizationInput: selection, + } }; + const calls = []; + const store = new WorkbenchDeepScanStore(async (...args) => { + calls.push(structuredClone(args)); + if (calls.length === 1) throw Object.assign(new Error("Synthetic lost selection response"), { code: "ETIMEDOUT" }); + return response; + }); + const result = await store.selectFinalization({ + scanId, coordinatorGeneration: 2, reason: "saturated", manifestPath: "/scan/scan-manifest.json", + resultPath: "/scan/artifacts/merge/checkpoints/aggregate.json", omittedWorkerIds: [], + }); + assert.deepEqual(result.finalizationInput, selection); + assert.equal(calls.length, 2); + assert.deepEqual(calls[1], calls[0]); + assert.deepEqual(calls[0], [[ + "finish-deep-scan", "--scan-id", scanId, "--coordinator-generation", "2", + "--terminal-reason", "saturated", "--manifest-path", "/scan/scan-manifest.json", + ], JSON.stringify({ resultPath: "/scan/artifacts/merge/checkpoints/aggregate.json" }), true]); +}); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.mjs index 589f7874fa..430e716009 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.mjs @@ -321,6 +321,7 @@ async function testReducerCommitAndFinishAgainstRealWorkbench() { CODEX_SECURITY_STATE_DIR: stateDir }; const python = process.env.PYTHON?.trim() || "python3"; + const finishCalls = []; const runWorkbench = async (args) => { const { stdout } = await execFileAsync(python, [workbenchPath, ...args], { cwd: pluginRoot, @@ -328,6 +329,12 @@ async function testReducerCommitAndFinishAgainstRealWorkbench() { maxBuffer: 4 * 1024 * 1024, timeout: 30_000 }); + if (args[0] === "finish-deep-scan") { + finishCalls.push([...args]); + if (finishCalls.length === 1) { + throw Object.assign(new Error("Synthetic lost committed finish response"), { code: "ETIMEDOUT" }); + } + } return JSON.parse(stdout); }; const store = new WorkbenchDeepScanStore(runWorkbench); @@ -508,6 +515,8 @@ async function testReducerCommitAndFinishAgainstRealWorkbench() { omittedWorkerIds: [late.id] }); assert.equal(finished.status, "succeeded"); + assert.equal(finishCalls.length, 2); + assert.deepEqual(finishCalls[1], finishCalls[0]); assert.equal(finished.terminalReason, "saturated"); assert.equal(finished.manifestPath, manifestPath); diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index bbd8fd16aa..5f035154ca 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -1767,7 +1767,17 @@ def commit_deep_scan_dedup_locked( return deep_scan_result(connection, scan_id) -def finish_deep_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: +def finish_deep_scan( + connection: sqlite3.Connection, args: argparse.Namespace, select_finalization: bool = False +) -> dict[str, Any]: + if select_finalization: + import sys + + args = argparse.Namespace( + **vars(args), + select_finalization=True, + finalization_result_path=json.load(sys.stdin)["resultPath"], + ) scan_id = require_uuid(args.scan_id, "scan-id") with scan_completion_lock(scan_id): return finish_deep_scan_locked(connection, args, scan_id) @@ -1781,15 +1791,26 @@ def finish_deep_scan_locked( ] if len(set(omitted_worker_ids)) != len(omitted_worker_ids): raise SystemExit("Omitted Deep Scan worker IDs must be unique.") + selecting = getattr(args, "select_finalization", False) promotion: tuple[Path, Path, Path | None] | None = None connection.execute("BEGIN IMMEDIATE") try: run = require_deep_scan_run(connection, scan_id) require_current_coordinator(run, args) scan = require_scan(connection, scan_id) + if selecting and run["workflow_version"] != "deep-security-scan/v2": + raise SystemExit("Selected finalization requires the supported v2 workflow.") + finalization = deep_scan_finalization_input(run) + if finalization is not None and ( + args.terminal_reason != finalization["terminalReason"] + or omitted_worker_ids != finalization["omittedWorkerIds"] + ): + raise SystemExit( + "Deep Scan finalization must retain its selected reason and omissions." + ) manifest_path = ( deep_scan_output_path(scan, args.manifest_path, "Deep Scan coordinator manifest path") - if args.staged_manifest_path + if args.staged_manifest_path or selecting else deep_scan_path( scan, args.manifest_path, "Deep Scan coordinator manifest path", kind="file" ) @@ -1799,6 +1820,7 @@ def finish_deep_scan_locked( failure_capped = False if ( standard_scan_manifest + and not selecting and args.terminal_reason == "capped" and (run["status"] == "running" or omitted_worker_ids) ): @@ -1905,7 +1927,10 @@ def finish_deep_scan_locked( "Deep Scan cannot finish capped before reaching its configured maximum." ) canonical_artifacts = None - if standard_scan_manifest: + if selecting: + if not standard_scan_manifest: + raise SystemExit("Selected Deep Scan finalization requires the parent manifest.") + elif standard_scan_manifest: for artifact_name in ("scan-manifest.json", "findings.json", "coverage.json"): deep_scan_path( scan, @@ -2009,6 +2034,17 @@ def finish_deep_scan_locked( f"Deep Scan {args.terminal_reason} completion must exactly identify all buffered discovery " "workers with --omitted-worker-id." ) + if selecting: + selection = selected_deep_scan_finalization( + connection, run, scan, args, omitted_worker_ids, zero_discovery_deadline + ) + connection.execute( + "UPDATE deep_scan_runs SET finalization_input_json = ?, terminal_reason = ?, " + "phase = 'terminal', updated_at = ? WHERE scan_id = ?", + (json.dumps(selection), selection["terminalReason"], now(), scan_id), + ) + connection.commit() + return deep_scan_result(connection, scan_id) if args.staged_manifest_path: staged_manifest_path = deep_scan_path( scan, @@ -2039,6 +2075,60 @@ def finish_deep_scan_locked( return deep_scan_result(connection, scan_id) +def selected_deep_scan_finalization( + connection: sqlite3.Connection, + run: sqlite3.Row, + scan: sqlite3.Row, + args: argparse.Namespace, + omitted_worker_ids: list[str], + zero_discovery_deadline: bool, +) -> dict[str, Any]: + """Select the committed attempt's immutable aggregate before publication.""" + if run["finalization_input_json"] is not None: + return json.loads(run["finalization_input_json"]) + result_path = getattr(args, "finalization_result_path", None) + relative: str | None = None + digest: str | None = None + if result_path is None: + if not zero_discovery_deadline: + raise SystemExit("Deep Scan finalization requires its accepted reducer result.") + else: + accepted = connection.execute( + "SELECT attempts.accepted_result_path, attempts.accepted_result_sha256 " + "FROM deep_scan_workers AS workers LEFT JOIN deep_scan_attempts AS attempts " + "ON attempts.worker_id = workers.id AND attempts.attempt = workers.attempt " + "WHERE workers.id = (SELECT id FROM deep_scan_workers WHERE scan_id = ? " + "AND kind = 'dedup' AND status = 'succeeded' ORDER BY completed_at DESC, id DESC LIMIT 1) " + "AND (workers.result_manifest_path = ? OR attempts.accepted_result_path = ?)", + (scan["id"], result_path, result_path), + ).fetchone() + if ( + accepted is None + or not accepted["accepted_result_path"] + or not accepted["accepted_result_sha256"] + ): + raise SystemExit( + "Deep Scan finalization requires its committed accepted reducer reference." + ) + scan_dir = Path(scan["scan_dir"]) + source = Path( + deep_scan_path( + scan, accepted["accepted_result_path"], "Selected Deep Scan result", kind="file" + ) + ) + relative = source.relative_to(scan_dir).as_posix() + digest = accepted["accepted_result_sha256"] + selection = { + "version": 1, + "resultPath": relative, + "resultSha256": digest, + "terminalReason": args.terminal_reason, + "omittedWorkerIds": omitted_worker_ids, + "selectedAt": now(), + } + return selection + + def fail_deep_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: scan_id = require_uuid(args.scan_id, "scan-id") with scan_completion_lock(scan_id): diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index b53a506ecf..64b560144b 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -3404,7 +3404,7 @@ def read_json_object(path: Path) -> dict[str, Any]: ) -def main() -> None: +def main(*, select_finalization: bool = False) -> None: # Workbench callers send UTF-8 even when Windows uses a legacy code page. sys.stdin.reconfigure(encoding="utf-8") args = parse_args(__doc__) @@ -3479,7 +3479,7 @@ def main() -> None: elif args.command == "commit-deep-scan-dedup": result = deep_scan.commit_deep_scan_dedup(connection, args) elif args.command == "finish-deep-scan": - result = deep_scan.finish_deep_scan(connection, args) + result = deep_scan.finish_deep_scan(connection, args, select_finalization) elif args.command == "fail-deep-scan": result = deep_scan.fail_deep_scan(connection, args) elif args.command == "record-deep-scan-publication-failure": diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 73d21ba22f..0e4aa1b871 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1540,6 +1540,11 @@ def _require_current_deep_publication( ).fetchall() ) selected_result = reducer["result_manifest_path"] if reducer is not None else None + finalization = db.deep_scan.deep_scan_finalization_input(run) + if finalization is not None: + selected = finalization["resultPath"] + scan = db.require_scan(connection, scan_id) + selected_result = str(Path(scan["scan_dir"]) / selected) if selected is not None else None if publication["resultPath"] != selected_result: raise SystemExit("Deep Scan aggregate belongs to a superseded publication selection.") From fdebdb50d36d6210b3770d833519cda95e717227 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:52:29 +0000 Subject: [PATCH 043/251] fix: complete selected Deep results in the enclosing scan --- .../mcp-app/src/deep-scan/worker-runner.ts | 3 +- .../tests/test_audit_acceptance_contract.mjs | 14 +- sdk/typescript/src/accepted-audit.ts | 55 +--- sdk/typescript/src/api.ts | 304 ++++++++++++------ sdk/typescript/src/deep-scan-finalization.ts | 26 ++ sdk/typescript/tests-ts/api-events.test.ts | 8 +- .../tests-ts/deep-finalization.test.ts | 281 ++++++++++++++++ .../tests-ts/fixtures/selected-deep-scan.py | 66 ++++ 8 files changed, 596 insertions(+), 161 deletions(-) create mode 100644 sdk/typescript/src/deep-scan-finalization.ts create mode 100644 sdk/typescript/tests-ts/deep-finalization.test.ts create mode 100644 sdk/typescript/tests-ts/fixtures/selected-deep-scan.py diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index cac40ad802..96e372760b 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -514,8 +514,7 @@ export class DeepScanWorkerRunner { audit.execution.diagnostics, ); } - if (audit.status === "accepted") result = audit.execution; - else throw audit.error; + result = audit.execution; } else { result = await execute(); if (signal.aborted) return await this.cancelAttempt(input, attempt, activeThreadId); diff --git a/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs index 138fd0176f..8d1e108d1f 100644 --- a/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs +++ b/plugins/codex-security/mcp-app/tests/test_audit_acceptance_contract.mjs @@ -103,19 +103,13 @@ for (const completeness of ["complete", "partial", "unknown"]) { assert.deepEqual(audit.accepted, accepted); assert.deepEqual(audit.checkpoint, accepted); const failure = new Error("Synthetic execution failure"); - const failed = await runAcceptedAudit({ signal: controller.signal, + await assert.rejects(runAcceptedAudit({ signal: controller.signal, execute: async () => { throw failure; }, accept: async () => { assert.fail("An execution failure cannot accept old output"); }, - }); - assert.equal(failed.status, "failed"); - assert.equal(failed.stage, "execution"); - assert.equal(failed.error, failure); - assert.equal(failed.accepted, undefined); - const canceled = await runAcceptedAudit({ signal: controller.signal, execute, + }), (error) => error === failure); + await assert.rejects(runAcceptedAudit({ signal: controller.signal, execute, accept: async () => { const evidence = await accept(); controller.abort("user canceled"); return evidence; }, - }); - assert.equal(canceled.status, "canceled"); - assert.deepEqual(canceled.checkpoint, accepted); + }), (error) => error === controller.signal.reason); assert.equal(manifest.scan.sealedAt, undefined); assert.equal(manifest.scan.artifacts, undefined); assert.equal((await readdir(workerRoot)).includes("scan-manifest.json"), false); diff --git a/sdk/typescript/src/accepted-audit.ts b/sdk/typescript/src/accepted-audit.ts index 6d540361fb..a5a679beaa 100644 --- a/sdk/typescript/src/accepted-audit.ts +++ b/sdk/typescript/src/accepted-audit.ts @@ -1,4 +1,4 @@ -/** Result acceptance is independent of process completion and source coverage. */ +/** Accepted evidence may still describe partial or unknown source coverage. */ export interface AuditEvidence { checkpoint?: Result; accepted?: Result; @@ -8,53 +8,30 @@ export type AuditOutcome = AuditEvidence & ( | { status: "accepted"; execution: Execution; accepted: Result } | { status: "checkpoint"; execution: Execution } - | { - status: "failed" | "canceled"; - execution?: Execution; - stage: "execution" | "acceptance"; - error: unknown; - } ); -/** - * Run one audit attempt under its parent scan. Callers bind the execution and - * artifact adapters; retries, registration, matching and sealing stay outside. - */ +/** One attempt; enclosing callers own retries and public completion. */ export async function runAcceptedAudit(input: { signal: AbortSignal; execute: () => Promise; accept: (execution: Execution) => Promise>; }): Promise> { - let execution: Execution | undefined; - let evidence: AuditEvidence = {}; - let stage: "execution" | "acceptance" = "execution"; - try { - input.signal.throwIfAborted(); - execution = await input.execute(); - input.signal.throwIfAborted(); - stage = "acceptance"; - evidence = await input.accept(execution); - input.signal.throwIfAborted(); - return evidence.accepted === undefined - ? { ...evidence, execution, status: "checkpoint" } - : { - ...evidence, - execution, - accepted: evidence.accepted, - status: "accepted", - }; - } catch (error) { - return { - ...evidence, - execution, - stage, - error, - status: input.signal.aborted ? "canceled" : "failed", - }; - } + input.signal.throwIfAborted(); + const execution = await input.execute(); + input.signal.throwIfAborted(); + const evidence = await input.accept(execution); + input.signal.throwIfAborted(); + return evidence.accepted === undefined + ? { ...evidence, execution, status: "checkpoint" } + : { + ...evidence, + execution, + status: "accepted", + accepted: evidence.accepted, + }; } -/** A completed audit may still report partial or unknown source coverage. */ +/** Process completion alone does not accept an unfinished audit checkpoint. */ export function auditEvidence( checkpoint: Result, ): AuditEvidence { diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 5d0f2e8435..dde6431fa2 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2,8 +2,8 @@ import { scanPreflightCodexConfig } from "./preflight-config.js"; export { scanPreflightCodexConfig } from "./preflight-config.js"; - -import { runAcceptedAudit } from "./accepted-audit.js"; +import { resumeSelectedDeepScan } from "./deep-scan-finalization.js"; +import { auditEvidence, runAcceptedAudit } from "./accepted-audit.js"; import { statSync } from "node:fs"; import { chmod, @@ -1201,6 +1201,7 @@ export class CodexSecurity { let preparedTargetWarnings: string[] = []; let runPostScan: (() => ReturnType) | null = null; + let selectedDeepFinalization = false; let activeScan: { id: string; options: WorkbenchCommandOptions; @@ -1908,12 +1909,53 @@ export class CodexSecurity { if (postScanPrompt?.trim()) { runPostScan = () => thread.runStreamed(postScanPrompt, { signal }); } - const { events } = await thread.runStreamed(prompt, { - signal, - }); + let observedScanThreadId = + typeof resumeThreadId === "string" ? resumeThreadId : undefined; + const recoverSelectedCompletion = async () => { + const threadId = observedScanThreadId ?? thread.id; + if (mode !== "deep" || !threadId || signal.aborted) return null; + const saved = await workbench(workbenchOptions, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + threadId, + ]).catch(() => null); + const deep = saved?.["deepScan"]; + if ( + !isRecord(deep) || + !isRecord(deep["finalizationInput"]) || + (deep["status"] !== "running" && deep["status"] !== "succeeded") + ) + return null; + selectedDeepFinalization = true; + await resumeSelectedDeepScan({ + scanId, + threadId, + pluginRoot: runtime.plugin.installedRoot, + signal, + runWorkbench: (args) => workbench(workbenchOptions, args), + }); + return { + status: "completed" as const, + threadId, + finalResponse: "", + usage: null, + lastStreamError: null, + }; + }; + const savedCompletion = resumeThreadId + ? await recoverSelectedCompletion() + : null; + const events = (async function* () { + if (savedCompletion) return; + yield* (await thread.runStreamed(prompt, { signal })).events; + })(); checkOpen(); const result = await runScanEvents({ + savedCompletion: savedCompletion ?? undefined, + recoverCompletion: recoverSelectedCompletion, thread, events, signal, @@ -1924,6 +1966,7 @@ export class CodexSecurity { workbenchValidated: true, model, onThreadStarted: async (threadId) => { + observedScanThreadId = threadId; if (resumeThreadId !== undefined) { if (threadId !== resumeThreadId) { throw new CodexSecurityError( @@ -1952,6 +1995,7 @@ export class CodexSecurity { } }, onFinalize: async (usage) => { + await recoverSelectedCompletion(); if (options.validationPrompt !== undefined) { tracker.recordUsage(usage); await tracker.refresh().catch(reportTrackingError); @@ -2334,7 +2378,11 @@ export class CodexSecurity { } // A failed attachment must not turn a resumable coordinator into a terminal failure. // Deep Scan orchestration persists its own terminal failures and cancellations. - if (activeScan !== null && options.resumeScanId === undefined) { + if ( + activeScan !== null && + options.resumeScanId === undefined && + !selectedDeepFinalization + ) { if ( options.validationPrompt !== undefined && !customValidationComplete @@ -3600,6 +3648,10 @@ async function removeTargetPathsFile(path: string | null): Promise { } interface ScanEventRunOptions { + savedCompletion?: Awaited>; + recoverCompletion?: () => Promise + > | null>; thread: CodexThreadLike; events: AsyncGenerator; signal: AbortSignal; @@ -3633,90 +3685,98 @@ export async function runScanEvents( let scanStarted = false; let tacStatusReported = false; try { + let completedTurn: + | (Awaited> & { + threadId: string; + status: "completed"; + }) + | undefined; const execute = async () => { - const turn = await readCodexTurn({ - thread: options.thread, - events: options.events, - onEvent: async (event) => { - if (!tacStatusReported) { - const tacStatus = trustedAccessStatusFromEvent(event); - if (tacStatus !== null) { - tacStatusReported = true; - notifyObserver( - "onTrustedAccessStatus", - options.onTrustedAccessStatus, - options.onObserverError, - tacStatus, - ); - if (tacStatus !== "granted") { + const turn = + options.savedCompletion ?? + (await readCodexTurn({ + thread: options.thread, + events: options.events, + onEvent: async (event) => { + if (!tacStatusReported) { + const tacStatus = trustedAccessStatusFromEvent(event); + if (tacStatus !== null) { + tacStatusReported = true; notifyObserver( - "onWarning", - options.onWarning, + "onTrustedAccessStatus", + options.onTrustedAccessStatus, options.onObserverError, - trustedAccessWarning(tacStatus, options.authentication), + tacStatus, ); + if (tacStatus !== "granted") { + notifyObserver( + "onWarning", + options.onWarning, + options.onObserverError, + trustedAccessWarning(tacStatus, options.authentication), + ); + } } } - } - for (const activity of scanActivitiesFromEvent( - event, - options.expectation.repository, - )) { - notifyObserver( - "onActivity", - options.onActivity, - options.onObserverError, - activity, - ); - } - for (const progress of scanProgressUpdatesFromEvent(event)) { - if ( - options.expectedFilesTotal !== undefined && - progress.filesTotal !== options.expectedFilesTotal - ) { - continue; + for (const activity of scanActivitiesFromEvent( + event, + options.expectation.repository, + )) { + notifyObserver( + "onActivity", + options.onActivity, + options.onObserverError, + activity, + ); } - notifyObserver( - "onProgress", - options.onProgress, - options.onObserverError, - progress, - ); - } - const workerStatus = workerStatusFromEvent(event); - if (workerStatus !== null) { - notifyObserver( - "onWorkerStatus", - options.onWorkerStatus, - options.onObserverError, - workerStatus, - ); - } - if (event.type === "thread.started") { - const startedThreadId = event["thread_id"]; - if (typeof startedThreadId === "string") { - await options.onThreadStarted?.(startedThreadId); + for (const progress of scanProgressUpdatesFromEvent(event)) { + if ( + options.expectedFilesTotal !== undefined && + progress.filesTotal !== options.expectedFilesTotal + ) { + continue; + } + notifyObserver( + "onProgress", + options.onProgress, + options.onObserverError, + progress, + ); } - if (!scanStarted) { - scanStarted = true; + const workerStatus = workerStatusFromEvent(event); + if (workerStatus !== null) { notifyObserver( - "onScanStarted", - options.onScanStarted, + "onWorkerStatus", + options.onWorkerStatus, options.onObserverError, + workerStatus, ); } - } - }, - onReconnect: (message, reconnect) => { - notifyObserver( - "onReconnect", - options.onReconnect, - options.onObserverError, - ...reconnect, - reconnectDetails(message), - ); - }, - }); + if (event.type === "thread.started") { + const startedThreadId = event["thread_id"]; + if (typeof startedThreadId === "string") { + await options.onThreadStarted?.(startedThreadId); + } + if (!scanStarted) { + scanStarted = true; + notifyObserver( + "onScanStarted", + options.onScanStarted, + options.onObserverError, + ); + } + } + }, + onReconnect: (message, reconnect) => { + notifyObserver( + "onReconnect", + options.onReconnect, + options.onObserverError, + ...reconnect, + reconnectDetails(message), + ); + }, + })); const { status, threadId, lastStreamError } = turn; if (status !== "completed") { throw new IncompleteScanError( @@ -3729,40 +3789,70 @@ export async function runScanEvents( "Codex Security did not report a thread ID.", ); } - return { ...turn, threadId, status }; + return (completedTurn = { ...turn, threadId, status }); }; - const audit = await runAcceptedAudit({ - signal: options.signal, - execute, - accept: async (turn) => { - const { status, threadId, finalResponse } = turn; - let { usage } = turn; - if (options.onFinalize !== undefined) { - usage = (await options.onFinalize(usage)) ?? usage; - } - const result = await collectResult( - { - status, - finalResponse, - usage, - ...(options.model === undefined ? {} : { model: options.model }), - }, - threadId, - options.scanDir, - options.pluginRoot, - options.expectation, - options.signal, - options.workbenchValidated, - ); - return { checkpoint: result, accepted: result }; - }, - }); - if (audit.status === "accepted") return audit.accepted; + const accept = async () => { + // The plugin's existing writer accepts these semantic documents. Matching, + // custom validation and the canonical seal remain in the enclosing owner. + const [manifest, findings, coverage] = await Promise.all( + ["scan-manifest.json", "findings.json", "coverage.json"].map( + async (name) => + JSON.parse( + ( + await readScanFile(options.scanDir, name, name, options.signal) + ).toString("utf8"), + ), + ), + ); + return auditEvidence({ + complete: manifest.scan.complete, + findings: findings.findings, + coverage, + }); + }; + let audit; + try { + audit = await runAcceptedAudit({ + signal: options.signal, + execute, + accept, + }); + } catch (error) { + if (options.signal.aborted) throw error; + const saved = await options.recoverCompletion?.(); + if (!saved || saved.status !== "completed" || saved.threadId === null) + throw error; + // The enclosing scan publishes its saved selection; acceptance then reads it. + const recovered = completedTurn ?? { ...saved, threadId: saved.threadId }; + audit = await runAcceptedAudit({ + signal: options.signal, + execute: async () => recovered, + accept, + }); + } if (audit.status === "checkpoint") throw new IncompleteScanError( "Codex Security produced only an unfinished audit checkpoint.", ); - throw audit.error; + const { status, threadId, finalResponse } = audit.execution; + let { usage } = audit.execution; + if (options.onFinalize !== undefined) { + usage = (await options.onFinalize(usage)) ?? usage; + } + return await collectResult( + { + status, + finalResponse, + usage, + ...(options.model === undefined ? {} : { model: options.model }), + }, + threadId, + options.scanDir, + options.pluginRoot, + options.expectation, + options.signal, + options.workbenchValidated, + ); } catch (error) { if (options.signal.reason instanceof ScanCostLimitExceededError) { throw options.signal.reason; diff --git a/sdk/typescript/src/deep-scan-finalization.ts b/sdk/typescript/src/deep-scan-finalization.ts new file mode 100644 index 0000000000..e18bf9f583 --- /dev/null +++ b/sdk/typescript/src/deep-scan-finalization.ts @@ -0,0 +1,26 @@ +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +type WorkbenchRunner = (args: string[]) => Promise>; + +/** Load the same publisher used by native Deep from the installed plugin. */ +export async function resumeSelectedDeepScan(input: { + scanId: string; + threadId: string; + pluginRoot: string; + runWorkbench: WorkbenchRunner; + signal: AbortSignal; +}): Promise { + const helper = ( + await import(pathToFileURL(join(input.pluginRoot, "mcp/helpers.mjs")).href) + ).default as { + resumeSelectedDeepScan: (input: { + scanId: string; + threadId: string; + pluginRoot: string; + runWorkbench: WorkbenchRunner; + signal: AbortSignal; + }) => Promise; + }; + await helper.resumeSelectedDeepScan(input); +} diff --git a/sdk/typescript/tests-ts/api-events.test.ts b/sdk/typescript/tests-ts/api-events.test.ts index 28d9b12129..2f7d79c739 100644 --- a/sdk/typescript/tests-ts/api-events.test.ts +++ b/sdk/typescript/tests-ts/api-events.test.ts @@ -1,4 +1,4 @@ -import { mkdir, stat } from "node:fs/promises"; +import { mkdir, rm, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { @@ -477,7 +477,8 @@ describe("one-shot scan events", () => { test("lets the workbench seal artifacts before validating completed scans", async () => { const root = await temporaryDirectory(); - const scanDir = join(root, "scan"); + const scanDir = await copyCompletedScan(root); + await rm(join(scanDir, "report.md")); const events = completedEvents(); let finalized = false; @@ -506,7 +507,8 @@ describe("one-shot scan events", () => { cache_write_input_tokens: 0, output_tokens: 3, }); - expect(existsSync(join(scanDir, "scan-manifest.json"))).toBe(false); + expect(existsSync(join(scanDir, "scan-manifest.json"))).toBe(true); + expect(existsSync(join(scanDir, "report.md"))).toBe(false); await copyCompletedScan(root); finalized = true; }, diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts new file mode 100644 index 0000000000..890c9857de --- /dev/null +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -0,0 +1,281 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, expect, test } from "bun:test"; +import type { ThreadEvent } from "@openai/codex-sdk"; +import { runWorkbench, type WorkbenchCommandOptions } from "../src/runtime.js"; +import { TestClient } from "./support/api-client.js"; +import { + completedEvents, + createApiTestFixtures, + preparedRuntime, +} from "./support/api-events.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +const { temporaryDirectory, cleanup } = createApiTestFixtures(); +afterEach(cleanup); +const threadId = "1af317a1-c9ed-4c73-b428-cb0d160cf8e8"; +const followUp = "Explain the selected finding."; + +for (const outcome of ["failed", "completed", "restart"] as const) { + const restart = outcome === "restart"; + test(`SDK completes a selected aggregate ${restart ? "after restart" : `after the parent turn ${outcome}`}`, async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const scanDir = join(root, "scan"); + const codexHome = join(root, "codex-home"); + const stateDir = join(root, "state"); + await Promise.all([ + mkdir(repository), + mkdir(scanDir, { mode: 0o700 }), + mkdir(codexHome), + ]); + await writeFile(join(repository, "extract.py"), "# Synthetic source\n"); + const environment = { + ...process.env, + CODEX_HOME: codexHome, + CODEX_SECURITY_STATE_DIR: stateDir, + }; + let scanId = ""; + let workbenchOptions: WorkbenchCommandOptions; + let publicationFails = restart; + const modelInputs: string[] = []; + const commands: string[] = []; + const makeClient = () => + new TestClient( + {}, + { + environment, + prepareRuntime: async () => { + const runtime = preparedRuntime(codexHome); + const manifest = JSON.parse( + await readFile( + join(PLUGIN_ROOT, ".codex-plugin/plugin.json"), + "utf8", + ), + ); + return { + ...runtime, + environment, + persistentCredentialHome: true, + plugin: { ...runtime.plugin, version: manifest.version }, + }; + }, + resolvePluginPython: async () => "python3", + prepareOutputDir: async () => scanDir, + runWorkbench: async (options, args, input) => { + workbenchOptions = options; + commands.push(args[0]!); + if (args[0] === "write-scan-draft" && publicationFails) { + publicationFails = false; + throw new Error("Synthetic publication write failure"); + } + const result = await runWorkbench(options, args, input); + if (args[0] === "register-cli-scan") + scanId = result["scanId"] as string; + return result; + }, + createCodex: () => { + const thread = { + id: threadId, + async runStreamed(input: string) { + modelInputs.push(input); + if (input === followUp) + return { events: completedEvents(threadId) }; + expect(modelInputs.length).toBe(1); + async function* events(): AsyncGenerator { + yield { type: "thread.started", thread_id: threadId }; + await runWorkbench(workbenchOptions, [ + "begin-deep-scan", + "--scan-id", + scanId, + "--thread-id", + threadId, + ]); + const draft = { + scanId, + complete: true, + findings: [ + { + ruleId: "path-traversal.archive", + title: "Unsafe archive extraction", + summary: + "An untrusted entry reaches a filesystem write.", + severity: { level: "high" }, + confidence: { + level: "high", + rationale: "Source evidence.", + }, + taxonomy: { + category: "path-traversal", + cwe: ["CWE-22"], + }, + locations: [{ path: "extract.py", startLine: 1 }], + remediation: + "Validate the resolved destination before writing.", + provenance: { + source: "local_plugin", + candidateId: "archive-entry", + }, + }, + ], + coverage: { + completeness: "partial", + surfaces: [], + explicitExclusions: [], + deferred: [ + { + id: "dependency", + reason: "A dependency remains unreviewed.", + }, + ], + }, + }; + const seeded = JSON.parse( + execFileSync( + "python3", + [ + fileURLToPath( + new URL( + "./fixtures/selected-deep-scan.py", + import.meta.url, + ), + ), + ], + { + input: JSON.stringify({ + scanId, + scanDir, + database: join(stateDir, "workbench.sqlite3"), + draft, + }), + encoding: "utf8", + env: environment, + }, + ), + ); + // Exercise the dedicated function bridge, without extending CLI arguments. + execFileSync( + "python3", + [ + "-c", + "import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](select_finalization=True)", + join(PLUGIN_ROOT, "scripts/workbench_db.py"), + "finish-deep-scan", + "--scan-id", + scanId, + "--coordinator-generation", + "2", + "--terminal-reason", + "saturated", + "--manifest-path", + join(scanDir, "scan-manifest.json"), + ], + { + input: JSON.stringify({ resultPath: seeded.resultPath }), + encoding: "utf8", + env: environment, + }, + ); + const sessions = join( + codexHome, + "sessions", + "2026", + "01", + "01", + ); + await mkdir(sessions, { recursive: true }); + await writeFile( + join(sessions, `rollout-${threadId}.jsonl`), + JSON.stringify({ + timestamp: new Date().toISOString(), + type: "session_meta", + payload: { id: threadId, cwd: scanDir }, + }) + "\n", + ); + if (outcome === "completed") { + yield { + type: "turn.completed", + usage: { + input_tokens: 0, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + reasoning_output_tokens: 0, + output_tokens: 0, + }, + }; + } else { + throw new Error( + "Parent turn ended before its final completion tool call", + ); + } + } + return { events: events() }; + }, + }; + return { + startThread: () => thread, + resumeThread: (id: string) => { + expect(id).toBe(threadId); + return thread; + }, + }; + }, + }, + ); + let client = makeClient(); + try { + if (restart) { + await expect( + client.run(repository, { mode: "deep", postScanPrompt: followUp }), + ).rejects.toThrow("Synthetic publication write failure"); + const pending = await runWorkbench(workbenchOptions!, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + threadId, + ]); + expect(pending["deepScan"]).toMatchObject({ + status: "running", + terminalReason: "saturated", + }); + expect(commands).not.toContain("fail-scan"); + await client.close(); + client = makeClient(); + } + const result = await client.run(repository, { + mode: "deep", + postScanPrompt: followUp, + ...(restart ? { resumeScanId: scanId, outputDir: scanDir } : {}), + }); + expect(result.threadId).toBe(threadId); + if (outcome === "completed") expect(result.cost?.estimatedUsd).toBe(0); + else expect(result.cost).toBeNull(); + expect(result.coverage.completeness).toBe("partial"); + expect(result.findings.findings[0]?.remediation).toBe( + "Validate the resolved destination before writing.", + ); + // postScanPrompt retains its existing behavior on each caller invocation. + expect(modelInputs.filter((input) => input !== followUp).length).toBe(1); + expect(modelInputs.filter((input) => input === followUp).length).toBe( + restart ? 2 : 1, + ); + const completed = await runWorkbench(workbenchOptions!, [ + "get-scan", + "--scan-id", + scanId, + ]); + expect(completed["scan"]).toMatchObject({ + progress: { status: "complete" }, + }); + expect(await readFile(join(scanDir, "report.md"), "utf8")).toContain( + "Validate the resolved destination", + ); + expect(commands).not.toContain("fail-scan"); + } finally { + await client.close(); + } + }, 30_000); +} diff --git a/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py b/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py new file mode 100644 index 0000000000..0a7759b216 --- /dev/null +++ b/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py @@ -0,0 +1,66 @@ +"""Synthetic accepted workers for the installed finalization/SDK contract test.""" +import hashlib +import json +import sqlite3 +import sys +import uuid +from pathlib import Path + +payload = json.load(sys.stdin) +scan_id = payload["scanId"] +scan_dir = Path(payload["scanDir"]) +draft = dict(payload["draft"]) +draft["sourceCoverage"] = draft.pop("coverage") +encoded = json.dumps(draft).encode() +digest = hashlib.sha256(encoded).hexdigest() +with sqlite3.connect(payload["database"]) as connection: + connection.execute("PRAGMA foreign_keys = ON") + timestamp = connection.execute( + "SELECT created_at FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) + ).fetchone()[0] + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "coordinator_generation = 2, phase = 'reducing', " + "discovery_runs_dispatched = 2, completion_sequence = 2, " + "consecutive_no_new = 2, stop_after_no_new = 2, max_discovery_runs = 2 " + "WHERE scan_id = ?", (scan_id,), + ) + discoveries = [] + for kind, label in [("discovery", "review-1"), ("discovery", "review-2"), ("dedup", "merge-1")]: + worker_id = str(uuid.uuid4()) + output = scan_dir / "artifacts" / "deep_discovery" / label / "output" + output.mkdir(parents=True) + prompt = output.parent / "prompt.md" + prompt.write_text("Synthetic accepted audit\n") + accepted = output / "checkpoints" / f"{digest}.json" + accepted.parent.mkdir() + accepted.write_bytes(encoded) + result = output / "result.json" + result.write_bytes(encoded) + sequence = len(discoveries) + 1 if kind == "discovery" else None + connection.execute( + "INSERT INTO deep_scan_workers " + "(id, scan_id, kind, status, merge_state, prompt_path, artifact_dir, " + "result_manifest_path, attempt, completion_sequence, created_at, updated_at, completed_at) " + "VALUES (?, ?, ?, 'succeeded', ?, ?, ?, ?, 1, ?, ?, ?, ?)", + (worker_id, scan_id, kind, "merged" if kind == "discovery" else "none", + str(prompt), str(output), str(result), sequence, timestamp, timestamp, timestamp), + ) + connection.execute( + "INSERT INTO deep_scan_attempts " + "(scan_id, worker_id, attempt, status, started_at, completed_at, accepted_result_path, accepted_result_sha256) " + "VALUES (?, ?, 1, 'succeeded', ?, ?, ?, ?)", + (scan_id, worker_id, timestamp, timestamp, str(accepted), digest), + ) + if kind == "discovery": + discoveries.append(worker_id) + else: + for order, discovery in enumerate(discoveries): + connection.execute( + "INSERT INTO deep_scan_dedup_inputs " + "(scan_id, dedup_worker_id, discovery_worker_id, input_order) VALUES (?, ?, ?, ?)", + (scan_id, worker_id, discovery, order), + ) + # The committed immutable reference survives loss of the replaceable output. + result.unlink() +print(json.dumps({"resultPath": str(result), "acceptedPath": str(accepted)})) From 538ad5c044cb1526761ef276300e87fc837e9771 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:15:53 +0000 Subject: [PATCH 044/251] test: cover original native selections and packaged completion helper --- .../mcp-app/tests/test_deep_scan_executor.mjs | 13 ++++++++++--- sdk/typescript/scripts/check-package.mjs | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index ac7db4fff0..c3b8cf0dd5 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -759,7 +759,7 @@ async function testIsolatedReconstructedWorkers() { model_reasoning_summary: name === "first" ? "none" : "concise", service_tier: name === "first" ? "flex" : "fast" }; - await writeFile(configPath, Object.entries(config).map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); + await writeFile(configPath, Object.entries(config).filter(([key]) => name !== "first" || key !== "model_provider").map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); await copyFile(process.execPath, executable); @@ -782,8 +782,13 @@ async function testIsolatedReconstructedWorkers() { reasoningEffort: "ultra", parentSandbox: trustedParentSandboxWithDenials }; + await mkdir(path.join(codexHome, "sessions")); + await writeFile(path.join(codexHome, "sessions", "owner.jsonl"), JSON.stringify({ + type: "session_meta", timestamp: "2026-01-01T00:00:00Z", + payload: { id: `fixture-${name}-owner`, model_provider: config.model_provider } + }) + "\n"); const saved = await loadOrCaptureDeepScanExecutionSettings(fixture.root, () => - captureDeepScanExecutionSettings(settings, settings.parentSandbox, { ...codexOptions.env, CODEX_CLI_PATH: executable })); + captureDeepScanExecutionSettings(settings, settings.parentSandbox, { ...codexOptions.env, CODEX_CLI_PATH: executable }, { threadId: `fixture-${name}-owner`, startedAt: "2026-01-01T00:01:00Z" })); const snapshotPath = path.join(fixture.root, "artifacts", "deep_discovery", "execution-settings.json"); const snapshot = await readFile(snapshotPath, "utf8"); assert.equal(snapshot.includes("synthetic-"), false); @@ -817,6 +822,7 @@ async function testIsolatedReconstructedWorkers() { scan.runtimeEnvironment.CODEX_API_KEY = `synthetic-${scan.name}-${phase}`; scan.runtimeEnvironment.FAKE_CODEX_SCAN_VALUE = `${scan.name}-${phase}`; scan.runtimeEnvironment.CODEX_HOME = path.join(scan.fixture.root, "observer-home"); + scan.runtimeEnvironment.CODEX_CLI_PATH = path.join(scan.fixture.root, "observer-codex"); } for (const kind of ["discovery", "dedup"]) { await Promise.all(scans.map(async (scan) => { @@ -831,6 +837,7 @@ async function testIsolatedReconstructedWorkers() { const child = JSON.parse(await readFile(scan.fixture.markerPath, "utf8")); const preflight = JSON.parse(await readFile(scan.fixture.preflightMarkerPath, "utf8")); assert.equal(child.executable, scan.settings.codexOptions.codexPathOverride); + assert.equal(child.codexCliPath, scan.settings.codexOptions.codexPathOverride); assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); assert.equal(preflight.codexHome, child.codexHome); assert.equal(child.scanValue, `${scan.name}-${phase}`); @@ -1771,7 +1778,7 @@ async function fakeCodexFixture( "for await (const chunk of process.stdin) stdin += chunk;", "const openaiAuthentication = stdin.includes('CAPTURE_SYNTHETIC_OPENAI_AUTH') ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY } : undefined;", "const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;", - "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", + "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, codexCliPath: process.env.CODEX_CLI_PATH, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", "if (stdin.includes('COMPLETE_THEN_HANG')) process.on('SIGTERM', () => setTimeout(() => process.exit(0), 100));", "if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); }", "if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); }", diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 12ce6dffb3..a751d59deb 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -186,6 +186,7 @@ const distFiles = new Set( "cost", "cost-model", "custom-validation", + "deep-scan-finalization", "custom-validation-prompt", "custom-publish", "deep-progress", From 578b0fd44bdfbc86ba47d19926b4aa22e5972776 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:08:11 +0000 Subject: [PATCH 045/251] fix: preserve explicit cancellation during selected publication --- sdk/typescript/src/api.ts | 31 ++++++- .../tests-ts/deep-finalization.test.ts | 82 +++++++++++++++++-- .../tests-ts/fixtures/selected-deep-scan.py | 9 +- 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index dde6431fa2..0688f683cd 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1202,9 +1202,11 @@ export class CodexSecurity { let runPostScan: (() => ReturnType) | null = null; let selectedDeepFinalization = false; + let observedScanThreadId: string | undefined; let activeScan: { id: string; options: WorkbenchCommandOptions; + mode: ScanMode; } | null = null; const prepareArtifactRestorer = this.#dependencies.prepareScanArtifactRestorer ?? @@ -1711,7 +1713,7 @@ export class CodexSecurity { }, ); } - activeScan = { id: scanId, options: workbenchOptions }; + activeScan = { id: scanId, options: workbenchOptions, mode }; if (mode === "deep" && options.onDeepProgress !== undefined) { let progressWarningReported = false; deepProgressTracker = new DeepScanProgressTracker({ @@ -1909,7 +1911,7 @@ export class CodexSecurity { if (postScanPrompt?.trim()) { runPostScan = () => thread.runStreamed(postScanPrompt, { signal }); } - let observedScanThreadId = + observedScanThreadId = typeof resumeThreadId === "string" ? resumeThreadId : undefined; const recoverSelectedCompletion = async () => { const threadId = observedScanThreadId ?? thread.id; @@ -2376,6 +2378,31 @@ export class CodexSecurity { return result; } catch {} } + if ( + activeScan?.mode === "deep" && + options.signal?.aborted && + observedScanThreadId + ) { + const workbenchOptions = { ...activeScan.options, signal: undefined }; + const saved = await workbench(workbenchOptions, [ + "get-deep-scan", + "--scan-id", + activeScan.id, + "--thread-id", + observedScanThreadId, + ]).catch(() => null); + const deep = saved?.["deepScan"]; + if (isRecord(deep) && isRecord(deep["finalizationInput"])) { + selectedDeepFinalization = true; + await workbench(workbenchOptions, [ + "cancel-scan", + "--scan-id", + activeScan.id, + "--thread-id", + observedScanThreadId, + ]); + } + } // A failed attachment must not turn a resumable coordinator into a terminal failure. // Deep Scan orchestration persists its own terminal failures and cancellations. if ( diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 890c9857de..b4b35c6567 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -18,7 +18,14 @@ afterEach(cleanup); const threadId = "1af317a1-c9ed-4c73-b428-cb0d160cf8e8"; const followUp = "Explain the selected finding."; -for (const outcome of ["failed", "completed", "restart"] as const) { +for (const outcome of [ + "failed", + "completed", + "restart", + "canceled-before-publication", + "canceled-during-publication", + "published-before-cancellation", +] as const) { const restart = outcome === "restart"; test(`SDK completes a selected aggregate ${restart ? "after restart" : `after the parent turn ${outcome}`}`, async () => { const root = await temporaryDirectory(); @@ -37,6 +44,7 @@ for (const outcome of ["failed", "completed", "restart"] as const) { CODEX_HOME: codexHome, CODEX_SECURITY_STATE_DIR: stateDir, }; + const cancellation = new AbortController(); let scanId = ""; let workbenchOptions: WorkbenchCommandOptions; let publicationFails = restart; @@ -72,6 +80,20 @@ for (const outcome of ["failed", "completed", "restart"] as const) { throw new Error("Synthetic publication write failure"); } const result = await runWorkbench(options, args, input); + if ( + args[0] === "write-scan-draft" && + outcome === "canceled-during-publication" + ) { + cancellation.abort("Synthetic user cancellation"); + } + if ( + args[0] === "complete-scan" && + outcome === "published-before-cancellation" + ) { + cancellation.abort( + "Synthetic user cancellation after completion", + ); + } if (args[0] === "register-cli-scan") scanId = result["scanId"] as string; return result; @@ -194,6 +216,8 @@ for (const outcome of ["failed", "completed", "restart"] as const) { payload: { id: threadId, cwd: scanDir }, }) + "\n", ); + if (outcome === "canceled-before-publication") + cancellation.abort("Synthetic user cancellation"); if (outcome === "completed") { yield { type: "turn.completed", @@ -245,9 +269,52 @@ for (const outcome of ["failed", "completed", "restart"] as const) { await client.close(); client = makeClient(); } + if (outcome.startsWith("canceled-")) { + await expect( + client.run(repository, { mode: "deep", signal: cancellation.signal }), + ).rejects.toThrow(/interrupted/); + const stopped = await runWorkbench( + { ...workbenchOptions!, signal: undefined }, + ["get-scan", "--scan-id", scanId], + ); + const deep = await runWorkbench( + { ...workbenchOptions!, signal: undefined }, + ["get-deep-scan", "--scan-id", scanId, "--thread-id", threadId], + ); + expect(stopped["scan"]).toMatchObject({ + progress: { status: "canceled" }, + findingCount: 1, + reportAvailable: true, + }); + expect( + JSON.parse(await readFile(join(scanDir, "coverage.json"), "utf8")) + .completeness, + ).toBe("partial"); + expect(await readFile(join(scanDir, "report.md"), "utf8")).toContain( + "Validate the resolved destination", + ); + expect(deep["deepScan"]).toMatchObject({ + status: "canceled", + finalizationInput: { terminalReason: "saturated" }, + }); + expect(commands).toContain("cancel-scan"); + expect(commands).not.toContain("fail-scan"); + expect(modelInputs.length).toBe(1); + await expect( + client.run(repository, { + mode: "deep", + resumeScanId: scanId, + outputDir: scanDir, + }), + ).rejects.toThrow(); + expect(modelInputs.length).toBe(1); + return; + } const result = await client.run(repository, { mode: "deep", - postScanPrompt: followUp, + signal: cancellation.signal, + postScanPrompt: + outcome === "published-before-cancellation" ? undefined : followUp, ...(restart ? { resumeScanId: scanId, outputDir: scanDir } : {}), }); expect(result.threadId).toBe(threadId); @@ -260,13 +327,12 @@ for (const outcome of ["failed", "completed", "restart"] as const) { // postScanPrompt retains its existing behavior on each caller invocation. expect(modelInputs.filter((input) => input !== followUp).length).toBe(1); expect(modelInputs.filter((input) => input === followUp).length).toBe( - restart ? 2 : 1, + outcome === "published-before-cancellation" ? 0 : restart ? 2 : 1, + ); + const completed = await runWorkbench( + { ...workbenchOptions!, signal: undefined }, + ["get-scan", "--scan-id", scanId], ); - const completed = await runWorkbench(workbenchOptions!, [ - "get-scan", - "--scan-id", - scanId, - ]); expect(completed["scan"]).toMatchObject({ progress: { status: "complete" }, }); diff --git a/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py b/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py index 0a7759b216..5b741021ca 100644 --- a/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py +++ b/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py @@ -9,10 +9,6 @@ payload = json.load(sys.stdin) scan_id = payload["scanId"] scan_dir = Path(payload["scanDir"]) -draft = dict(payload["draft"]) -draft["sourceCoverage"] = draft.pop("coverage") -encoded = json.dumps(draft).encode() -digest = hashlib.sha256(encoded).hexdigest() with sqlite3.connect(payload["database"]) as connection: connection.execute("PRAGMA foreign_keys = ON") timestamp = connection.execute( @@ -27,6 +23,11 @@ ) discoveries = [] for kind, label in [("discovery", "review-1"), ("discovery", "review-2"), ("dedup", "merge-1")]: + draft = dict(payload["draft"]) + if kind == "dedup": + draft["sourceCoverage"] = draft.pop("coverage") + encoded = json.dumps(draft).encode() + digest = hashlib.sha256(encoded).hexdigest() worker_id = str(uuid.uuid4()) output = scan_dir / "artifacts" / "deep_discovery" / label / "output" output.mkdir(parents=True) From 77c6cba4b9c5e5e3129506e926f1dd83883cab45 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:05:42 +0000 Subject: [PATCH 046/251] Reconcile scan usage by owned execution and charged response --- sdk/typescript/README.md | 8 + sdk/typescript/src/api.ts | 39 +- sdk/typescript/src/cost-model.ts | 54 ++ sdk/typescript/src/cost.ts | 299 ++++++++- sdk/typescript/src/scan-logs.ts | 49 +- sdk/typescript/src/scan-sessions.ts | 59 ++ sdk/typescript/tests-ts/scan-resume.test.ts | 15 +- .../scan-usage-reconciliation.test.ts | 601 ++++++++++++++++++ 8 files changed, 1097 insertions(+), 27 deletions(-) create mode 100644 sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index bd86a0fc38..98d72cbd07 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -941,6 +941,14 @@ including cache reads and writes. They exclude long-context and other processing tier adjustments, fees, and surcharges. GPT-5.5 and GPT-6 Astra are supported; models without known prices show an unavailable estimate. +Deep Scan accounting includes failed, replaced, and canceled worker attempts and +their descendants once. Shared conversation usage is limited to the original +scan turn and scan interval. Missing usage remains unavailable or partial; +reported zero remains zero. When sessions use different models, `cost.modelCosts` +records each model's tokens, estimate, and pricing basis, and `estimatedUsd` sums +those estimates. A partial estimate has `cost.coverage: "partial"`; missing model +attribution leaves the estimate unavailable until usage can be reconciled. + For compatibility, `cacheWriteInputTokens` remains the reported token subtotal. `cacheWriteInputTokensReported: false` means at least one included usage record did not report cache writes. Raw usage uses `cache_write_input_tokens_reported`. diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 0688f683cd..edcdf27a97 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -74,6 +74,7 @@ import { type ScanCost, type ScanSessionEvent, } from "./cost.js"; +import type { ScanExecutionAttribution } from "./scan-sessions.js"; import { DeepScanProgressTracker, type DeepScanProgress, @@ -1714,6 +1715,22 @@ export class CodexSecurity { ); } activeScan = { id: scanId, options: workbenchOptions, mode }; + if (mode === "deep") { + tracker.setAttributionReader(async () => { + const context = await workbench( + { ...workbenchOptions, signal: undefined }, + ["get-scan", "--scan-id", scanId], + ); + const scan = context["scan"]; + if (isRecord(scan) && !("executionAttribution" in scan)) + return undefined; + return isRecord(scan) && isRecord(scan["executionAttribution"]) + ? (scan[ + "executionAttribution" + ] as unknown as ScanExecutionAttribution) + : null; + }); + } if (mode === "deep" && options.onDeepProgress !== undefined) { let progressWarningReported = false; deepProgressTracker = new DeepScanProgressTracker({ @@ -2063,7 +2080,10 @@ export class CodexSecurity { return { usage, cost: estimateScanCost(model, usage) }; }); throwIfAborted(signal, scanDir); - if (options.maxCostUsd !== undefined && snapshot.cost === null) { + if ( + options.maxCostUsd !== undefined && + (snapshot.cost === null || snapshot.cost.coverage === "partial") + ) { notifyObserver( "onWarning", options.onWarning, @@ -4216,6 +4236,23 @@ function addScanCosts( previous.cacheWriteInputTokens + current.cacheWriteInputTokens, outputTokens: previous.outputTokens + current.outputTokens, estimatedUsd: previous.estimatedUsd + current.estimatedUsd, + ...(previous.coverage === "partial" || current.coverage === "partial" + ? { coverage: "partial" as const } + : {}), + ...(previous.modelCosts || + current.modelCosts || + previous.model !== current.model + ? { + modelCosts: [ + ...(previous.modelCosts ?? [previous]), + ...(current.modelCosts ?? [current]), + ], + } + : {}), + ...(previous.cacheWriteInputTokensReported === false || + current.cacheWriteInputTokensReported === false + ? { cacheWriteInputTokensReported: false } + : {}), }; } diff --git a/sdk/typescript/src/cost-model.ts b/sdk/typescript/src/cost-model.ts index f24dcd4a2c..fe23706829 100644 --- a/sdk/typescript/src/cost-model.ts +++ b/sdk/typescript/src/cost-model.ts @@ -6,6 +6,8 @@ export interface ScanCost { cacheWriteInputTokensReported?: boolean; outputTokens: number; estimatedUsd: number; + coverage?: "partial"; + modelCosts?: readonly ScanCost[]; pricing?: { source: string; asOf: string; @@ -96,6 +98,58 @@ export function tokenUsage(value: unknown): ScanTokenUsage | null { export function estimateScanCost( model: string | undefined, usage: unknown, +): ScanCost | null { + if (isRecord(usage) && Array.isArray(usage["modelUsage"])) { + const total = tokenUsage(usage); + if (total === null || usage["modelUsage"].length === 0) return null; + const costs: ScanCost[] = []; + for (const part of usage["modelUsage"]) { + if (!isRecord(part) || typeof part["model"] !== "string") return null; + const cost = estimateModelCost(part["model"], part); + if (cost === null) return null; + costs.push(cost); + } + const sum = ( + key: + | "inputTokens" + | "cachedInputTokens" + | "cacheWriteInputTokens" + | "outputTokens" + | "estimatedUsd", + ) => costs.reduce((value, cost) => value + cost[key], 0); + if ( + sum("inputTokens") !== total.input_tokens || + sum("cachedInputTokens") !== total.cached_input_tokens || + sum("cacheWriteInputTokens") !== total.cache_write_input_tokens || + sum("outputTokens") !== total.output_tokens + ) + return null; + return { + model: model ?? costs[0]!.model, + inputTokens: total.input_tokens, + cachedInputTokens: total.cached_input_tokens, + cacheWriteInputTokens: total.cache_write_input_tokens, + ...(total.cache_write_input_tokens_reported === false + ? { cacheWriteInputTokensReported: false } + : {}), + outputTokens: total.output_tokens, + estimatedUsd: sum("estimatedUsd"), + modelCosts: costs, + ...(costs.length === 1 ? { pricing: costs[0]!.pricing } : {}), + ...(usage["coverage"] === "partial" + ? { coverage: "partial" as const } + : {}), + }; + } + const cost = estimateModelCost(model, usage); + return cost && isRecord(usage) && usage["coverage"] === "partial" + ? { ...cost, coverage: "partial" } + : cost; +} + +function estimateModelCost( + model: string | undefined, + usage: unknown, ): ScanCost | null { if (model === undefined) return null; const pricingModel = model.startsWith("openai.") diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 2263f136fa..28c13a9abc 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -11,9 +11,12 @@ import { type ScanActivity, } from "./scan-activity.js"; import { + attributedScanThreads, + isAttributedScanEvent, isScanArtifactDirectory, sessionParentThreadId, sessionStartedAt, + type ScanExecutionAttribution, } from "./scan-sessions.js"; import { scanProgressUpdatesFromEvent, @@ -48,6 +51,16 @@ interface SessionUsage { inheritedUsage: ScanTokenUsage | null; replaying: boolean; usage: ScanTokenUsage | null; + counterUsage: ScanTokenUsage | null; + model: string | null; + modelUsage: Map; + currentTurnId: string | null; + previousUsage: ScanTokenUsage | null; + responseIds: Set; + responseUsageObserved: boolean; + responseTokens: number; + expectedResponseTokens: number; + counterRegressed: boolean; calls: Map; activities: ScanActivity[]; progress: ScanProgress[]; @@ -94,6 +107,16 @@ function createSessionUsage(): SessionUsage { inheritedUsage: null, replaying: false, usage: null, + counterUsage: null, + model: null, + modelUsage: new Map(), + currentTurnId: null, + previousUsage: null, + responseIds: new Set(), + responseUsageObserved: false, + responseTokens: 0, + expectedResponseTokens: 0, + counterRegressed: false, calls: new Map(), activities: [], progress: [], @@ -119,6 +142,9 @@ export class ScanCostTracker { #lastCost: string | null = null; #highestFilesCompleted = 0; #expectedFilesTotal: number | undefined; + #attribution: ScanExecutionAttribution | null = null; + #readAttribution: + (() => Promise) | undefined; public constructor(options: ScanCostTrackerOptions) { this.#options = options; @@ -129,10 +155,23 @@ export class ScanCostTracker { this.#expectedFilesTotal = filesTotal; } + public setAttributionReader( + reader: () => Promise, + ): void { + this.#readAttribution = reader; + } + public recordUsage(usage: unknown, threadId = this.#threadId): void { const normalized = tokenUsage(usage); if (threadId !== null) { - this.#receipts.set(threadId, normalized); + const previous = this.#receipts.get(threadId); + if ( + previous == null || + (normalized !== null && + normalized.total_tokens >= previous.total_tokens) + ) { + this.#receipts.set(threadId, normalized); + } } } @@ -189,7 +228,11 @@ export class ScanCostTracker { } if (fallbackUsage !== undefined) this.recordUsage(fallbackUsage); await this.refresh(); - if (this.#receipts.size > 0 || this.#snapshot.usage !== null) + if ( + this.#readAttribution || + this.#receipts.size > 0 || + this.#snapshot.usage !== null + ) return this.#snapshot; const cost = estimateScanCost(this.#options.model, fallbackUsage); this.#snapshot = { usage: fallbackUsage ?? null, cost }; @@ -199,6 +242,19 @@ export class ScanCostTracker { async #readSessions(): Promise { if (this.#threadId === null) return; + if (this.#readAttribution) { + const record = await this.#readAttribution(); + if (record === null) return; + const attribution = record === undefined || record.legacy ? null : record; + if ( + attribution && + (!this.#attribution || + attribution.completedAt !== this.#attribution.completedAt) + ) { + this.#sessions.clear(); + } + this.#attribution = attribution; + } const unreadable: Array<{ session: SessionUsage; error: unknown }> = []; for await (const path of sessionFiles( join(this.#options.codexHome, "sessions"), @@ -209,15 +265,22 @@ export class ScanCostTracker { this.#sessions.set(path, session); } try { - await readSessionUsage(path, session, this.#options.repository); + await readSessionUsage( + path, + session, + this.#options.repository, + this.#attribution, + ); } catch (error) { if (session.threadId === null) throw error; unreadable.push({ session, error }); } } - const included = new Set([this.#threadId, ...this.#receipts.keys()]); - if (this.#options.scanDirectory !== undefined) { + const included = this.#attribution + ? attributedScanThreads(this.#sessions.values(), this.#attribution) + : new Set([this.#threadId, ...this.#receipts.keys()]); + if (!this.#attribution && this.#options.scanDirectory !== undefined) { const scanStartedAt = [...this.#sessions.values()].find( (session) => session.threadId === this.#threadId, @@ -243,7 +306,7 @@ export class ScanCostTracker { } } } - let changed = true; + let changed = this.#attribution === null; while (changed) { changed = false; for (const session of this.#sessions.values()) { @@ -262,7 +325,20 @@ export class ScanCostTracker { if (included.has(session.threadId!)) throw error; } - const usages = new Map(this.#receipts); + let incomplete = false; + const usages = new Map( + [...this.#receipts].filter( + ([threadId]) => + included.has(threadId) && + (!this.#attribution || + this.#attribution.executionThreadIds.includes(threadId)), + ), + ); + if (this.#attribution) { + for (const threadId of included) { + if (!usages.has(threadId)) usages.set(threadId, null); + } + } for (const [path, tracked] of this.#sessions) { const threadId = tracked.threadId; if (threadId === null || !included.has(threadId)) continue; @@ -274,7 +350,12 @@ export class ScanCostTracker { // Replay only newly associated sessions, including their early events. session = createSessionUsage(); session.events = []; - await readSessionUsage(path, session, this.#options.repository); + await readSessionUsage( + path, + session, + this.#options.repository, + this.#attribution, + ); this.#sessions.set(path, session); } let worker: number | undefined; @@ -300,6 +381,13 @@ export class ScanCostTracker { } this.#reportWorkerProgress(session); } + if ( + session.counterUsage && + session.counterUsage.total_tokens > + (usages.get(threadId)?.total_tokens ?? -1) + ) { + usages.set(threadId, session.counterUsage); + } const receipt = usages.get(threadId); if ( session.usage !== null && @@ -310,18 +398,75 @@ export class ScanCostTracker { ) { usages.set(threadId, session.usage); } + if (!usages.has(threadId)) usages.set(threadId, null); + if ( + (session.counterRegressed && !session.responseUsageObserved) || + session.expectedResponseTokens > session.responseTokens + ) + incomplete = true; + if (session.pendingLineBytes > 0 && !this.#receipts.get(threadId)) + incomplete = true; } let usage: ScanTokenUsage | null = null; for (const value of usages.values()) { if (value === null) { + if (this.#attribution) { + incomplete = true; + continue; + } this.#snapshot = { usage: null, cost: null }; return; } usage = addTokenUsage(usage, value); } - if (usage === null) return; - const cost = estimateScanCost(this.#options.model, usage); - this.#snapshot = { usage, cost }; + if (usage === null) { + this.#snapshot = { usage: null, cost: null }; + return; + } + const modelUsage = new Map(); + let observedModel = false; + for (const [threadId, value] of usages) { + if (value === null) continue; + const session = [...this.#sessions.values()].find( + (item) => item.threadId === threadId, + ); + for (const [model, tokens] of session?.modelUsage ?? []) { + if (model !== null) observedModel = true; + modelUsage.set( + model, + addTokenUsage(modelUsage.get(model) ?? null, tokens), + ); + } + const remainder = session?.usage + ? subtractTokenUsage(value, session.usage) + : value; + if (remainder !== null && remainder.total_tokens > 0) { + const model = + this.#attribution || (session?.modelUsage.size ?? 0) > 0 + ? null + : (session?.model ?? + (threadId === this.#threadId ? this.#options.model : null)); + modelUsage.set( + model, + addTokenUsage(modelUsage.get(model) ?? null, remainder), + ); + } + } + const reconciled = + observedModel || this.#attribution !== null + ? { + ...usage, + modelUsage: [...modelUsage].map(([model, tokens]) => ({ + model, + ...tokens, + })), + } + : usage; + const measured = incomplete + ? { ...reconciled, coverage: "partial" } + : reconciled; + const cost = estimateScanCost(this.#options.model, measured); + this.#snapshot = { usage: measured, cost }; this.#reportCost(cost); } @@ -396,6 +541,7 @@ async function readSessionUsage( path: string, session: SessionUsage, repository?: string, + attribution: ScanExecutionAttribution | null = null, ): Promise { if (session.unreadable) return; let file; @@ -417,7 +563,12 @@ async function readSessionUsage( if (bytesRead === 0) return; session.offset += bytesRead; try { - readSessionChunk(buffer.subarray(0, bytesRead), session, repository); + readSessionChunk( + buffer.subarray(0, bytesRead), + session, + repository, + attribution, + ); } catch (error) { session.unreadable = true; session.pendingLine = []; @@ -434,6 +585,7 @@ function readSessionChunk( contents: Buffer, session: SessionUsage, repository?: string, + attribution: ScanExecutionAttribution | null = null, ): void { let lineStart = 0; while (lineStart < contents.length) { @@ -451,13 +603,19 @@ function readSessionChunk( } if (session.pendingLineBytes === 0) { - readSessionEvent(fragment.toString("utf8"), session, repository); + readSessionEvent( + fragment.toString("utf8"), + session, + repository, + attribution, + ); } else { if (fragment.length > 0) session.pendingLine.push(Buffer.from(fragment)); readSessionEvent( Buffer.concat(session.pendingLine, lineBytes).toString("utf8"), session, repository, + attribution, ); session.pendingLine = []; session.pendingLineBytes = 0; @@ -470,6 +628,7 @@ function readSessionEvent( line: string, session: SessionUsage, repository?: string, + attribution: ScanExecutionAttribution | null = null, ): void { if (line.length === 0) return; let event: unknown; @@ -492,6 +651,7 @@ function readSessionEvent( if (typeof payload["cwd"] === "string") { session.workingDirectory = payload["cwd"]; } + if (typeof payload["model"] === "string") session.model = payload["model"]; session.startedAt = sessionStartedAt(payload["timestamp"]); session.parentThreadId = sessionParentThreadId(payload); session.events?.push(event); @@ -520,7 +680,82 @@ function readSessionEvent( } return; } - session.events?.push(event); + if ( + (event["type"] === "turn_context" || payload["type"] === "task_started") && + typeof payload["turn_id"] === "string" + ) { + session.currentTurnId = payload["turn_id"]; + } + if ( + event["type"] === "turn_context" && + typeof payload["model"] === "string" + ) { + session.model = payload["model"]; + } + if (event["type"] === "token_usage_record") { + const responseId = payload["response_id"]; + const usage = tokenUsage(payload["usage"]); + if ( + typeof responseId !== "string" || + usage === null || + (typeof payload["thread_id"] === "string" && + payload["thread_id"] !== session.threadId) || + session.responseIds.has(responseId) + ) + return; + session.responseIds.add(responseId); + const cumulative = tokenUsage(payload["thread_token_usage"]); + if (cumulative) + session.expectedResponseTokens = Math.max( + session.expectedResponseTokens, + cumulative.total_tokens, + ); + if (!session.responseUsageObserved) { + // Exact receipts include compaction and survive counter resets. Keep the + // legacy counter as an independent lower bound, never add it to receipts. + session.responseUsageObserved = true; + session.usage = null; + session.modelUsage.clear(); + } + session.responseTokens += usage.total_tokens; + const turnId = + typeof payload["turn_id"] === "string" + ? payload["turn_id"] + : session.currentTurnId; + if ( + attribution && + !isAttributedScanEvent( + attribution, + session.threadId!, + turnId, + event["timestamp"], + ) + ) + return; + const model = + typeof payload["model"] === "string" ? payload["model"] : session.model; + session.usage = addTokenUsage(session.usage, usage); + session.modelUsage.set( + model, + addTokenUsage(session.modelUsage.get(model) ?? null, usage), + ); + session.events?.push(event); + return; + } + const attributable = + attribution === null || + isAttributedScanEvent( + attribution, + session.threadId!, + session.currentTurnId, + event["timestamp"], + ); + if (attributable) session.events?.push(event); + if ( + !attributable && + !(event["type"] === "event_msg" && payload["type"] === "token_count") + ) + return; if (event["type"] === "response_item") { session.progress.push(...sessionProgressUpdates(payload)); if (repository === undefined) return; @@ -658,7 +893,41 @@ function readSessionEvent( session.inheritedUsage === null ? usage : subtractTokenUsage(usage, session.inheritedUsage); - if (ownUsage !== null) session.usage = ownUsage; + if (ownUsage !== null) { + const delta = + session.previousUsage === null + ? ownUsage + : subtractTokenUsage(ownUsage, session.previousUsage); + if ( + session.previousUsage !== null && + ownUsage.total_tokens < session.previousUsage.total_tokens + ) { + session.counterRegressed = true; + return; + } + session.previousUsage = ownUsage; + if ( + attribution && + !isAttributedScanEvent( + attribution, + session.threadId!, + session.currentTurnId, + event["timestamp"], + ) + ) + return; + if (delta !== null && !session.responseUsageObserved) { + session.modelUsage.set( + session.model, + addTokenUsage(session.modelUsage.get(session.model) ?? null, delta), + ); + } + session.counterUsage = + attribution && delta !== null + ? addTokenUsage(session.counterUsage, delta) + : ownUsage; + if (!session.responseUsageObserved) session.usage = session.counterUsage; + } } function uuid7Order(value: unknown): bigint | null { diff --git a/sdk/typescript/src/scan-logs.ts b/sdk/typescript/src/scan-logs.ts index 851a8f2da1..dbe33be226 100644 --- a/sdk/typescript/src/scan-logs.ts +++ b/sdk/typescript/src/scan-logs.ts @@ -5,9 +5,12 @@ import { sessionFiles } from "./cost.js"; import { CodexSecurityError } from "./errors.js"; import type { JsonObject } from "./config.js"; import { + attributedScanThreads, + isAttributedScanEvent, isScanArtifactDirectory, sessionParentThreadId, sessionStartedAt, + type ScanExecutionAttribution, } from "./scan-sessions.js"; interface ScanLogOptions { @@ -19,6 +22,7 @@ interface ScanLogOptions { scanDirectory?: string; completedAt?: string | null; allowMissingRoot?: boolean; + executionAttribution?: ScanExecutionAttribution | null; } export type ScanLogSource = JsonObject & { @@ -26,6 +30,7 @@ export type ScanLogSource = JsonObject & { continuationThreadId?: string; threadIds?: string[]; executionThreadIds?: string[]; + executionAttribution?: ScanExecutionAttribution | null; mode?: string; scanDir?: string; progress?: { status?: string; updatedAt?: string }; @@ -47,6 +52,7 @@ export function readSavedScanLogs( threadId: threadId ?? scan.threadIds?.[0], threadIds: scan.threadIds, executionThreadIds: scan.executionThreadIds ?? [], + executionAttribution: scan.executionAttribution, codexHome, allowMissingRoot: options.allowMissingRoot, scanDirectory: scan.mode === "deep" ? scan.scanDir : undefined, @@ -126,15 +132,20 @@ export async function readScanLogs(options: ScanLogOptions) { ); } - const included = new Set([ - ...(options.threadId ? [options.threadId] : []), - ...(options.threadIds ?? []), - ...(options.executionThreadIds ?? []), - ]); + const attribution = options.executionAttribution?.legacy + ? null + : options.executionAttribution; + const included = attribution + ? attributedScanThreads(logs.values(), attribution) + : new Set([ + ...(options.threadId ? [options.threadId] : []), + ...(options.threadIds ?? []), + ...(options.executionThreadIds ?? []), + ]); // A Desktop owner can contain other work. Include its log without treating // the whole conversation tree as part of this scan. const traversed = new Set(options.executionThreadIds ?? included); - const pending = [...traversed]; + const pending = attribution ? [] : [...traversed]; for (const parentId of pending) { const parent = logs.get(parentId); for (const session of logs.values()) { @@ -160,8 +171,17 @@ export async function readScanLogs(options: ScanLogOptions) { const events: Record[] = []; for (const session of sessions) { let replaying = false; + let turnId: string | null = null; for await (const event of sessionEvents(session.path)) { const payload = event["payload"]; + if ( + isRecord(payload) && + (event["type"] === "turn_context" || + payload["type"] === "task_started") && + typeof payload["turn_id"] === "string" + ) { + turnId = payload["turn_id"]; + } if (event["type"] === "session_meta" && isRecord(payload)) { replaying = payload["id"] !== session.threadId; } @@ -178,7 +198,22 @@ export async function readScanLogs(options: ScanLogOptions) { } replaying = false; } - events.push({ threadId: session.threadId, event }); + if ( + !attribution || + event["type"] === "session_meta" || + isAttributedScanEvent( + attribution, + session.threadId, + event["type"] === "token_usage_record" && + isRecord(payload) && + typeof payload["turn_id"] === "string" + ? payload["turn_id"] + : turnId, + event["timestamp"], + ) + ) { + events.push({ threadId: session.threadId, event }); + } } } diff --git a/sdk/typescript/src/scan-sessions.ts b/sdk/typescript/src/scan-sessions.ts index 676f7a10bc..47b4c90918 100644 --- a/sdk/typescript/src/scan-sessions.ts +++ b/sdk/typescript/src/scan-sessions.ts @@ -1,5 +1,64 @@ import { isAbsolute, join, relative, sep } from "node:path"; +export interface ScanExecutionAttribution { + formatVersion: 1; + legacy?: true; + executionThreadIds: string[]; + owner: { threadId: string | null; turnId: string | null; startedAt: string }; + startedAt: string; + completedAt: string | null; +} + +export function attributedScanThreads( + sessions: Iterable<{ + threadId: string | null; + parentThreadId: string | null; + }>, + attribution: ScanExecutionAttribution, +): Set { + const included = new Set(attribution.executionThreadIds); + const pending = [...included]; + const all = [...sessions]; + for (const parent of pending) { + for (const session of all) { + if ( + session.threadId !== null && + session.parentThreadId === parent && + !included.has(session.threadId) + ) { + included.add(session.threadId); + pending.push(session.threadId); + } + } + } + if (attribution.owner.threadId) included.add(attribution.owner.threadId); + return included; +} + +export function isAttributedScanEvent( + attribution: ScanExecutionAttribution, + threadId: string, + turnId: string | null, + timestamp: unknown, +): boolean { + const time = sessionStartedAt(timestamp); + if ( + time === null || + time < Date.parse(attribution.startedAt) || + (attribution.completedAt !== null && + time > Date.parse(attribution.completedAt)) + ) + return false; + if ( + threadId !== attribution.owner.threadId || + attribution.executionThreadIds.includes(threadId) + ) + return true; + return ( + attribution.owner.turnId !== null && turnId === attribution.owner.turnId + ); +} + export function sessionStartedAt(timestamp: unknown): number | null { const startedAt = typeof timestamp === "string" ? Date.parse(timestamp) : Number.NaN; diff --git a/sdk/typescript/tests-ts/scan-resume.test.ts b/sdk/typescript/tests-ts/scan-resume.test.ts index a70986bb24..d2cf810235 100644 --- a/sdk/typescript/tests-ts/scan-resume.test.ts +++ b/sdk/typescript/tests-ts/scan-resume.test.ts @@ -158,10 +158,16 @@ async function interruptedScan( const sessionPath = join(codexHome, "sessions", `rollout-${threadId}.jsonl`); await writeFile( sessionPath, - JSON.stringify({ - type: "session_meta", - payload: { id: threadId, cwd: scanDir }, - }) + "\n", + [ + { type: "session_meta", payload: { id: threadId, cwd: scanDir } }, + { + type: "turn_context", + timestamp: new Date().toISOString(), + payload: { turn_id: "synthetic-scan-turn", model: "gpt-5.6-sol" }, + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", ); if (mode === "deep") { await command([ @@ -488,6 +494,7 @@ test.each([ f.sessionPath, JSON.stringify({ type: "event_msg", + timestamp: new Date().toISOString(), payload: { type: "token_count", info: { diff --git a/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts b/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts new file mode 100644 index 0000000000..6265ebf0b5 --- /dev/null +++ b/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts @@ -0,0 +1,601 @@ +import { describe, expect, test } from "bun:test"; +import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { estimateScanCost } from "../src/cost-model.js"; +import { ScanCostTracker } from "../src/cost.js"; +import { readScanLogs } from "../src/scan-logs.js"; +import type { ScanExecutionAttribution } from "../src/scan-sessions.js"; + +describe("scan usage reconciliation", () => { + test("SDK usage and logs share attempt membership and the original owner turn", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-attribution-")); + const observed: unknown[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + onSessionEvent: (event) => observed.push(event), + }); + const at = (second: number) => + `2026-09-01T00:00:${String(second).padStart(2, "0")}Z`; + const attribution: ScanExecutionAttribution = { + formatVersion: 1, + executionThreadIds: ["old-worker", "replacement-worker"], + owner: { threadId: "parent", turnId: "scan-turn", startedAt: at(1) }, + startedAt: at(1), + completedAt: at(10), + }; + const token = (second: number, count: number) => ({ + timestamp: at(second), + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: { input_tokens: count, output_tokens: 0 } }, + }, + }); + const context = (second: number, turn: string) => ({ + timestamp: at(second), + type: "turn_context", + payload: { turn_id: turn, model: "gpt-5.6-sol" }, + }); + try { + await mkdir(join(home, "sessions")); + for (const [id, parent, events] of [ + [ + "parent", + null, + [ + context(0, "prior-turn"), + token(0, 100), + context(1, "scan-turn"), + token(2, 110), + context(3, "unrelated-turn"), + token(4, 1010), + ], + ], + [ + "old-worker", + null, + [context(1, "worker-turn"), token(2, 20), token(11, 120)], + ], + ["replacement-worker", null, [context(3, "worker-turn"), token(4, 30)]], + ["worker-child", "old-worker", [context(3, "child-turn"), token(4, 5)]], + ["unrelated-child", "parent", [context(3, "side-turn"), token(4, 900)]], + ] as const) { + const records = [ + { + type: "session_meta", + payload: { id, ...(parent ? { parent_thread_id: parent } : {}) }, + }, + ...events, + ]; + await writeFile( + join(home, "sessions", `${id}.jsonl`), + records.map((value) => JSON.stringify(value)).join("\n") + "\n", + ); + } + tracker.setAttributionReader(async () => attribution); + tracker.start("parent"); + const snapshot = await tracker.stop(); + expect(snapshot.cost?.inputTokens).toBe(65); + expect(JSON.stringify(observed)).not.toContain("unrelated-turn"); + expect(JSON.stringify(observed)).not.toContain(at(11)); + const logs = await readScanLogs({ + scanId: "scan", + threadId: "parent", + codexHome: home, + executionAttribution: attribution, + }); + expect(logs.sessions.map((session) => session.threadId).sort()).toEqual([ + "old-worker", + "parent", + "replacement-worker", + "worker-child", + ]); + expect( + logs.events.some(({ event }) => + JSON.stringify(event).includes("unrelated-turn"), + ), + ).toBe(false); + expect( + logs.events.some(({ event }) => JSON.stringify(event).includes(at(11))), + ).toBe(false); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } + }); + + test("waits for attribution and retains uncertainty until missing attempt usage arrives", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-delayed-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + let attribution: ScanExecutionAttribution | null = null; + tracker.setAttributionReader(async () => attribution); + const at = "2026-09-01T00:00:02Z"; + const records = (id: string, count: number) => + [ + { type: "session_meta", payload: { id } }, + { + timestamp: at, + type: "turn_context", + payload: { model: "gpt-5.6-sol", turn_id: "own" }, + }, + { + timestamp: at, + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: count, output_tokens: 0 }, + }, + }, + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n"; + try { + await mkdir(join(home, "sessions")); + await writeFile( + join(home, "sessions", "worker.jsonl"), + records("worker", 20), + ); + tracker.start("worker"); + tracker.recordUsage({ input_tokens: 20, output_tokens: 0 }); + expect((await tracker.refresh()).cost).toBeNull(); + attribution = { + formatVersion: 1, + executionThreadIds: ["worker", "failed-attempt"], + owner: { threadId: "worker", turnId: "own", startedAt: at }, + startedAt: "2026-09-01T00:00:01Z", + completedAt: "2026-09-01T00:00:10Z", + }; + expect((await tracker.refresh()).cost).toMatchObject({ + inputTokens: 20, + coverage: "partial", + }); + await writeFile( + join(home, "sessions", "failed.jsonl"), + records("failed-attempt", 5), + ); + const final = await tracker.stop(); + expect(final.cost?.inputTokens).toBe(25); + expect(final.cost?.coverage).toBeUndefined(); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } + }); + + test("preserves receipt accounting for a resumed legacy Deep scan", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-legacy-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + legacy: true, + executionThreadIds: ["legacy-parent"], + owner: { + threadId: "legacy-parent", + turnId: null, + startedAt: "2026-09-01T00:00:00Z", + }, + startedAt: "2026-09-01T00:00:00Z", + completedAt: null, + })); + try { + tracker.start("legacy-parent"); + const snapshot = await tracker.stop({ + input_tokens: 10000, + output_tokens: 100, + }); + expect(snapshot.cost?.inputTokens).toBe(10000); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } + }); + + test("prices each observed model instead of repricing the sum with the parent", () => { + const usage = { + input_tokens: 200, + output_tokens: 20, + modelUsage: [ + { model: "gpt-5.6-sol", input_tokens: 100, output_tokens: 10 }, + { model: "gpt-6-astra", input_tokens: 100, output_tokens: 10 }, + ], + }; + const expected = + estimateScanCost("gpt-5.6-sol", usage.modelUsage[0])!.estimatedUsd + + estimateScanCost("gpt-6-astra", usage.modelUsage[1])!.estimatedUsd; + expect(estimateScanCost("gpt-5.6-sol", usage)?.estimatedUsd).toBe(expected); + }); + + test("keeps incomplete model attribution unpriced", () => { + expect( + estimateScanCost("gpt-5.6-sol", { + input_tokens: 200, + output_tokens: 20, + modelUsage: [ + { model: "gpt-5.6-sol", input_tokens: 100, output_tokens: 10 }, + { model: null, input_tokens: 100, output_tokens: 10 }, + ], + }), + ).toBeNull(); + }); + + test("reconciles stale and missing cumulative receipts without reducing usage", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-receipts-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + try { + tracker.start("worker"); + tracker.recordUsage({ input_tokens: 160, output_tokens: 0 }); + tracker.recordUsage({ input_tokens: 100, output_tokens: 0 }); + tracker.recordUsage(null); + expect((await tracker.stop()).cost?.inputTokens).toBe(160); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } + }); + + test("tracks per-model deltas within one resumed session", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-models-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + try { + await mkdir(join(home, "sessions")); + const records = [ + { type: "session_meta", payload: { id: "worker" } }, + { + type: "turn_context", + payload: { model: "gpt-5.6-sol", turn_id: "turn-1" }, + }, + { + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 100, output_tokens: 10 }, + }, + }, + }, + { + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: { input_tokens: 60, output_tokens: 6 } }, + }, + }, + { + type: "turn_context", + payload: { model: "gpt-6-astra", turn_id: "turn-2" }, + }, + { + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 200, output_tokens: 20 }, + }, + }, + }, + ]; + await writeFile( + join(home, "sessions", "worker.jsonl"), + records.map((record) => JSON.stringify(record)).join("\n") + "\n", + ); + tracker.start("worker"); + tracker.recordUsage({ input_tokens: 200, output_tokens: 20 }); + const snapshot = await tracker.stop(); + expect(snapshot.cost?.inputTokens).toBe(200); + expect(snapshot.cost?.estimatedUsd).toBeCloseTo(0.0021, 12); + expect(snapshot.cost?.modelCosts?.map((cost) => cost.model)).toEqual([ + "gpt-5.6-sol", + "gpt-6-astra", + ]); + tracker.recordUsage({ input_tokens: 250, output_tokens: 25 }); + expect((await tracker.refresh()).cost).toBeNull(); + await appendFile( + join(home, "sessions", "worker.jsonl"), + JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 250, output_tokens: 25 }, + }, + }, + }) + "\n", + ); + expect((await tracker.refresh()).cost?.estimatedUsd).toBeCloseTo( + 0.00285, + 12, + ); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } + }); +}); + +describe("charged response receipts", () => { + for (const exactOnly of [false, true]) { + test(`counts compaction and deduplicates responses across counter resets (exact only: ${exactOnly})`, async () => { + const home = await mkdtemp(join(tmpdir(), "usage-response-receipts-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + const usage = (input: number, cached: number, output: number) => ({ + input_tokens: input, + cached_input_tokens: cached, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: 0, + total_tokens: input + output, + }); + const record = ( + id: string, + count: unknown, + cumulative: unknown, + model = "gpt-5.6-sol", + ) => ({ + type: "token_usage_record", + payload: { + thread_id: "worker", + turn_id: "turn", + response_id: id, + model, + usage: count, + thread_token_usage: cumulative, + }, + }); + const first = record("normal-1", usage(100, 80, 10), usage(100, 80, 10)); + const compact = record( + "compaction", + usage(50, 40, 5), + usage(150, 120, 15), + "gpt-6-astra", + ); + const second = record("normal-2", usage(120, 90, 12), usage(120, 90, 12)); + const counter = (count: unknown) => ({ + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: count }, + }, + }); + const events = [ + { type: "session_meta", payload: { id: "worker" } }, + first, + ...(!exactOnly ? [counter(usage(100, 80, 10))] : []), + compact, + { + type: "compacted", + payload: { message: "Synthetic context summary" }, + }, + compact, + second, + ...(!exactOnly ? [counter(usage(220, 170, 22))] : []), + first, + ]; + try { + await mkdir(join(home, "sessions")); + await writeFile( + join(home, "sessions", "worker.jsonl"), + events.map((e) => JSON.stringify(e)).join("\n") + "\n", + ); + tracker.start("worker"); + tracker.recordUsage(usage(220, 170, 22)); + const result = await tracker.stop(); + expect(result.usage).toMatchObject(usage(270, 210, 27)); + expect( + result.cost?.modelCosts?.map((part) => [ + part.model, + part.inputTokens + part.outputTokens, + ]), + ).toEqual([ + ["gpt-5.6-sol", 242], + ["gpt-6-astra", 55], + ]); + expect((await tracker.refresh()).usage).toEqual(result.usage); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } + }); + } + + test("uses receipt turn identity for shared-parent usage and logs", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-response-owner-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + const attribution: ScanExecutionAttribution = { + formatVersion: 1, + executionThreadIds: [], + owner: { + threadId: "parent", + turnId: "scan-turn", + startedAt: "2026-09-01T00:00:01Z", + }, + startedAt: "2026-09-01T00:00:01Z", + completedAt: "2026-09-01T00:00:10Z", + }; + const receipt = ( + id: string, + turn: string, + second: string, + input: number, + ) => ({ + type: "token_usage_record", + timestamp: `2026-09-01T00:00:${second}Z`, + payload: { + response_id: id, + thread_id: "parent", + turn_id: turn, + model: "gpt-5.6-sol", + usage: { input_tokens: input, output_tokens: 0 }, + }, + }); + try { + await mkdir(join(home, "sessions")); + await writeFile( + join(home, "sessions", "parent.jsonl"), + [ + { type: "session_meta", payload: { id: "parent" } }, + receipt("prior", "prior-turn", "00", 900), + receipt("owned", "scan-turn", "02", 20), + receipt("side", "other-turn", "03", 800), + receipt("post", "scan-turn", "11", 700), + ] + .map((e) => JSON.stringify(e)) + .join("\n") + "\n", + ); + tracker.setAttributionReader(async () => attribution); + tracker.start("parent"); + expect((await tracker.stop()).cost?.inputTokens).toBe(20); + const logs = await readScanLogs({ + scanId: "scan", + threadId: "parent", + codexHome: home, + executionAttribution: attribution, + }); + const ids = logs.events + .map( + ({ event }) => + (event as { payload?: Record })["payload"]?.[ + "response_id" + ], + ) + .filter(Boolean); + expect(ids).toEqual(["owned"]); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } + }); +}); + +test("delayed response receipts resolve cumulative gaps without treating smaller counters as stale", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-delayed-response-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + const record = (id: string, tokens: number, cumulative: number) => + JSON.stringify({ + type: "token_usage_record", + payload: { + response_id: id, + thread_id: "worker", + model: "gpt-5.6-sol", + usage: { input_tokens: tokens, output_tokens: 0 }, + thread_token_usage: { input_tokens: cumulative, output_tokens: 0 }, + }, + }) + "\n"; + try { + await mkdir(join(home, "sessions")); + const file = join(home, "sessions", "worker.jsonl"); + await writeFile( + file, + JSON.stringify({ type: "session_meta", payload: { id: "worker" } }) + + "\n" + + record("first", 100, 100) + + record("third", 50, 180), + ); + tracker.start("worker"); + expect((await tracker.refresh()).cost).toMatchObject({ + inputTokens: 150, + coverage: "partial", + }); + await appendFile(file, record("second", 30, 130)); + const result = await tracker.stop(); + expect(result.cost?.inputTokens).toBe(180); + expect(result.cost?.coverage).toBeUndefined(); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } +}); + +test("a reader installed before the native attribution writer preserves legacy receipts", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-reader-first-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + try { + tracker.setAttributionReader(async () => undefined); + tracker.start("parent"); + tracker.recordUsage({ input_tokens: 100, output_tokens: 0 }); + expect((await tracker.stop()).cost?.inputTokens).toBe(100); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } +}); + +test("late exact receipts replace an overlapping legacy counter without adding it twice", async () => { + const home = await mkdtemp(join(tmpdir(), "usage-overlap-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + const usage = (count: number) => ({ input_tokens: count, output_tokens: 0 }); + const receipt = (id: string, count: number, cumulative: number) => ({ + type: "token_usage_record", + payload: { + thread_id: "worker", + response_id: id, + model: "gpt-5.6-sol", + usage: usage(count), + thread_token_usage: usage(cumulative), + }, + }); + try { + await mkdir(join(home, "sessions")); + await writeFile( + join(home, "sessions", "worker.jsonl"), + [ + { type: "session_meta", payload: { id: "worker" } }, + { + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: usage(100) }, + }, + }, + receipt("new", 10, 110), + receipt("old", 100, 100), + { + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: usage(10) }, + }, + }, + ] + .map((e) => JSON.stringify(e)) + .join("\n") + "\n", + ); + tracker.start("worker"); + const result = await tracker.stop(); + expect(result.cost?.inputTokens).toBe(110); + expect(result.cost?.coverage).toBeUndefined(); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } +}); From d7fd7b878c8b3abea45b3cd19432b049a8b635ea Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:11:34 +0000 Subject: [PATCH 047/251] Reconcile native usage with durable execution attribution --- .../codex-security/scripts/workbench_db.py | 1 + .../scripts/workbench_scan_usage.py | 151 +++++++++++++++++- .../scripts/workbench_validation.py | 14 ++ .../tests/test_workbench_scan_usage.py | 69 ++++++++ 4 files changed, 227 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 64b560144b..d48a83ed84 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -2843,6 +2843,7 @@ def scan_result( "continuationThreadId": scan["continuation_thread_id"], "threadIds": scan_usage._scan_root_thread_ids(connection, scan, None), "executionThreadIds": scan_usage._scan_execution_thread_ids(connection, scan), + "executionAttribution": scan_usage.scan_execution_attribution(connection, scan), "failureMessage": scan["failure_message"], "findings": [ finding_result(connection, scan, row, related=relations.get(row["id"], [])) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index e3296d6c8f..68cdaea92e 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -90,7 +90,23 @@ def collect_scan_usage( ) -> dict[str, Any]: """Count only complete, attributable rollout events inside this scan's window.""" - roots = _scan_root_thread_ids(connection, scan, thread_id) + attribution = scan_execution_attribution(connection, scan) + roots = ( + list( + dict.fromkeys( + [ + *( + [attribution["owner"]["threadId"]] + if attribution["owner"].get("threadId") + else [] + ), + *attribution["executionThreadIds"], + ] + ) + ) + if attribution + else _scan_root_thread_ids(connection, scan, thread_id) + ) if not roots: return _unavailable_usage("scan_thread_unavailable") @@ -109,6 +125,7 @@ def collect_scan_usage( state_database, roots, warnings, + descendant_roots=set(attribution["executionThreadIds"]) if attribution else None, ) except (OSError, sqlite3.Error, ValueError): return _unavailable_usage("codex_state_unavailable") @@ -120,7 +137,20 @@ def collect_scan_usage( observed_thread_count = 0 accepted_thread_ids: set[str] = set() excluded_thread_ids: set[str] = set() + model_usage: dict[str | None, dict[str, int]] = {} for session in sessions: + owner_turn_id = None + if ( + attribution + and session.thread_id not in attribution["executionThreadIds"] + and session.parent_thread_id is None + ): + owner = attribution["owner"] + if session.thread_id != owner.get("threadId") or not owner.get("turnId"): + missing_thread_ids.add(session.thread_id) + warnings.add("scan_owner_turn_unavailable") + continue + owner_turn_id = owner["turnId"] if session.parent_thread_id in excluded_thread_ids: excluded_thread_ids.add(session.thread_id) continue @@ -136,6 +166,8 @@ def collect_scan_usage( session, started_at=started_at, completed_at=stopped_at, + owner_turn_id=owner_turn_id, + model_usage=model_usage, ) except (OSError, UnicodeError, ValueError): missing_thread_ids.add(session.thread_id) @@ -170,6 +202,8 @@ def collect_scan_usage( result["missingThreadCount"] = len(missing_thread_ids) if warnings: result["warnings"] = sorted(warnings) + if attribution or any(model is not None for model in model_usage): + result["modelUsage"] = [{"model": model, **usage} for model, usage in model_usage.items()] return result @@ -197,12 +231,13 @@ def _scan_root_thread_ids( row["sdk_thread_id"] for row in connection.execute( """ - SELECT DISTINCT sdk_thread_id - FROM deep_scan_workers + SELECT sdk_thread_id FROM deep_scan_attempt_sessions WHERE scan_id = ? + UNION + SELECT sdk_thread_id FROM deep_scan_workers WHERE scan_id = ? AND sdk_thread_id IS NOT NULL ORDER BY sdk_thread_id """, - (scan["id"],), + (scan["id"], scan["id"]), ) ) roots: list[str] = [] @@ -224,6 +259,85 @@ def _scan_execution_thread_ids(connection: sqlite3.Connection, scan: sqlite3.Row ) +def capture_scan_usage_owner(connection: sqlite3.Connection, scan: sqlite3.Row) -> dict[str, Any]: + """Bind the active native turn once; joining a scan does not bind later conversation work.""" + roots = _scan_root_thread_ids(connection, scan, None) + owner = roots[0] if roots else None + result = { + "threadId": owner, + "turnId": None, + "startedAt": scan["started_at"], + "dedicated": scan["recipe_json"] is not None, + } + database = _codex_state_database() + if owner is None or database is None: + return result + try: + sessions, _ = _discover_rollout_sessions(database, [owner], set(), descendant_roots=set()) + if not sessions: + return result + with sessions[0].path.open("rb") as source: + for line in source: + if not line.endswith(b"\n"): + continue + event = json.loads(line) + payload = event.get("payload") + if not isinstance(payload, dict): + continue + if event.get("type") == "turn_context" or ( + event.get("type") == "event_msg" and payload.get("type") == "task_started" + ): + turn_id = payload.get("turn_id") + if isinstance(turn_id, str): + result["turnId"] = turn_id + elif event.get("type") == "event_msg" and payload.get("type") == "task_complete": + result["turnId"] = None + except (OSError, ValueError, sqlite3.Error): + # Accounting availability must not prevent a scan from starting. + pass + return result + + +def scan_execution_attribution( + connection: sqlite3.Connection, scan: sqlite3.Row +) -> dict[str, Any] | None: + if scan["mode"] != "deep": + return None + run = connection.execute( + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan["id"],) + ).fetchone() + if run is None: + return None + owner_json = run["usage_owner_json"] if "usage_owner_json" in run.keys() else None + if owner_json is None: + if ( + connection.execute( + "SELECT 1 FROM deep_scan_attempts WHERE scan_id = ? LIMIT 1", (scan["id"],) + ).fetchone() + is None + ): + return None + roots = _scan_root_thread_ids(connection, scan, None) + owner = { + "threadId": roots[0] if roots else None, + "turnId": None, + "startedAt": scan["started_at"], + "dedicated": scan["recipe_json"] is not None, + } + else: + owner = json.loads(owner_json) + executions = _scan_execution_thread_ids(connection, scan) + if owner.get("dedicated") and owner.get("threadId") not in executions: + executions.append(owner["threadId"]) + return { + "formatVersion": 1, + "executionThreadIds": executions, + "owner": owner, + "startedAt": scan["started_at"], + "completedAt": scan["completed_at"], + } + + def _codex_state_database() -> Path | None: configured_database = os.environ.get("CODEX_STATE_DB", "").strip() if configured_database: @@ -263,6 +377,8 @@ def _discover_rollout_sessions( state_database: Path, roots: list[str], warnings: set[str], + *, + descendant_roots: set[str] | None = None, ) -> tuple[list[RolloutSession], set[str]]: database = sqlite3.connect( state_database.as_uri() + "?mode=ro", @@ -298,6 +414,8 @@ def _discover_rollout_sessions( continue sessions.append(RolloutSession(root, None, path)) seen_thread_ids.add(root) + if descendant_roots is not None and root not in descendant_roots: + continue descendants = database.execute( """ WITH RECURSIVE descendants( @@ -402,12 +520,16 @@ def _read_rollout_usage( *, started_at: datetime, completed_at: datetime | None, + owner_turn_id: str | None = None, + model_usage: dict[str | None, dict[str, int]] | None = None, ) -> tuple[dict[str, int], set[str]]: total = _empty_token_usage() warnings: set[str] = set() previous = _empty_token_usage() boundary_reached = False usage_observed = False + current_turn_id: str | None = None + current_model: str | None = None with session.path.open("rb") as source: for line_number, raw_line in enumerate(source, start=1): @@ -427,6 +549,10 @@ def _read_rollout_usage( warnings.add("rollout_record_invalid") continue payload = event.get("payload") + if event.get("type") in {"session_meta", "turn_context"} and isinstance(payload, dict): + model = payload.get("model") + if isinstance(model, str) and model: + current_model = model if line_number == 1: if event.get("type") != "session_meta" or not isinstance(payload, dict): warnings.add("thread_identity_mismatch") @@ -449,6 +575,10 @@ def _read_rollout_usage( if not isinstance(payload, dict): continue + if event.get("type") == "turn_context" or ( + event.get("type") == "event_msg" and payload.get("type") == "task_started" + ): + current_turn_id = payload.get("turn_id") if not boundary_reached: if _is_owned_task_start(session.thread_id, event, payload): task_started_at = _timestamp(event.get("timestamp")) @@ -473,19 +603,24 @@ def _read_rollout_usage( if timestamp is None or snapshot is None: warnings.add("token_record_invalid") continue - delta = { - key: value - previous[key] if value >= previous[key] else value - for key, value in snapshot.items() - } + if snapshot["totalTokens"] < previous["totalTokens"]: + continue + delta = {key: max(0, value - previous[key]) for key, value in snapshot.items()} previous = snapshot if timestamp < started_at: continue if completed_at is not None and timestamp > completed_at: continue + if owner_turn_id is not None and current_turn_id != owner_turn_id: + continue usage_observed = True + if model_usage is not None: + model_usage.setdefault(current_model, _empty_token_usage()) if delta["totalTokens"] <= 0: continue _add_token_usage(total, delta) + if model_usage is not None: + _add_token_usage(model_usage.setdefault(current_model, _empty_token_usage()), delta) if not boundary_reached: warnings.add("thread_ownership_unavailable") diff --git a/plugins/codex-security/scripts/workbench_validation.py b/plugins/codex-security/scripts/workbench_validation.py index a65aff1e79..f4f68f5f42 100644 --- a/plugins/codex-security/scripts/workbench_validation.py +++ b/plugins/codex-security/scripts/workbench_validation.py @@ -137,6 +137,7 @@ def _valid_measured_scan_usage(usage: object) -> bool: "threadCount", "missingThreadCount", "warnings", + "modelUsage", *SCAN_USAGE_TOKEN_KEYS, } if thread_count == 0 or not set(usage).issubset(allowed_keys): @@ -144,6 +145,19 @@ def _valid_measured_scan_usage(usage: object) -> bool: counts = {key: usage.get(key) for key in SCAN_USAGE_TOKEN_KEYS} if not _valid_scan_token_counts(counts): return False + if "modelUsage" in usage: + parts = usage["modelUsage"] + if not isinstance(parts, list) or not parts: + return False + for part in parts: + if not isinstance(part, dict) or set(part) != {"model", *SCAN_USAGE_TOKEN_KEYS}: + return False + if part["model"] is not None and not isinstance(part["model"], str): + return False + if not _valid_scan_token_counts({key: part[key] for key in SCAN_USAGE_TOKEN_KEYS}): + return False + if any(sum(part[key] for part in parts) != counts[key] for key in SCAN_USAGE_TOKEN_KEYS): + return False missing = usage.get("missingThreadCount", 0) if type(missing) is not int or missing < 0: return False diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index ccbb6e0721..4b95d3b7c6 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -702,3 +702,72 @@ def test_failed_scan_preserves_legacy_failure_behavior(tmp_path: Path) -> None: )["scan"] assert failed["progress"]["status"] == "failed" assert "usage" not in failed + + +def test_rollout_usage_reconciles_stale_cumulative_events_and_models( + tmp_path: Path, workbench_api +) -> None: + usage_reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + events = [ + _event(start, "turn_context", {"turn_id": "own-turn", "model": "gpt-5.6-sol"}), + _token_event(start, 100, 10), + _token_event(start, 60, 6), + _event(start, "turn_context", {"turn_id": "own-turn", "model": "gpt-6-astra"}), + _token_event(start, 200, 20), + ] + rollout = _rollout(tmp_path, "worker", events) + counts, warnings = usage_reader._read_rollout_usage( + usage_reader.RolloutSession("worker", None, rollout), + started_at=start, + completed_at=None, + ) + assert counts == _counts(200, 0, 20) + assert warnings == set() + models = {} + counts, warnings = usage_reader._read_rollout_usage( + usage_reader.RolloutSession("worker", None, rollout), + started_at=start, + completed_at=None, + model_usage=models, + ) + assert counts == _counts(200, 0, 20) + assert warnings == set() + assert models == {"gpt-5.6-sol": _counts(100, 0, 10), "gpt-6-astra": _counts(100, 0, 10)} + + +def test_shared_parent_usage_requires_original_turn_and_scan_interval( + tmp_path: Path, workbench_api +) -> None: + usage_reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + end = start + timedelta(seconds=5) + events = [ + _event( + start - timedelta(seconds=1), + "turn_context", + {"turn_id": "prior", "model": "gpt-5.6-sol"}, + ), + _token_event(start - timedelta(seconds=1), 100, 10), + _event(start, "turn_context", {"turn_id": "scan-turn", "model": "gpt-6-astra"}), + _token_event(start, 110, 12), + _event(start, "turn_context", {"turn_id": "unrelated", "model": "gpt-5.6-sol"}), + _token_event(start, 910, 92), + _event( + end + timedelta(seconds=1), + "turn_context", + {"turn_id": "scan-turn", "model": "gpt-6-astra"}, + ), + _token_event(end + timedelta(seconds=1), 1000, 100), + ] + models = {} + counts, warnings = usage_reader._read_rollout_usage( + usage_reader.RolloutSession("parent", None, _rollout(tmp_path, "parent", events)), + started_at=start, + completed_at=end, + owner_turn_id="scan-turn", + model_usage=models, + ) + assert counts == _counts(10, 0, 2) + assert warnings == set() + assert models == {"gpt-6-astra": _counts(10, 0, 2)} From 04661c90458414b594b9b166f2f9ad61a30ac3e0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:20:21 +0000 Subject: [PATCH 048/251] fix: preserve accepted attempts and compact merge receipts --- .../mcp-app/src/deep-scan/store.ts | 38 +- .../mcp-app/src/deep-scan/types.ts | 20 +- .../mcp-app/src/deep-scan/worker-runner.ts | 76 ++-- .../tests/test_deep_scan_attempt_replay.mjs | 102 +++++ .../tests/test_deep_scan_coordinator.mjs | 24 -- .../mcp-app/tests/test_deep_scan_store.mjs | 4 +- .../scripts/deep_scan_workbench.py | 354 ++++++++++++++++-- .../tests/test_deep_scan_persistence.py | 340 ++++++++++++++++- .../tests/test_workbench_deep_scan.py | 17 +- .../tests/test_workbench_scan_usage.py | 9 +- 10 files changed, 869 insertions(+), 115 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index a9f87734c1..4246962f72 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -251,8 +251,10 @@ export class WorkbenchDeepScanStore implements DeepScanStore { ? ["--replaceable-failure-kind", update.replaceableFailureKind] : []) ], true); - const worker = parseWorker(result, update.id); const state = objectValue(result.deepScan, "deepScan"); + const worker = parseWorker(state.workerReceipt + ? { deepScan: { ...state, workers: [state.workerReceipt] } } + : result, update.id); return state.consecutiveErrors === undefined ? worker : { @@ -270,8 +272,8 @@ export class WorkbenchDeepScanStore implements DeepScanStore { workerIds: string[]; promptPath: string; artifactDir: string; - }): Promise { - await this.enqueueWrite([ + }): Promise { + return parseDeepScan(await this.enqueueWrite([ "claim-deep-scan-dedup", "--scan-id", input.scanId, @@ -283,7 +285,7 @@ export class WorkbenchDeepScanStore implements DeepScanStore { input.artifactDir, ...this.coordinatorLeaseArgs(input.scanId), ...input.workerIds.flatMap((workerId) => ["--input-worker-id", workerId]) - ], true); + ], true)); } async commitDedup(commit: DedupCommit): Promise { @@ -642,7 +644,17 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { : undefined, error: optionalString(value.error), persistedWorkers: parsePersistedWorkers(value.workers), - persistedDedupInputs: parsePersistedDedupInputs(value.dedupInputs) + persistedDedupInputs: parsePersistedDedupInputs(value.dedupInputs), + persistedMergeClaims: Array.isArray(value.mergeClaims) ? value.mergeClaims.map((candidate) => { + const claim = objectValue(candidate, "deepScan.mergeClaim"); + return { + workerId: requiredString(claim.workerId, "deepScan.mergeClaim.workerId"), + previousWorkerId: optionalString(claim.previousWorkerId), + previousResultPath: optionalString(claim.previousResultPath), + previousResultSha256: optionalString(claim.previousResultSha256) + }; + }) : [], + ...(value.committedMerge ? { committedMerge: parseCommittedMerge(value.committedMerge) } : {}) }; } @@ -686,11 +698,24 @@ function parsePersistedDedupInputs(value: unknown): PersistedDeepScanDedupInput[ inputOrder: nonNegativeInteger( input.inputOrder, "deepScan.dedupInput.inputOrder" - ) + ), + resultManifestPath: optionalString(input.resultManifestPath), + resultManifestSha256: optionalString(input.resultManifestSha256), + attempt: optionalPositiveInteger(input.attempt) }; }); } +function parseCommittedMerge(value: unknown): NonNullable { + const commit = objectValue(value, "deepScan.committedMerge"); + return { + workerId: requiredString(commit.workerId, "deepScan.committedMerge.workerId"), + resultManifestPath: requiredString(commit.resultManifestPath, "deepScan.committedMerge.resultManifestPath"), + resultManifestSha256: requiredString(commit.resultManifestSha256, "deepScan.committedMerge.resultManifestSha256"), + newFindings: nonNegativeInteger(commit.newFindings, "deepScan.committedMerge.newFindings") + }; +} + function deepScanPhase(value: unknown): DeepScanRunState["phase"] { if (value === undefined || value === null) return undefined; if (value === "setup" || value === "discovery" || value === "reducing" || value === "terminal") { @@ -752,6 +777,7 @@ function parsePersistedWorker(value: JsonObject): PersistedDeepScanWorker { attempt: nonNegativeInteger(value.attempt, "deepScan.worker.attempt"), threadId: optionalString(value.sdkThreadId), resultManifestPath: optionalString(value.resultManifestPath), + acceptedResultPath: optionalString(value.acceptedResultPath), completionSequence: optionalPositiveInteger(value.completionSequence), error: optionalString(value.error) }; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/types.ts b/plugins/codex-security/mcp-app/src/deep-scan/types.ts index 34af68ff69..9e0a22fe8b 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/types.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/types.ts @@ -72,12 +72,29 @@ export interface DeepScanRunState { error?: string; persistedWorkers?: PersistedDeepScanWorker[]; persistedDedupInputs?: PersistedDeepScanDedupInput[]; + persistedMergeClaims?: PersistedDeepScanMergeClaim[]; + committedMerge?: { + workerId: string; + resultManifestPath: string; + resultManifestSha256: string; + newFindings: number; + }; +} + +export interface PersistedDeepScanMergeClaim { + workerId: string; + previousWorkerId?: string; + previousResultPath?: string; + previousResultSha256?: string; } export interface PersistedDeepScanDedupInput { dedupWorkerId: string; discoveryWorkerId: string; inputOrder: number; + resultManifestPath?: string; + resultManifestSha256?: string; + attempt?: number; } export interface BeginDeepScanResult { @@ -126,6 +143,7 @@ export interface PersistedDeepScanWorker { attempt: number; threadId?: string; resultManifestPath?: string; + acceptedResultPath?: string; completionSequence?: number; consecutiveErrors?: number; mergeState: DeepScanMergeState; @@ -165,7 +183,7 @@ export interface DeepScanStore { workerIds: string[]; promptPath: string; artifactDir: string; - }): Promise; + }): Promise; commitDedup(commit: DedupCommit): Promise; selectFinalization?(input: { scanId: string; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 96e372760b..6bf41818aa 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -229,11 +229,7 @@ export class DeepScanWorkerRunner { }; let persisted: PersistedDeepScanWorker; try { - persisted = await this.replayStoreMutation( - "discovery_acceptance_replay", - workerId, - async () => await this.options.store.updateWorker(acceptance) - ); + persisted = await this.options.store.updateWorker(acceptance); } catch (error) { if (!this.options.signal.aborted) throw error; return { type: "discovery", status: "canceled", workerId }; @@ -252,7 +248,7 @@ export class DeepScanWorkerRunner { id: workerId, label: workerLabel, artifactDir, - resultPath: files.resultPath, + resultPath: persisted.acceptedResultPath ?? files.resultPath, completionSequence: persisted.completionSequence, attempt: outcome.attempt, threadId: outcome.threadId @@ -264,10 +260,9 @@ export class DeepScanWorkerRunner { const { id: reducerId, label: reducerLabel, - consumed, - previousReducerResultPath, previousSourceCoverage } = request; + let { consumed, previousReducerResultPath } = request; const { artifacts, run } = this.options; const reducerRoot = join(artifacts.dedupRoot, reducerLabel); const artifactDir = join(reducerRoot, "output"); @@ -283,13 +278,23 @@ export class DeepScanWorkerRunner { })) }); await writePrivateFile(promptPath, basePrompt); - await this.options.store.claimDedup({ + const claimed = await this.options.store.claimDedup({ id: reducerId, scanId: run.scanId, workerIds: consumed.map((worker) => worker.id), promptPath, artifactDir }); + const claim = claimed?.persistedMergeClaims?.find((item) => item.workerId === reducerId); + if (claim) { + previousReducerResultPath = claim.previousResultPath; + const inputs = claimed?.persistedDedupInputs?.filter((item) => item.dedupWorkerId === reducerId) ?? []; + consumed = inputs.sort((a, b) => a.inputOrder - b.inputOrder).map((item) => { + const discovery = consumed.find((worker) => worker.id === item.discoveryWorkerId); + if (!discovery || !item.resultManifestPath) throw new Error("The reducer claim is missing an accepted input."); + return { ...discovery, resultPath: item.resultManifestPath }; + }); + } this.options.log({ event: "dedup_claimed", scanId: run.scanId, @@ -394,25 +399,26 @@ export class DeepScanWorkerRunner { newFindings: reducerValidation.newFindings, resultManifestPath: resultPath }; - const committed = await this.replayStoreMutation( - "dedup_commit_replay", - reducerId, - async () => await this.options.store.commitDedup(commit) - ); + const committed = await this.options.store.commitDedup(commit); + const accepted = committed.committedMerge; + const acceptedPath = accepted?.resultManifestPath ?? resultPath; + // V1 checkpoints omit host-only coverage; retain the validated projection. + const acceptedResult = reducerValidation.result; + const newFindings = accepted?.newFindings ?? reducerValidation.newFindings; this.options.log({ event: "dedup_committed", scanId: run.scanId, workerId: reducerId, count: consumed.length, - newFindings: reducerValidation.newFindings + newFindings }); return { type: "dedup", id: reducerId, consumed, - resultPath, - result: reducerValidation.result, - newFindings: reducerValidation.newFindings, + resultPath: acceptedPath, + result: acceptedResult, + newFindings, attempt: outcome.attempt, threadId: outcome.threadId, run: committed @@ -659,32 +665,6 @@ export class DeepScanWorkerRunner { }); } - /** Replay idempotent SQLite commits when their process response is ambiguous. */ - private async replayStoreMutation( - event: string, - workerId: string, - operation: () => Promise - ): Promise { - try { - return await operation(); - } catch (firstError) { - this.options.log({ - event, - scanId: this.options.run.scanId, - workerId, - reason: errorKind(firstError) - }); - try { - return await operation(); - } catch (replayError) { - throw new Error( - `Deep Scan persistence replay failed: ${asError(replayError).message}`, - { cause: firstError } - ); - } - } - } - private async cancelAttempt( input: { workerId: string; @@ -842,14 +822,6 @@ function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -function errorKind(error: unknown): string { - const normalized = asError(error); - const code = "code" in normalized && typeof normalized.code === "string" - ? normalized.code - : undefined; - return code ? `${normalized.name}:${code}` : normalized.name; -} - function abortError(reason?: unknown): Error { const error = new Error("Deep Scan worker was aborted.", { cause: reason }); error.name = "AbortError"; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs new file mode 100644 index 0000000000..2356fe7ac0 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const plugin = path.resolve(app, ".."); +const bundle = await build({ + bundle: true, format: "esm", platform: "node", write: false, + loader: { ".md": "text" }, + stdin: { resolveDir: app, contents: [ + 'export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts";', + 'export { DeepScanWorkerRunner } from "./src/deep-scan/worker-runner.ts";', + 'export { createDeepScanArtifacts, ensureDeepScanDirectories } from "./src/deep-scan/artifacts.ts";' + ].join("\n") } +}); +const { WorkbenchDeepScanStore, DeepScanWorkerRunner, createDeepScanArtifacts, ensureDeepScanDirectories } = + await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`); +const execute = promisify(execFile); +for (const responseLosses of [3, 1]) await testResponseLoss(responseLosses); + +async function testResponseLoss(responseLosses) { + const root = await mkdtemp(path.join(tmpdir(), "deep-attempt-receipt-")); + const target = path.join(root, "target"); + const environment = { ...process.env, CODEX_HOME: path.join(root, "home"), CODEX_SECURITY_STATE_DIR: path.join(root, "state") }; + const counts = new Map(); + const receipts = new Map(); + const raw = async (args) => { + const { stdout } = await execute(process.env.PYTHON || "python3", [path.join(plugin, "scripts/workbench_db.py"), ...args], { + env: environment, timeout: 30_000, maxBuffer: 4 * 1024 * 1024 + }); + return JSON.parse(stdout); + }; + const store = new WorkbenchDeepScanStore(async (args) => { + const result = await raw(args); + const key = args[0] === "commit-deep-scan-dedup" ? "merge" + : args[0] === "upsert-deep-scan-worker" && args[args.indexOf("--status") + 1] === "succeeded" ? "acceptance" : null; + const operation = key === "acceptance" ? `${key}:${args[args.indexOf("--worker-id") + 1]}` : key; + if (key) { + counts.set(key, (counts.get(key) ?? 0) + 1); + const receipt = key === "acceptance" ? result.deepScan.workerReceipt : result.deepScan.committedMerge; + if (!receipts.has(operation)) receipts.set(operation, receipt); + else assert.deepEqual(receipt, receipts.get(operation), "replay returns the original operation receipt"); + if (counts.get(key) <= responseLosses) { + const error = new Error("fixture response lost after committed write"); + error.code = "ETIMEDOUT"; + throw error; + } + } + return result; + }); + try { + await mkdir(target); + await writeFile(path.join(target, "fixture.py"), "print('fixture')\n"); + const { run } = await store.begin({ targetPath: target, scope: ".", threadId: "fixture-owner", scanRoot: path.join(root, "scans") }); + const artifacts = createDeepScanArtifacts(run.scanDir); + await ensureDeepScanDirectories(artifacts); + let executions = 0; + const runner = new DeepScanWorkerRunner({ + run, store, artifacts, pluginRoot: plugin, signal: new AbortController().signal, + random: () => 0.5, log: () => {}, retryDelaysMs: [], + clock: { now: () => Date.now(), sleep: async () => {} }, + executor: { async run(request) { + executions++; + await request.onThreadStarted?.(`fixture-session-${executions}`); + const draft = { scanId: run.scanId, findings: [], threatModel: { summary: "Synthetic fixture." } }; + if (request.kind === "discovery") draft.coverage = { + completeness: "complete", surfaces: [{ label: "Fixture", disposition: "no_issue_found" }], explicitExclusions: [], deferred: [] + }; + await writeFile(path.join(request.artifactContext.root, "result.json"), JSON.stringify(draft)); + return { threadId: `fixture-session-${executions}` }; + } } + }); + if (responseLosses === 3) { + const outcome = await runner.runDiscoveryWorker(randomUUID(), "discovery-1").catch((error) => error); + assert.equal(counts.get("acceptance"), 3, "the runner must not multiply the store's retry policy"); + assert.match(outcome.message, /response lost/); + assert.equal(executions, 1); + return; + } + const discovery = await runner.runDiscoveryWorker(randomUUID(), "discovery-1"); + assert.equal(discovery.status, "succeeded"); + assert.match(discovery.worker.resultPath, /checkpoints/); + const second = await runner.runDiscoveryWorker(randomUUID(), "discovery-2"); + // Acceptance receipts are operation-specific, so only the first discovery loses a response. + await rm(path.join(discovery.worker.artifactDir, "result.json")); + const merged = await runner.runReducer({ id: randomUUID(), label: "dedup-1", consumed: [discovery.worker, second.worker] }); + assert.match(merged.resultPath, /checkpoints/); + assert.equal(merged.newFindings, 0); + assert.equal(merged.run.persistedDedupInputs.filter((input) => input.dedupWorkerId === merged.id).length, 2); + assert.equal(counts.get("merge"), 2); + assert.equal(executions, 3); + assert.deepEqual(JSON.parse(await readFile(merged.resultPath, "utf8")), merged.result); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs index 199685cd03..3625954d91 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs @@ -1384,29 +1384,6 @@ async function testLostFinishResponseObservesCommitWithoutOverwritingSuccessMani assert.equal(manifest.scan.scanId, fixture.run.scanId); } -async function testLostWorkerCommitResponsesReplayIdempotently() { - const fixture = await fixtureRun({ workers: 1, subagents: 0, stopAfterNoNew: 1, maxDiscoveryRuns: 1 }); - const store = new FakeStore(fixture.run); - store.loseFirstDiscoveryAcceptanceResponseAfterCommit = true; - store.loseFirstDedupCommitResponseAfterCommit = true; - const coordinator = new DeepScanCoordinator({ - run: fixture.run, - store, - executor: new FakeExecutor({ dedupNewFindings: [0] }), - pluginRoot: fixture.pluginRoot, - clock: immediateClock - }); - coordinator.start(); - - const terminal = await coordinator.wait(undefined, 5_000); - assert.equal(terminal?.status, "succeeded"); - assert.equal(store.discoveryAcceptanceResponseLosses, 1); - assert.equal(store.dedupCommitResponseLosses, 1); - assert.equal(store.dedupCommitCalls.length, 2); - assert.equal(store.dedupCommits.length, 1); - assert.equal(store.failCalls, 0); -} - async function testCommittedReducerIsReconciledBeforeDiscoveryFailureManifest() { const fixture = await fixtureRun({ workers: 3, subagents: 0, stopAfterNoNew: 10, maxDiscoveryRuns: 3 }); const thirdWorkerGate = deferred(); @@ -3998,7 +3975,6 @@ try { await testFailureManifestWriteDoesNotMaskOriginalError(); await testFinishPersistenceFailureRewritesManifestAsFailure(); await testLostFinishResponseObservesCommitWithoutOverwritingSuccessManifest(); - await testLostWorkerCommitResponsesReplayIdempotently(); await testCommittedReducerIsReconciledBeforeDiscoveryFailureManifest(); await testLongWorkerErrorIsBoundedOnlyAtPersistenceBoundary(); await testDiscoveryPhasePersistenceFailureStopsDispatch(); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs index 6b42bf36fb..d56bee20a6 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs @@ -425,7 +425,7 @@ async function testPersistenceRetriesRemainInsideTheWriteQueue() { if (args[0] === "claim-deep-scan-dedup" && calls.length === 1) { throw new Error("sqlite3.OperationalError: database is locked"); } - return {}; + return stateResult(scanId); }); const claim = store.claimDedup({ @@ -706,7 +706,7 @@ function idempotentPersistenceScenarios() { }) }, { operation: "claim-deep-scan-dedup", - result: {}, + result: stateResult(scanId), invoke: (store) => store.claimDedup({ id: reducerId, scanId, diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 5f035154ca..306057099f 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -19,8 +19,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from deep_scan_config import resolve_deep_scan_config from filesystem_identity import serialize_filesystem_identity -from finalize_scan_contract import _read_scan_local_json +from finalize_scan_contract import _read_scan_local_json, write_scan_local_bytes from workbench.handoff import require_current_continuation +from workbench_saved_results import _worker_checkpoint_head +from workbench_scan_usage import capture_scan_usage_owner from workbench_target import ( directory_content_digest, directory_snapshot_regular_file_count, @@ -454,16 +456,18 @@ def _deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, scan = require_scan(connection, run["scan_id"]) worker_rows = connection.execute( """ - SELECT * - FROM deep_scan_workers - WHERE scan_id = ? - ORDER BY created_at, id + SELECT workers.*, attempts.accepted_result_path + FROM deep_scan_workers AS workers + LEFT JOIN deep_scan_attempts AS attempts + ON attempts.worker_id = workers.id AND attempts.attempt = workers.attempt + WHERE workers.scan_id = ? + ORDER BY workers.created_at, workers.id """, (run["scan_id"],), ) input_rows = connection.execute( """ - SELECT dedup_worker_id, discovery_worker_id, input_order + SELECT * FROM deep_scan_dedup_inputs WHERE scan_id = ? ORDER BY dedup_worker_id, input_order @@ -504,6 +508,11 @@ def _deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, "schemaVersion": run["schema_version"], "workflowVersion": run["workflow_version"], "finalizationInput": deep_scan_finalization_input(run), + "usageOwner": ( + json.loads(run["usage_owner_json"]) + if "usage_owner_json" in run.keys() and run["usage_owner_json"] + else None + ), "coordinatorGeneration": run["coordinator_generation"], "status": run["status"], "phase": run["phase"], @@ -528,11 +537,55 @@ def _deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, "updatedAt": run["updated_at"], "completedAt": run["completed_at"], "workers": [deep_scan_worker_state(row) for row in worker_rows], + "attempts": [ + { + "workerId": row["worker_id"], + "attempt": row["attempt"], + "status": row["status"], + "startedAt": row["started_at"], + "completedAt": row["completed_at"], + "endReason": row["end_reason"], + "error": row["error_message"], + "acceptedResultPath": row["accepted_result_path"], + "acceptedResultSha256": row["accepted_result_sha256"], + } + for row in connection.execute( + "SELECT * FROM deep_scan_attempts WHERE scan_id = ? ORDER BY worker_id, attempt", + (scan_id,), + ) + ], + "attemptSessions": [ + { + "workerId": row["worker_id"], + "attempt": row["attempt"], + "sdkThreadId": row["sdk_thread_id"], + "observedAt": row["observed_at"], + } + for row in connection.execute( + "SELECT * FROM deep_scan_attempt_sessions WHERE scan_id = ? " + "ORDER BY observed_at, worker_id, attempt, sdk_thread_id", + (scan_id,), + ) + ], + "mergeClaims": [ + { + "workerId": row["worker_id"], + "previousWorkerId": row["previous_worker_id"], + "previousResultPath": row["previous_result_path"], + "previousResultSha256": row["previous_result_sha256"], + } + for row in connection.execute( + "SELECT * FROM deep_scan_merge_claims WHERE scan_id = ? ORDER BY rowid", (scan_id,) + ) + ], "dedupInputs": [ { "dedupWorkerId": row["dedup_worker_id"], "discoveryWorkerId": row["discovery_worker_id"], "inputOrder": row["input_order"], + "resultManifestPath": row["result_manifest_path"], + "resultManifestSha256": row["result_manifest_sha256"], + "attempt": row["attempt"], } for row in input_rows ], @@ -580,6 +633,9 @@ def deep_scan_worker_state(row: sqlite3.Row) -> dict[str, Any]: "promptPath": row["prompt_path"], "artifactDir": row["artifact_dir"], "resultManifestPath": row["result_manifest_path"], + "acceptedResultPath": row["accepted_result_path"] + if "accepted_result_path" in row.keys() + else None, "attempt": row["attempt"], "sdkThreadId": row["sdk_thread_id"], "completionSequence": row["completion_sequence"], @@ -648,7 +704,14 @@ def ensure_deep_scan_run( timestamp, ), ) - return require_deep_scan_run(connection, scan["id"]) + run = require_deep_scan_run(connection, scan["id"]) + if "usage_owner_json" in run.keys(): + connection.execute( + "UPDATE deep_scan_runs SET usage_owner_json = ? WHERE scan_id = ?", + (json.dumps(capture_scan_usage_owner(connection, scan)), scan["id"]), + ) + run = require_deep_scan_run(connection, scan["id"]) + return run def existing_deep_scan_for_target( @@ -1260,6 +1323,132 @@ def require_worker_transition(current: str, requested: str) -> None: raise SystemExit(f"Deep Scan worker cannot transition from {current} to {requested}.") +def snapshot_accepted_result(scan: sqlite3.Row, worker: sqlite3.Row) -> tuple[str, str]: + source = deep_scan_path( + scan, worker["result_manifest_path"], "Accepted worker result", kind="file" + ) + scan_dir = Path(scan["scan_dir"]) + contents = Path(source).read_bytes() + semantic = json.loads(contents) + if isinstance(semantic, dict): + semantic.pop("handoffClaimToken", None) + directory = Path(worker["artifact_dir"]) / "checkpoints" + head = ( + _worker_checkpoint_head( + scan_dir, Path(worker["artifact_dir"]).relative_to(scan_dir).as_posix(), scan["id"] + ) + if worker["kind"] == "discovery" + else None + ) + candidates = [scan_dir / head] if head else sorted(directory.glob("*.json")) + for checkpoint in candidates: + safe = deep_scan_path(scan, str(checkpoint), "Accepted worker checkpoint", kind="file") + checkpoint_bytes = Path(safe).read_bytes() + if json.loads(checkpoint_bytes) == semantic: + return safe, hashlib.sha256(checkpoint_bytes).hexdigest() + if head: + raise SystemExit( + "The accepted worker result does not match its current checkpoint head." + ) + # Legacy/direct file producers may have no checkpoint. Use the existing native + # checkpoint store; typed artifact writers already supplied the matching copy. + digest = hashlib.sha256(contents).hexdigest() + destination = directory / f"{digest}.json" + if destination.exists(): + raise SystemExit("An existing worker checkpoint does not match its accepted content.") + write_scan_local_bytes(scan_dir, destination.relative_to(scan_dir).as_posix(), contents) + return str(destination), digest + + +def record_worker_attempt( + connection: sqlite3.Connection, + scan: sqlite3.Row, + worker: sqlite3.Row, + timestamp: str, + *, + observed_thread_id: str | None = None, + error: str | None = None, + end_reason: str | None = None, +) -> None: + if worker["status"] == "queued" or worker["attempt"] < 1: + return + connection.execute( + """ + UPDATE deep_scan_attempts + SET status = 'replaced', completed_at = ?, end_reason = 'replacement_attempt' + WHERE worker_id = ? AND attempt < ? AND completed_at IS NULL + """, + (timestamp, worker["id"], worker["attempt"]), + ) + status = worker["status"] + if end_reason in DEEP_SCAN_REPLACEABLE_FAILURE_KINDS: + status = "failed" + if status != "running": + error = worker["error_message"] + if status == "running" and error: + status = "failed" + completed = timestamp if status != "running" else None + reason = end_reason or ( + "execution_or_artifact_error" if status == "failed" else status if completed else None + ) + accepted_path = accepted_sha = None + if status == "succeeded" and worker["result_manifest_path"]: + accepted_path, accepted_sha = snapshot_accepted_result(scan, worker) + connection.execute( + """ + INSERT INTO deep_scan_attempts ( + scan_id, worker_id, attempt, status, started_at, completed_at, + end_reason, error_message, accepted_result_path, accepted_result_sha256 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(worker_id, attempt) DO UPDATE SET + status = CASE WHEN deep_scan_attempts.completed_at IS NULL THEN excluded.status + ELSE deep_scan_attempts.status END, + completed_at = COALESCE(deep_scan_attempts.completed_at, excluded.completed_at), + end_reason = COALESCE(deep_scan_attempts.end_reason, excluded.end_reason), + error_message = COALESCE(excluded.error_message, deep_scan_attempts.error_message), + accepted_result_path = COALESCE(excluded.accepted_result_path, + deep_scan_attempts.accepted_result_path), + accepted_result_sha256 = COALESCE(excluded.accepted_result_sha256, + deep_scan_attempts.accepted_result_sha256) + """, + ( + scan["id"], + worker["id"], + worker["attempt"], + status, + timestamp, + completed, + reason, + error, + accepted_path, + accepted_sha, + ), + ) + if observed_thread_id: + connection.execute( + """ + INSERT OR IGNORE INTO deep_scan_attempt_sessions ( + scan_id, worker_id, attempt, sdk_thread_id, observed_at + ) VALUES (?, ?, ?, ?, ?) + """, + (scan["id"], worker["id"], worker["attempt"], observed_thread_id, timestamp), + ) + + +def worker_result_reference( + connection: sqlite3.Connection, scan: sqlite3.Row, worker: sqlite3.Row +) -> tuple[str, str]: + accepted = connection.execute( + "SELECT accepted_result_path, accepted_result_sha256 FROM deep_scan_attempts " + "WHERE worker_id = ? AND attempt = ?", + (worker["id"], worker["attempt"]), + ).fetchone() + if accepted is not None and accepted["accepted_result_path"]: + return accepted["accepted_result_path"], accepted["accepted_result_sha256"] + # Old accepted workers have no attempt history; freeze their current accepted result on claim. + return snapshot_accepted_result(scan, worker) + + def upsert_deep_scan_worker( connection: sqlite3.Connection, args: argparse.Namespace ) -> dict[str, Any]: @@ -1303,11 +1492,14 @@ def upsert_deep_scan_worker( scan, args.artifact_dir, "Worker artifact directory", kind="directory" ) result_manifest_path = ( - deep_scan_path( - scan, - args.result_manifest_path, - "Worker result manifest path", - kind="file", + ( + deep_scan_output_path( + scan, args.result_manifest_path, "Worker result manifest path" + ) + if terminal_repeat + else deep_scan_path( + scan, args.result_manifest_path, "Worker result manifest path", kind="file" + ) ) if args.result_manifest_path else None @@ -1366,8 +1558,18 @@ def upsert_deep_scan_worker( timestamp, ), ) + record_worker_attempt( + connection, + scan, + require_deep_scan_worker(connection, worker_id), + timestamp, + observed_thread_id=optional_text(args.sdk_thread_id, maximum=512), + error=optional_text(args.error_message, maximum=2400), + end_reason=args.replaceable_failure_kind, + ) + result = deep_scan_result(connection, scan_id) connection.commit() - return deep_scan_result(connection, scan_id) + return result if existing["scan_id"] != scan_id or existing["kind"] != args.kind: raise SystemExit("Deep Scan worker identity does not match its persisted run and kind.") @@ -1390,8 +1592,15 @@ def upsert_deep_scan_worker( and repeated_error != existing["error_message"] ): raise SystemExit("Deep Scan worker terminal state is immutable.") + receipt = connection.execute( + "SELECT receipt_json FROM deep_scan_attempts WHERE worker_id = ? AND attempt = ?", + (worker_id, existing["attempt"]), + ).fetchone() + result = deep_scan_result(connection, scan_id) + if receipt is not None and receipt["receipt_json"]: + result["deepScan"]["workerReceipt"] = json.loads(receipt["receipt_json"]) connection.commit() - return deep_scan_result(connection, scan_id) + return result attempt = args.attempt if args.attempt is not None else existing["attempt"] if attempt < existing["attempt"]: raise SystemExit("Deep Scan worker attempt cannot decrease.") @@ -1487,11 +1696,30 @@ def upsert_deep_scan_worker( worker_id, ), ) + record_worker_attempt( + connection, + scan, + require_deep_scan_worker(connection, worker_id), + timestamp, + observed_thread_id=optional_text(args.sdk_thread_id, maximum=512), + error=optional_text(args.error_message, maximum=2400), + end_reason=args.replaceable_failure_kind, + ) + result = deep_scan_result(connection, scan_id) + if args.status in {"succeeded", "failed", "canceled"}: + receipt = next( + worker for worker in result["deepScan"]["workers"] if worker["id"] == worker_id + ) + result["deepScan"]["workerReceipt"] = receipt + connection.execute( + "UPDATE deep_scan_attempts SET receipt_json = ? WHERE worker_id = ? AND attempt = ?", + (json.dumps(receipt), worker_id, attempt), + ) connection.commit() except BaseException: connection.rollback() raise - return deep_scan_result(connection, scan_id) + return result def claim_deep_scan_dedup( @@ -1504,7 +1732,8 @@ def claim_deep_scan_dedup( raise SystemExit("Dedup input worker IDs must be unique.") connection.execute("BEGIN IMMEDIATE") try: - run, scan = require_running_deep_scan(connection, scan_id) + run = require_deep_scan_run(connection, scan_id) + scan = require_scan(connection, scan_id) require_current_coordinator(run, args) prompt_path = deep_scan_path(scan, args.prompt_path, "Dedup prompt path", kind="file") artifact_dir = deep_scan_path( @@ -1533,9 +1762,11 @@ def claim_deep_scan_dedup( and existing["artifact_dir"] == artifact_dir and persisted_inputs == input_ids ): + result = deep_scan_result(connection, scan_id) connection.commit() - return deep_scan_result(connection, scan_id) + return result raise SystemExit("Dedup worker ID is already used by a different reducer claim.") + require_running_deep_scan(connection, scan_id) active_reducer = connection.execute( """ SELECT 1 FROM deep_scan_workers @@ -1603,14 +1834,41 @@ def claim_deep_scan_dedup( """, (worker_id, scan_id, prompt_path, artifact_dir, timestamp, timestamp), ) + previous = connection.execute( + "SELECT * FROM deep_scan_workers WHERE scan_id = ? AND kind = 'dedup' " + "AND status = 'succeeded' ORDER BY completed_at DESC, rowid DESC LIMIT 1", + (scan_id,), + ).fetchone() + previous_path, previous_sha = ( + worker_result_reference(connection, scan, previous) if previous else (None, None) + ) + connection.execute( + """ + INSERT INTO deep_scan_merge_claims ( + worker_id, scan_id, previous_worker_id, previous_result_path, previous_result_sha256 + ) VALUES (?, ?, ?, ?, ?) + """, + (worker_id, scan_id, previous["id"] if previous else None, previous_path, previous_sha), + ) for input_order, input_id in enumerate(input_ids): + discovery = require_deep_scan_worker(connection, input_id) + accepted_path, accepted_sha = worker_result_reference(connection, scan, discovery) connection.execute( """ INSERT INTO deep_scan_dedup_inputs ( - scan_id, dedup_worker_id, discovery_worker_id, input_order - ) VALUES (?, ?, ?, ?) + scan_id, dedup_worker_id, discovery_worker_id, input_order, + result_manifest_path, result_manifest_sha256, attempt + ) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (scan_id, worker_id, input_id, input_order), + ( + scan_id, + worker_id, + input_id, + input_order, + accepted_path, + accepted_sha, + discovery["attempt"], + ), ) connection.execute( f""" @@ -1632,11 +1890,12 @@ def claim_deep_scan_dedup( """, (timestamp, scan_id), ) + result = deep_scan_result(connection, scan_id) connection.commit() except BaseException: connection.rollback() raise - return deep_scan_result(connection, scan_id) + return result def commit_deep_scan_dedup( @@ -1662,8 +1921,14 @@ def commit_deep_scan_dedup_locked( if worker["scan_id"] != scan_id or worker["kind"] != "dedup": raise SystemExit("Dedup worker does not belong to this Deep Scan.") if worker["status"] == "succeeded": + receipt = connection.execute( + "SELECT receipt_json FROM deep_scan_merge_claims WHERE worker_id = ?", (worker_id,) + ).fetchone() + result = deep_scan_result(connection, scan_id) + if receipt is not None and receipt["receipt_json"]: + result["deepScan"]["committedMerge"] = json.loads(receipt["receipt_json"]) connection.commit() - return deep_scan_result(connection, scan_id) + return result require_running_deep_scan(connection, scan_id) if worker["status"] not in {"queued", "running"}: raise SystemExit("Only an active dedup worker can commit a result.") @@ -1709,6 +1974,23 @@ def commit_deep_scan_dedup_locked( ) if not inputs or any(row["merge_state"] != "merging" for row in inputs): raise SystemExit("Dedup inputs are not in the claimed merging state.") + claim = connection.execute( + "SELECT * FROM deep_scan_merge_claims WHERE worker_id = ?", (worker_id,) + ).fetchone() + references = [ + (row["result_manifest_path"], row["result_manifest_sha256"]) + for row in connection.execute( + "SELECT * FROM deep_scan_dedup_inputs WHERE dedup_worker_id = ? ORDER BY input_order", + (worker_id,), + ) + ] + if claim is not None and claim["previous_result_path"]: + references.append((claim["previous_result_path"], claim["previous_result_sha256"])) + for path, digest in references: + if path is not None and digest is not None: + safe_path = deep_scan_path(scan, path, "Claimed reducer input", kind="file") + if hashlib.sha256(Path(safe_path).read_bytes()).hexdigest() != digest: + raise SystemExit("A claimed Deep Scan reducer input changed after acceptance.") if candidate_ledger_path and canonical_candidate_ledger_path: canonical_path = Path(canonical_candidate_ledger_path) publication_copy = canonical_path.with_name( @@ -1752,6 +2034,27 @@ def commit_deep_scan_dedup_locked( """, (no_new_streak, timestamp, scan_id), ) + committed_worker = require_deep_scan_worker(connection, worker_id) + record_worker_attempt( + connection, + scan, + committed_worker, + timestamp, + observed_thread_id=committed_worker["sdk_thread_id"], + ) + accepted_path, accepted_sha = worker_result_reference(connection, scan, committed_worker) + result = deep_scan_result(connection, scan_id) + result["deepScan"]["committedMerge"] = { + "workerId": worker_id, + "resultManifestPath": accepted_path, + "resultManifestSha256": accepted_sha, + "newFindings": args.new_findings_count, + } + connection.execute( + "INSERT INTO deep_scan_merge_claims (worker_id, scan_id, receipt_json) VALUES (?, ?, ?) " + "ON CONFLICT(worker_id) DO UPDATE SET receipt_json = excluded.receipt_json", + (worker_id, scan_id, json.dumps(result["deepScan"]["committedMerge"])), + ) connection.commit() except BaseException: connection.rollback() @@ -1764,7 +2067,7 @@ def commit_deep_scan_dedup_locked( finish_staged_file(promotion) if publication_copy is not None: publication_copy.unlink(missing_ok=True) - return deep_scan_result(connection, scan_id) + return result def finish_deep_scan( @@ -2311,6 +2614,11 @@ def cancel_from_parent_scan(connection: sqlite3.Connection, scan_id: str, timest def cancel_active_workers(connection: sqlite3.Connection, scan_id: str, timestamp: str) -> None: + connection.execute( + "UPDATE deep_scan_attempts SET status = 'canceled', completed_at = ?, " + "end_reason = 'scan_stopped' WHERE scan_id = ? AND completed_at IS NULL", + (timestamp, scan_id), + ) connection.execute( """ UPDATE deep_scan_workers diff --git a/plugins/codex-security/tests/test_deep_scan_persistence.py b/plugins/codex-security/tests/test_deep_scan_persistence.py index bd9fe1392f..0f2f0e67e9 100644 --- a/plugins/codex-security/tests/test_deep_scan_persistence.py +++ b/plugins/codex-security/tests/test_deep_scan_persistence.py @@ -1,11 +1,21 @@ from __future__ import annotations +import json import sqlite3 +import subprocess import sys +import uuid from pathlib import Path import pytest -from test_workbench_deep_scan import begin_target_scan, dispatch_discovery_worker +from test_workbench_deep_scan import ( + begin_target_scan, + commit_reducer, + dispatch_discovery_worker, + upsert_worker, + worker_paths, +) +from workbench_test_support import run_workbench def test_state_snapshot_does_not_mix_concurrent_acceptance( @@ -70,3 +80,331 @@ def test_state_snapshot_preserves_its_callers_transaction( assert connection.in_transaction connection.rollback() assert deep_scan.deep_scan_state(connection, scan_id)["consecutiveErrors"] == 0 + + +def test_replaced_attempts_retain_observed_sessions_and_accepted_result(tmp_path: Path) -> None: + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + run = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + worker_id = str(uuid.uuid4()) + prompt, artifacts, result = worker_paths(Path(run["scanDir"]), "discovery-1") + mutation = dict( + scan_id=run["scanId"], + worker_id=worker_id, + kind="discovery", + prompt_path=prompt, + artifact_dir=artifacts, + ) + upsert_worker(state, home, **mutation, status="running", attempt=1, thread_id="old-session") + upsert_worker( + state, + home, + **mutation, + status="running", + attempt=1, + thread_id="old-session", + error="Artifact validation failed", + ) + upsert_worker(state, home, **mutation, status="running", attempt=2, thread_id="new-session") + result.write_text('{"findings": []}\n') + accepted = upsert_worker( + state, + home, + **mutation, + status="succeeded", + attempt=2, + thread_id="new-session", + result_path=result, + )["deepScan"] + attempts = accepted["attempts"] + assert [(item["attempt"], item["status"]) for item in attempts] == [ + (1, "failed"), + (2, "succeeded"), + ] + assert [item["sdkThreadId"] for item in accepted["attemptSessions"]] == [ + "old-session", + "new-session", + ] + assert attempts[0]["error"] == "Artifact validation failed" + assert attempts[0]["completedAt"] is not None + assert attempts[1]["acceptedResultSha256"] + assert Path(attempts[1]["acceptedResultPath"]).read_text() == result.read_text() + result.unlink() + replayed = upsert_worker( + state, + home, + **mutation, + status="succeeded", + attempt=2, + thread_id="new-session", + result_path=result, + )["deepScan"] + assert replayed == accepted + + +def test_merge_replay_returns_original_operation_after_later_work(tmp_path: Path) -> None: + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + run = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + scan_id, scan_dir = run["scanId"], Path(run["scanDir"]) + inputs = [ + dispatch_discovery_worker( + state, + home, + scan_id=scan_id, + scan_dir=scan_dir, + name=f"discovery-{index}", + )[0] + for index in range(2) + ] + committed = commit_reducer( + state, + home, + scan_id=scan_id, + scan_dir=scan_dir, + name="dedup-1", + input_worker_ids=inputs, + new_findings_count=0, + ) + reducer = next(worker for worker in committed["workers"] if worker["kind"] == "dedup") + frozen = committed["committedMerge"]["resultManifestPath"] + assert Path(frozen).is_file() + Path(reducer["resultManifestPath"]).unlink() + assert [item["discoveryWorkerId"] for item in committed["dedupInputs"]] == inputs + assert all(item["attempt"] == 1 for item in committed["dedupInputs"]) + assert all("/checkpoints/" in item["resultManifestPath"] for item in committed["dedupInputs"]) + later = dispatch_discovery_worker( + state, + home, + scan_id=scan_id, + scan_dir=scan_dir, + name="discovery-2", + )[0] + second_commit = commit_reducer( + state, + home, + scan_id=scan_id, + scan_dir=scan_dir, + name="dedup-2", + input_worker_ids=[later], + new_findings_count=1, + ) + second_claim = second_commit["mergeClaims"][-1] + assert second_claim["previousWorkerId"] == reducer["id"] + assert second_claim["previousResultPath"] == frozen + assert ( + second_claim["previousResultSha256"] == committed["committedMerge"]["resultManifestSha256"] + ) + replay = run_workbench( + state, + "commit-deep-scan-dedup", + "--scan-id", + scan_id, + "--worker-id", + reducer["id"], + "--result-manifest-path", + str(scan_dir / "different.json"), + "--new-findings-count", + "99", + environment={"CODEX_HOME": str(home)}, + )["deepScan"] + assert replay["committedMerge"] == committed["committedMerge"] + assert replay["completionSequence"] == second_commit["completionSequence"] + assert replay["noNewStreak"] == second_commit["noNewStreak"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + receipt = json.loads( + connection.execute( + "SELECT receipt_json FROM deep_scan_merge_claims WHERE worker_id = ?", + (reducer["id"],), + ).fetchone()[0] + ) + assert receipt == committed["committedMerge"] + + +def test_native_usage_keeps_replaced_failed_canceled_attempts_and_descendants( + tmp_path: Path, workbench_api, monkeypatch: pytest.MonkeyPatch +) -> None: + from datetime import datetime, timedelta + + from test_workbench_scan_usage import _counts, _event, _rollout, _state_graph, _token_event + + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + run = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + worker_id = str(uuid.uuid4()) + prompt, artifacts, _ = worker_paths(Path(run["scanDir"]), "discovery-1") + mutation = dict( + scan_id=run["scanId"], + worker_id=worker_id, + kind="discovery", + prompt_path=prompt, + artifact_dir=artifacts, + ) + upsert_worker(state, home, **mutation, status="running", attempt=1, thread_id="old") + upsert_worker(state, home, **mutation, status="running", attempt=2, thread_id="old") + upsert_worker( + state, + home, + **mutation, + status="running", + attempt=2, + thread_id="old", + error="fixture failure", + ) + upsert_worker(state, home, **mutation, status="running", attempt=3, thread_id="replacement") + terminal = upsert_worker( + state, home, **mutation, status="canceled", attempt=3, thread_id="replacement" + )["deepScan"] + assert [item["status"] for item in terminal["attempts"]] == ["replaced", "failed", "canceled"] + environment = { + "CODEX_HOME": str(home), + "CODEX_SQLITE_HOME": str(tmp_path / "native"), + "CODEX_STATE_DB": "", + } + for key, value in environment.items(): + monkeypatch.setenv(key, value) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.row_factory = sqlite3.Row + scan = connection.execute("SELECT * FROM scans WHERE id = ?", (run["scanId"],)).fetchone() + timestamp = datetime.fromisoformat(scan["started_at"]) + timedelta(microseconds=1) + context = _event( + timestamp, "turn_context", {"turn_id": "fixture-turn", "model": "gpt-5.6-sol"} + ) + old = _rollout(tmp_path, "old", [context]) + _state_graph( + environment, + { + "thread-deep-scan": _rollout(tmp_path, "thread-deep-scan", []), + "old": old, + "replacement": _rollout( + tmp_path, "replacement", [context, _token_event(timestamp, 30, 0)] + ), + "child": _rollout( + tmp_path, + "child", + [context, _token_event(timestamp, 5, 0)], + parent_thread_id="old", + ), + "unrelated": _rollout( + tmp_path, + "unrelated", + [context, _token_event(timestamp, 900, 0)], + parent_thread_id="thread-deep-scan", + ), + }, + [("old", "child"), ("thread-deep-scan", "unrelated")], + ) + reader = sys.modules["workbench_scan_usage"] + pending = reader.collect_scan_usage(connection, scan) + assert pending["inputTokens"] == 35 + assert pending["missingThreadCount"] == 2 + old.write_text(old.read_text() + json.dumps(_token_event(timestamp, 20, 0)) + "\n") + measured = reader.collect_scan_usage(connection, scan) + assert measured["inputTokens"] == 55 + assert measured["threadCount"] == 3 + assert measured["missingThreadCount"] == 1 + assert measured["coverage"] == "partial" # Original shared parent turn was unavailable. + assert measured["modelUsage"] == [{"model": "gpt-5.6-sol", **_counts(55, 0, 0)}] + + +def test_claim_replay_preserves_original_inputs_after_concurrent_discovery(tmp_path: Path) -> None: + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + run = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + scan_id, scan_dir = run["scanId"], Path(run["scanDir"]) + inputs = [ + dispatch_discovery_worker( + state, home, scan_id=scan_id, scan_dir=scan_dir, name=f"discovery-{index}" + )[0] + for index in range(2) + ] + prompt, artifacts, _ = worker_paths(scan_dir, "reducer") + args = [ + "claim-deep-scan-dedup", + "--scan-id", + scan_id, + "--worker-id", + str(uuid.uuid4()), + "--prompt-path", + str(prompt), + "--artifact-dir", + str(artifacts), + ] + for worker in inputs: + args.extend(["--input-worker-id", worker]) + claimed = run_workbench(state, *args, environment={"CODEX_HOME": str(home)}) + dispatch_discovery_worker( + state, home, scan_id=scan_id, scan_dir=scan_dir, name="concurrent-discovery" + ) + replayed = run_workbench(state, *args, environment={"CODEX_HOME": str(home)}) + assert replayed["deepScan"]["mergeClaims"] == claimed["deepScan"]["mergeClaims"] + assert replayed["deepScan"]["dedupInputs"] == claimed["deepScan"]["dedupInputs"] + assert ( + replayed["deepScan"]["completionSequence"] == claimed["deepScan"]["completionSequence"] + 1 + ) + + +def test_acceptance_reuses_authoritative_checkpoint_without_rewriting(tmp_path: Path) -> None: + import hashlib + + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + run = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + worker_id = str(uuid.uuid4()) + prompt, artifacts, result = worker_paths(Path(run["scanDir"]), "discovery") + mutation = dict( + scan_id=run["scanId"], + worker_id=worker_id, + kind="discovery", + prompt_path=prompt, + artifact_dir=artifacts, + attempt=1, + ) + upsert_worker(state, home, **mutation, status="running") + draft = {"scanId": run["scanId"], "findings": [], "coverage": {}} + content = json.dumps(draft, indent=2).encode() + b"\n" + checkpoint = artifacts / "checkpoints" / f"{hashlib.sha256(content).hexdigest()}.json" + checkpoint.parent.mkdir() + checkpoint.write_bytes(content) + (artifacts / "checkpoint-head.json").write_text(json.dumps({"checkpoint": checkpoint.name})) + result.write_text(json.dumps({**draft, "handoffClaimToken": "synthetic-claim"})) + accepted = upsert_worker(state, home, **mutation, status="succeeded", result_path=result)[ + "deepScan" + ] + assert accepted["attempts"][0]["acceptedResultPath"] == str(checkpoint) + assert accepted["attempts"][0]["acceptedResultSha256"] == hashlib.sha256(content).hexdigest() + result.unlink() + upsert_worker(state, home, **mutation, status="succeeded", result_path=result) + assert list(checkpoint.parent.iterdir()) == [checkpoint] + assert checkpoint.read_bytes() == content + assert not (artifacts / "accepted").exists() + + +def test_acceptance_rejects_mutable_result_behind_checkpoint_head(tmp_path: Path) -> None: + import hashlib + + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + run = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + worker_id = str(uuid.uuid4()) + prompt, artifacts, result = worker_paths(Path(run["scanDir"]), "discovery") + mutation = dict( + scan_id=run["scanId"], + worker_id=worker_id, + kind="discovery", + prompt_path=prompt, + artifact_dir=artifacts, + attempt=1, + ) + upsert_worker(state, home, **mutation, status="running") + draft = {"scanId": run["scanId"], "findings": [], "coverage": {"deferred": ["unresolved"]}} + content = json.dumps(draft).encode() + checkpoint = artifacts / "checkpoints" / f"{hashlib.sha256(content).hexdigest()}.json" + checkpoint.parent.mkdir() + checkpoint.write_bytes(content) + (artifacts / "checkpoint-head.json").write_text(json.dumps({"checkpoint": checkpoint.name})) + result.write_text(json.dumps({**draft, "coverage": {}})) + with pytest.raises(subprocess.CalledProcessError) as failure: + upsert_worker(state, home, **mutation, status="succeeded", result_path=result) + assert "does not match its current checkpoint head" in failure.value.stderr + assert checkpoint.read_bytes() == content diff --git a/plugins/codex-security/tests/test_workbench_deep_scan.py b/plugins/codex-security/tests/test_workbench_deep_scan.py index bc8de80585..9d598c4bbf 100644 --- a/plugins/codex-security/tests/test_workbench_deep_scan.py +++ b/plugins/codex-security/tests/test_workbench_deep_scan.py @@ -1886,10 +1886,21 @@ def test_failed_reducer_rebuffers_claimed_inputs_for_same_generation_replacement error="fixture reducer exhausted its attempts", coordinator_generation=2, )["deepScan"] - replayed_workers = {worker["id"]: worker for worker in replayed["workers"]} + assert replayed["workerReceipt"] == failed["workerReceipt"] assert replayed["phase"] == "reducing" - assert replayed["consecutiveErrors"] == counter_before_failure - assert all(replayed_workers[worker]["mergeState"] == "merging" for worker in replacement_inputs) + current = run_workbench( + state_dir, + "get-deep-scan", + "--scan-id", + scan_id, + "--thread-id", + "thread-deep-scan", + environment=deep_environment(codex_home), + )["deepScan"] + current_workers = {worker["id"]: worker for worker in current["workers"]} + assert current["phase"] == "reducing" + assert current["consecutiveErrors"] == counter_before_failure + assert all(current_workers[worker]["mergeState"] == "merging" for worker in replacement_inputs) upsert_worker( state_dir, diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 4b95d3b7c6..b8fdb3aa1a 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -623,10 +623,13 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N ) usage = _complete_scan(fixture)["scan"]["usage"] assert usage == { - "coverage": "complete", + "coverage": "partial", "source": "codex_rollout", - **_counts(37, 0, 10), - "threadCount": 3, + **_counts(27, 0, 7), + "threadCount": 2, + "missingThreadCount": 1, + "warnings": ["scan_owner_turn_unavailable"], + "modelUsage": [{"model": None, **_counts(27, 0, 7)}], } From 4b903ba6e839ac1906bf02eac2bcac532286a835 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:07:32 +0000 Subject: [PATCH 049/251] Count native charged responses and retain usage uncertainty --- .../scripts/workbench_scan_usage.py | 83 ++++++++++- .../tests/test_deep_scan_usage_owner.py | 99 +++++++++++++ .../tests/test_workbench_scan_usage.py | 139 +++++++++++++++++- 3 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 plugins/codex-security/tests/test_deep_scan_usage_owner.py diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 68cdaea92e..b98168e2f7 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -91,6 +91,8 @@ def collect_scan_usage( """Count only complete, attributable rollout events inside this scan's window.""" attribution = scan_execution_attribution(connection, scan) + if attribution and attribution.get("legacy"): + attribution = None roots = ( list( dict.fromkeys( @@ -309,14 +311,14 @@ def scan_execution_attribution( if run is None: return None owner_json = run["usage_owner_json"] if "usage_owner_json" in run.keys() else None + legacy = False if owner_json is None: - if ( + legacy = ( connection.execute( "SELECT 1 FROM deep_scan_attempts WHERE scan_id = ? LIMIT 1", (scan["id"],) ).fetchone() is None - ): - return None + ) roots = _scan_root_thread_ids(connection, scan, None) owner = { "threadId": roots[0] if roots else None, @@ -331,6 +333,7 @@ def scan_execution_attribution( executions.append(owner["threadId"]) return { "formatVersion": 1, + **({"legacy": True} if legacy else {}), "executionThreadIds": executions, "owner": owner, "startedAt": scan["started_at"], @@ -524,12 +527,18 @@ def _read_rollout_usage( model_usage: dict[str | None, dict[str, int]] | None = None, ) -> tuple[dict[str, int], set[str]]: total = _empty_token_usage() + counter_total = _empty_token_usage() warnings: set[str] = set() previous = _empty_token_usage() boundary_reached = False usage_observed = False current_turn_id: str | None = None current_model: str | None = None + response_ids: set[str] = set() + response_usage_observed = False + response_tokens = 0 + expected_response_tokens = 0 + local_models: dict[str | None, dict[str, int]] = {} with session.path.open("rb") as source: for line_number, raw_line in enumerate(source, start=1): @@ -596,6 +605,49 @@ def _read_rollout_usage( if inherited_usage is not None: previous = inherited_usage continue + if event.get("type") == "token_usage_record": + response_id = payload.get("response_id") + usage = _token_snapshot({"info": {"total_token_usage": payload.get("usage")}}) + if ( + not isinstance(response_id, str) + or usage is None + or payload.get("thread_id", session.thread_id) != session.thread_id + or response_id in response_ids + ): + continue + response_ids.add(response_id) + cumulative = _token_snapshot( + {"info": {"total_token_usage": payload.get("thread_token_usage")}} + ) + if cumulative is not None: + expected_response_tokens = max( + expected_response_tokens, cumulative["totalTokens"] + ) + if not response_usage_observed: + response_usage_observed = True + total = _empty_token_usage() + local_models = {} + response_tokens += usage["totalTokens"] + timestamp = _timestamp(event.get("timestamp")) + if timestamp is None: + warnings.add("token_record_invalid") + continue + if timestamp < started_at or ( + completed_at is not None and timestamp > completed_at + ): + continue + if ( + owner_turn_id is not None + and payload.get("turn_id", current_turn_id) != owner_turn_id + ): + continue + usage_observed = True + model = payload.get("model", current_model) + if not isinstance(model, str): + model = None + _add_token_usage(total, usage) + _add_token_usage(local_models.setdefault(model, _empty_token_usage()), usage) + continue if event.get("type") != "event_msg" or payload.get("type") != "token_count": continue timestamp = _timestamp(event.get("timestamp")) @@ -604,6 +656,7 @@ def _read_rollout_usage( warnings.add("token_record_invalid") continue if snapshot["totalTokens"] < previous["totalTokens"]: + warnings.add("token_counter_regressed") continue delta = {key: max(0, value - previous[key]) for key, value in snapshot.items()} previous = snapshot @@ -614,14 +667,28 @@ def _read_rollout_usage( if owner_turn_id is not None and current_turn_id != owner_turn_id: continue usage_observed = True - if model_usage is not None: - model_usage.setdefault(current_model, _empty_token_usage()) + if not response_usage_observed: + local_models.setdefault(current_model, _empty_token_usage()) if delta["totalTokens"] <= 0: continue - _add_token_usage(total, delta) - if model_usage is not None: - _add_token_usage(model_usage.setdefault(current_model, _empty_token_usage()), delta) + _add_token_usage(counter_total, delta) + if not response_usage_observed: + _add_token_usage(total, delta) + _add_token_usage( + local_models.setdefault(current_model, _empty_token_usage()), delta + ) + if counter_total["totalTokens"] > total["totalTokens"]: + remainder = {key: max(0, value - total[key]) for key, value in counter_total.items()} + total = dict(counter_total) + _add_token_usage(local_models.setdefault(None, _empty_token_usage()), remainder) + if response_usage_observed: + warnings.discard("token_counter_regressed") + if expected_response_tokens > response_tokens: + warnings.add("token_receipts_incomplete") + if model_usage is not None: + for model, usage in local_models.items(): + _add_token_usage(model_usage.setdefault(model, _empty_token_usage()), usage) if not boundary_reached: warnings.add("thread_ownership_unavailable") elif not usage_observed: diff --git a/plugins/codex-security/tests/test_deep_scan_usage_owner.py b/plugins/codex-security/tests/test_deep_scan_usage_owner.py new file mode 100644 index 0000000000..9b5cb237cd --- /dev/null +++ b/plugins/codex-security/tests/test_deep_scan_usage_owner.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from test_workbench_scan_usage import _event, _rollout, _state_graph +from workbench_test_support import run_workbench + + +def test_original_usage_turn_survives_join_and_coordinator_recovery(tmp_path: Path) -> None: + state = tmp_path / "state" + environment = { + "CODEX_HOME": str(tmp_path / "codex"), + "CODEX_SQLITE_HOME": str(tmp_path / "native"), + "CODEX_STATE_DB": "", + } + timestamp = datetime.now(timezone.utc) + rollout = _rollout( + tmp_path, + "shared-parent", + [ + _event(timestamp, "turn_context", {"turn_id": "original-turn", "model": "gpt-5.6-sol"}), + ], + ) + _state_graph(environment, {"shared-parent": rollout}, []) + target = tmp_path / "target" + target.mkdir() + begun = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "shared-parent", + "--target-path", + str(target), + "--scope", + ".", + "--scan-root", + str(tmp_path / "scans"), + environment=environment, + )["deepScan"] + owner = begun["usageOwner"] + assert owner["threadId"] == "shared-parent" + assert owner["turnId"] == "original-turn" + assert owner["dedicated"] is False + rollout.write_text( + rollout.read_text() + + json.dumps( + _event(timestamp, "turn_context", {"turn_id": "later-turn", "model": "gpt-6-astra"}) + ) + + "\n" + ) + joined = run_workbench( + state, + "begin-deep-scan", + "--scan-id", + begun["scanId"], + "--thread-id", + "shared-parent", + environment=environment, + )["deepScan"] + assert joined["usageOwner"] == owner + claim_args = [ + "claim-deep-scan-coordinator", + "--scan-id", + begun["scanId"], + "--thread-id", + "shared-parent", + ] + claimed = run_workbench(state, *claim_args, environment=environment)["deepScan"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET updated_at = '2000-01-01T00:00:00+00:00' WHERE scan_id = ?", + (begun["scanId"],), + ) + recovered = run_workbench(state, *claim_args, environment=environment)["deepScan"] + assert recovered["coordinatorGeneration"] == claimed["coordinatorGeneration"] + 1 + assert recovered["usageOwner"] == owner + other_target = tmp_path / "other-target" + other_target.mkdir() + other = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "shared-parent", + "--target-path", + str(other_target), + "--scope", + ".", + "--scan-root", + str(tmp_path / "scans"), + environment=environment, + )["deepScan"] + assert other["usageOwner"]["turnId"] == "later-turn" + original = run_workbench( + state, "get-scan", "--scan-id", begun["scanId"], environment=environment + )["scan"] + assert original["executionAttribution"]["owner"] == owner diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index b8fdb3aa1a..de46cd405d 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -726,7 +726,7 @@ def test_rollout_usage_reconciles_stale_cumulative_events_and_models( completed_at=None, ) assert counts == _counts(200, 0, 20) - assert warnings == set() + assert warnings == {"token_counter_regressed"} models = {} counts, warnings = usage_reader._read_rollout_usage( usage_reader.RolloutSession("worker", None, rollout), @@ -735,7 +735,7 @@ def test_rollout_usage_reconciles_stale_cumulative_events_and_models( model_usage=models, ) assert counts == _counts(200, 0, 20) - assert warnings == set() + assert warnings == {"token_counter_regressed"} assert models == {"gpt-5.6-sol": _counts(100, 0, 10), "gpt-6-astra": _counts(100, 0, 10)} @@ -774,3 +774,138 @@ def test_shared_parent_usage_requires_original_turn_and_scan_interval( assert counts == _counts(10, 0, 2) assert warnings == set() assert models == {"gpt-6-astra": _counts(10, 0, 2)} + + +@pytest.mark.parametrize("counters", [False, True]) +def test_response_receipts_count_compaction_once_across_resets( + tmp_path: Path, workbench_api, counters: bool +) -> None: + reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + + def usage(input_tokens, cached, output): + return dict( + input_tokens=input_tokens, + cached_input_tokens=cached, + cache_write_input_tokens=0, + output_tokens=output, + reasoning_output_tokens=0, + total_tokens=input_tokens + output, + ) + + def receipt(response, count, cumulative, model="gpt-5.6-sol", turn="scan-turn", second=1): + return _event( + start + timedelta(seconds=second), + "token_usage_record", + dict( + response_id=response, + thread_id="parent", + turn_id=turn, + model=model, + usage=count, + thread_token_usage=cumulative, + ), + ) + + first = receipt("first", usage(100, 80, 10), usage(100, 80, 10)) + compact = receipt("compaction", usage(50, 40, 5), usage(150, 120, 15), model="gpt-6-astra") + second = receipt("second", usage(120, 90, 12), usage(120, 90, 12)) + events = [ + first, + *([_token_event(start + timedelta(seconds=1), 100, 10)] if counters else []), + compact, + _event(start + timedelta(seconds=1), "compacted", {"message": "Synthetic summary"}), + compact, + second, + *([_token_event(start + timedelta(seconds=1), 220, 22)] if counters else []), + first, + receipt("other", usage(900, 0, 0), usage(900, 0, 0), turn="other-turn"), + receipt("post", usage(800, 0, 0), usage(1700, 0, 0), second=11), + ] + models = {} + total, warnings = reader._read_rollout_usage( + reader.RolloutSession("parent", None, _rollout(tmp_path, "parent", events)), + started_at=start, + completed_at=start + timedelta(seconds=10), + owner_turn_id="scan-turn", + model_usage=models, + ) + assert total == _counts(270, 210, 27) + assert warnings == set() + assert models == {"gpt-5.6-sol": _counts(220, 170, 22), "gpt-6-astra": _counts(50, 40, 5)} + + +def test_delayed_response_receipt_resolves_missing_cumulative_usage( + tmp_path: Path, workbench_api +) -> None: + reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + + def receipt(response, tokens, cumulative): + def usage(value): + return dict(input_tokens=value, output_tokens=0, total_tokens=value) + + return _event( + start, + "token_usage_record", + dict( + response_id=response, + thread_id="parent", + model="gpt-5.6-sol", + usage=usage(tokens), + thread_token_usage=usage(cumulative), + ), + ) + + rollout = _rollout(tmp_path, "parent", [receipt("first", 100, 100), receipt("third", 50, 180)]) + session = reader.RolloutSession("parent", None, rollout) + total, warnings = reader._read_rollout_usage(session, started_at=start, completed_at=None) + assert total == _counts(150, 0, 0) + assert warnings == {"token_receipts_incomplete"} + with rollout.open("a") as source: + source.write(json.dumps(receipt("second", 30, 130)) + "\n") + total, warnings = reader._read_rollout_usage(session, started_at=start, completed_at=None) + assert total == _counts(180, 0, 0) + assert warnings == set() + + +def test_exact_receipts_replace_overlapping_legacy_counter(tmp_path: Path, workbench_api) -> None: + reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + + def receipt(response, tokens, cumulative): + def usage(value): + return dict(input_tokens=value, output_tokens=0, total_tokens=value) + + return _event( + start, + "token_usage_record", + dict( + response_id=response, + thread_id="parent", + model="gpt-5.6-sol", + usage=usage(tokens), + thread_token_usage=usage(cumulative), + ), + ) + + rollout = _rollout( + tmp_path, + "parent", + [ + _token_event(start, 100, 0), + receipt("new", 10, 110), + receipt("old", 100, 100), + _token_event(start, 10, 0), + ], + ) + models = {} + total, warnings = reader._read_rollout_usage( + reader.RolloutSession("parent", None, rollout), + started_at=start, + completed_at=None, + model_usage=models, + ) + assert total == _counts(110, 0, 0) + assert warnings == set() + assert models == {"gpt-5.6-sol": _counts(110, 0, 0)} From 064fafcc075087e2508a14ba264b4ac9fbc995f7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:24:06 +0000 Subject: [PATCH 050/251] refactor: keep execution projections with scan usage --- plugins/codex-security/scripts/workbench_db.py | 4 +--- plugins/codex-security/scripts/workbench_scan_usage.py | 8 ++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index d48a83ed84..2ded9cd584 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -2841,9 +2841,7 @@ def scan_result( **scan_usage.stored_scan_cost_fields(scan["cost_json"]), "contract": scan_contract(scan), "continuationThreadId": scan["continuation_thread_id"], - "threadIds": scan_usage._scan_root_thread_ids(connection, scan, None), - "executionThreadIds": scan_usage._scan_execution_thread_ids(connection, scan), - "executionAttribution": scan_usage.scan_execution_attribution(connection, scan), + **scan_usage.scan_execution_fields(connection, scan), "failureMessage": scan["failure_message"], "findings": [ finding_result(connection, scan, row, related=relations.get(row["id"], [])) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index b98168e2f7..1fb1d7e0a0 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -341,6 +341,14 @@ def scan_execution_attribution( } +def scan_execution_fields(connection: sqlite3.Connection, scan: sqlite3.Row) -> dict[str, Any]: + return { + "threadIds": _scan_root_thread_ids(connection, scan, None), + "executionThreadIds": _scan_execution_thread_ids(connection, scan), + "executionAttribution": scan_execution_attribution(connection, scan), + } + + def _codex_state_database() -> Path | None: configured_database = os.environ.get("CODEX_STATE_DB", "").strip() if configured_database: From be957136794b7966051460dab9b81172ad0d04ed Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:15:37 +0000 Subject: [PATCH 051/251] Fence publication to committed finalization input --- .../scripts/workbench_saved_results.py | 34 ++++---- .../test_selected_publication_authority.py | 80 +++++++++++++++++++ 2 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 plugins/codex-security/tests/test_selected_publication_authority.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 0e4aa1b871..c5d761a9e2 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1530,21 +1530,27 @@ def _require_current_deep_publication( coordinator_generation=publication.get("coordinatorGeneration") if publication else None ), ) - # Generation-one runs predate host publication metadata. Keep their existing - # draft path; adopted coordinators must carry their generation and selection. - if publication is None: - return - reducer = _latest_successful_reducer( - connection.execute( - "SELECT * FROM deep_scan_workers WHERE scan_id = ?", (scan_id,) - ).fetchall() - ) - selected_result = reducer["result_manifest_path"] if reducer is not None else None - finalization = db.deep_scan.deep_scan_finalization_input(run) - if finalization is not None: - selected = finalization["resultPath"] + selection = db.deep_scan.deep_scan_finalization_input(run) + if selection is not None: + if publication is None: + raise SystemExit("Deep Scan publication requires its committed selection.") scan = db.require_scan(connection, scan_id) - selected_result = str(Path(scan["scan_dir"]) / selected) if selected is not None else None + selected_result = ( + str(Path(scan["scan_dir"]) / selection["resultPath"]) + if selection["resultPath"] is not None + else None + ) + else: + # Generation-one runs predate host publication metadata. Keep their existing + # draft path; adopted coordinators must carry their generation and selection. + if publication is None: + return + reducer = _latest_successful_reducer( + connection.execute( + "SELECT * FROM deep_scan_workers WHERE scan_id = ?", (scan_id,) + ).fetchall() + ) + selected_result = reducer["result_manifest_path"] if reducer is not None else None if publication["resultPath"] != selected_result: raise SystemExit("Deep Scan aggregate belongs to a superseded publication selection.") diff --git a/plugins/codex-security/tests/test_selected_publication_authority.py b/plugins/codex-security/tests/test_selected_publication_authority.py new file mode 100644 index 0000000000..4571855535 --- /dev/null +++ b/plugins/codex-security/tests/test_selected_publication_authority.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import hashlib +import json + +import pytest +from test_deep_scan_publication_authority import stage_publication +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan + + +@pytest.mark.parametrize("publication", ["selected", "mutable", "stale-generation", "unfenced"]) +def test_publication_uses_committed_finalization_selection( + workbench_api, workbench_db, publication_scan, publication +): + scan = publication_scan() + result = add_worker(workbench_db, scan) + contents = json.dumps( + { + "scanId": scan.scan_id, + "complete": True, + "findings": scan.findings, + "coverage": scan.coverage, + } + ).encode() + digest = hashlib.sha256(contents).hexdigest() + accepted = result.parent / "accepted" / f"{digest}.json" + accepted.parent.mkdir() + accepted.write_bytes(contents) + selection = { + "version": 1, + "resultPath": accepted.relative_to(scan.scan_dir).as_posix(), + "resultSha256": digest, + "terminalReason": "saturated", + "omittedWorkerIds": [], + "selectedAt": scan.timestamp, + } + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_workers SET kind = 'dedup', merge_state = 'none' " + "WHERE result_manifest_path = ?", + (str(result),), + ) + workbench_db.execute( + "UPDATE deep_scan_runs SET coordinator_generation = ?, finalization_input_json = ? " + "WHERE scan_id = ?", + (1 if publication == "unfenced" else 3, json.dumps(selection), scan.scan_id), + ) + # The accepted bytes survive replacement or deletion of the worker's output. + result.unlink(missing_ok=True) + staged = stage_publication( + scan, + generation=None + if publication == "unfenced" + else 2 + if publication == "stale-generation" + else 3, + result_path=result if publication == "mutable" else accepted, + title="Selected accepted aggregate", + ) + before = {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} + + if publication == "selected": + workbench_api["write_scan_draft"](workbench_db, staged) + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert findings[0]["title"] == "Selected accepted aggregate" + else: + with pytest.raises(SystemExit, match="coordinator|publication|aggregate"): + workbench_api["write_scan_draft"](workbench_db, staged) + assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} == before + assert accepted.read_bytes() == contents + assert ( + json.loads( + workbench_db.execute( + "SELECT finalization_input_json FROM deep_scan_runs WHERE scan_id = ?", + (scan.scan_id,), + ).fetchone()[0] + ) + == selection + ) From 2d61d8e523a2ed9e5ed79f264ffe1954e7d106eb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:24:04 +0000 Subject: [PATCH 052/251] Recover and publish registered accepted result references --- .../scripts/workbench_saved_results.py | 40 ++++---- .../test_accepted_publication_references.py | 98 +++++++++++++++++++ 2 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 plugins/codex-security/tests/test_accepted_publication_references.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index c5d761a9e2..088147d407 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -104,6 +104,23 @@ def _children(scan_dir: Path, relative: str) -> list[str]: return sorted(child.name for child in cursor.iterdir()) +def _saved_workers(connection: Any, scan_id: str) -> list[dict[str, Any]]: + rows = connection.execute( + "SELECT worker.*, attempt.accepted_result_path FROM deep_scan_workers AS worker " + "LEFT JOIN deep_scan_attempts AS attempt " + "ON attempt.worker_id = worker.id AND attempt.attempt = worker.attempt " + "WHERE worker.scan_id = ? ORDER BY worker.created_at, worker.id", + (scan_id,), + ).fetchall() + return [ + { + **dict(row), + "result_manifest_path": row["accepted_result_path"] or row["result_manifest_path"], + } + for row in rows + ] + + def _latest_successful_reducer(workers: list[Any]) -> Any | None: return max( ( @@ -258,11 +275,7 @@ def _saved_results_changed(db: Any, connection: Any, scan: Any) -> bool: try: scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) manifest_path = db.artifact_path(scan_dir, db.ARTIFACTS["manifest"], required=False) - workers = connection.execute( - "SELECT id, kind, status, completed_at, artifact_dir, result_manifest_path " - "FROM deep_scan_workers WHERE scan_id = ?", - (scan["id"],), - ).fetchall() + workers = _saved_workers(connection, scan["id"]) paths = dict(_saved_result_paths(scan_dir, workers)) frozen_sources = scan["retained_source_digests_json"] @@ -354,11 +367,7 @@ def _recovery_source_digests( else: include_parent = True - workers = connection.execute( - "SELECT id, kind, status, completed_at, artifact_dir, result_manifest_path " - "FROM deep_scan_workers WHERE scan_id = ?", - (scan["id"],), - ).fetchall() + workers = _saved_workers(connection, scan["id"]) checkpoint_heads = _worker_checkpoint_heads(scan_dir, workers, scan["id"]) paths = dict(_saved_result_paths(scan_dir, workers)) recovery_sources = dict(frozen_sources or {}) @@ -1336,10 +1345,7 @@ def record_publication(manifest: dict[str, Any], findings: dict[str, Any]) -> No scan_dir, scan_id, binding, - connection.execute( - "SELECT * FROM deep_scan_workers WHERE scan_id = ? ORDER BY created_at, id", - (scan_id,), - ).fetchall(), + _saved_workers(connection, scan_id), warnings, stopped=True, reason=( @@ -1545,11 +1551,7 @@ def _require_current_deep_publication( # draft path; adopted coordinators must carry their generation and selection. if publication is None: return - reducer = _latest_successful_reducer( - connection.execute( - "SELECT * FROM deep_scan_workers WHERE scan_id = ?", (scan_id,) - ).fetchall() - ) + reducer = _latest_successful_reducer(_saved_workers(connection, scan_id)) selected_result = reducer["result_manifest_path"] if reducer is not None else None if publication["resultPath"] != selected_result: raise SystemExit("Deep Scan aggregate belongs to a superseded publication selection.") diff --git a/plugins/codex-security/tests/test_accepted_publication_references.py b/plugins/codex-security/tests/test_accepted_publication_references.py new file mode 100644 index 0000000000..e7d12d871e --- /dev/null +++ b/plugins/codex-security/tests/test_accepted_publication_references.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import hashlib +import json +from argparse import Namespace + +import pytest +from test_deep_scan_publication_authority import stage_publication +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan + + +def accept_reducer(connection, scan): + result = add_worker(connection, scan) + worker_id = result.parent.name + coverage = { + **scan.coverage, + "completeness": "partial", + "deferred": [{"id": "accepted-follow-up", "reason": "Accepted unresolved review."}], + "reviews": [{"workerId": worker_id, "attempt": 1, "completeness": "partial"}], + } + contents = json.dumps( + { + "scanId": scan.scan_id, + "complete": True, + "findings": scan.findings, + "sourceCoverage": coverage, + } + ).encode() + digest = hashlib.sha256(contents).hexdigest() + result.write_bytes(contents) + accepted = result.parent / "accepted" / f"{digest}.json" + accepted.parent.mkdir() + accepted.write_bytes(contents) + result.unlink() + with connection: + connection.execute( + "UPDATE deep_scan_workers SET kind = 'dedup', merge_state = 'none' WHERE id = ?", + (worker_id,), + ) + connection.execute( + "INSERT INTO deep_scan_attempts (scan_id, worker_id, attempt, status, started_at, " + "completed_at, accepted_result_path, accepted_result_sha256) " + "VALUES (?, ?, 1, 'succeeded', ?, ?, ?, ?)", + (scan.scan_id, worker_id, scan.timestamp, scan.timestamp, str(accepted), digest), + ) + connection.execute( + "UPDATE deep_scan_runs SET coordinator_generation = 3 WHERE scan_id = ?", + (scan.scan_id,), + ) + return result, accepted, coverage + + +@pytest.mark.parametrize("selected", [True, False], ids=["accepted", "replaceable-output"]) +def test_legacy_publication_compares_registered_accepted_reference( + workbench_api, workbench_db, publication_scan, selected +): + scan = publication_scan() + result, accepted, _ = accept_reducer(workbench_db, scan) + staged = stage_publication( + scan, generation=3, result_path=accepted if selected else result, title="Accepted aggregate" + ) + before = {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} + + if selected: + workbench_api["write_scan_draft"](workbench_db, staged) + else: + with pytest.raises(SystemExit, match="aggregate"): + workbench_api["write_scan_draft"](workbench_db, staged) + assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} == before + + +def test_stopped_recovery_uses_accepted_bytes_after_replaceable_output_disappears( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + _, accepted, coverage = accept_reducer(workbench_db, scan) + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + contents = accepted.read_bytes() + + stopped = workbench_api["fail_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped."), + )["scan"] + + assert stopped["findingCount"] == 1 + published = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert published["reviews"] == coverage["reviews"] + assert coverage["deferred"][0] in published["deferred"] + manifest = (scan.scan_dir / "scan-manifest.json").read_bytes() + workbench_api["preserve_scan_results"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, claim_token=None, thread_id=None, coordinator_generation=None + ), + ) + assert (scan.scan_dir / "scan-manifest.json").read_bytes() == manifest + assert accepted.read_bytes() == contents From 6e912240e7651fff47433bdac143b97405e7826c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:20:17 +0000 Subject: [PATCH 053/251] Recover Deep Scan workers from accepted checkpoint references --- .../mcp-app/src/deep-scan/coordinator.ts | 26 +++++++++++++------ .../tests/test_deep_scan_attempt_replay.mjs | 17 +++++++++++- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 864131c2d9..1e98eff65d 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -960,19 +960,20 @@ export class DeepScanCoordinator { const recovered: AcceptedDiscovery[] = []; for (const worker of this.state.persistedWorkers ?? []) { if (worker.kind !== "discovery" || worker.status !== "succeeded") continue; - if (!worker.resultManifestPath || !worker.completionSequence) { + const resultPath = worker.acceptedResultPath ?? worker.resultManifestPath; + if (!resultPath || !worker.completionSequence) { throw new Error(`Accepted discovery ${worker.id} has incomplete persisted evidence.`); } await validateDiscoveryArtifacts( this.artifacts, - worker.resultManifestPath, + resultPath, this.state.scanId ); recovered.push({ id: worker.id, label: basename(dirname(worker.promptPath)), artifactDir: worker.artifactDir, - resultPath: worker.resultManifestPath, + resultPath, completionSequence: worker.completionSequence, attempt: worker.attempt, ...(worker.threadId ? { threadId: worker.threadId } : {}) @@ -996,23 +997,32 @@ export class DeepScanCoordinator { )); let noNewStreak = 0; for (const worker of completedReducers) { - if (!worker.resultManifestPath) { + const resultPath = worker.acceptedResultPath ?? worker.resultManifestPath; + if (!resultPath) { throw new Error(`Completed reducer ${worker.id} has no persisted result manifest.`); } const consumed = inputs .filter((input) => input.dedupWorkerId === worker.id) .sort((left, right) => left.inputOrder - right.inputOrder) - .map((input) => discoveriesById.get(input.discoveryWorkerId)); + .map((input) => { + const discovery = discoveriesById.get(input.discoveryWorkerId); + return discovery && { + ...discovery, + resultPath: input.resultManifestPath ?? discovery.resultPath, + attempt: input.attempt ?? discovery.attempt, + }; + }); if (consumed.length === 0 || consumed.some((value) => !value)) { throw new Error(`Completed reducer ${worker.id} has incomplete persisted inputs.`); } const accepted = consumed as AcceptedDiscovery[]; + const claim = this.state.persistedMergeClaims?.find((item) => item.workerId === worker.id); const { newFindings, result } = await validateReducerArtifacts({ artifacts: this.artifacts, artifactDir: worker.artifactDir, - resultPath: worker.resultManifestPath, + resultPath, reducerId: worker.id, - previousReducerResultPath: outcomes.at(-1)?.resultPath + previousReducerResultPath: claim ? claim.previousResultPath : outcomes.at(-1)?.resultPath }, this.state.scanId); if (result.sourceCoverage === undefined) { const context = { @@ -1036,7 +1046,7 @@ export class DeepScanCoordinator { type: "dedup", id: worker.id, consumed: accepted, - resultPath: worker.resultManifestPath, + resultPath, newFindings, attempt: worker.attempt, ...(worker.threadId ? { threadId: worker.threadId } : {}), diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs index 2356fe7ac0..7eda3581ee 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs @@ -16,10 +16,11 @@ const bundle = await build({ stdin: { resolveDir: app, contents: [ 'export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts";', 'export { DeepScanWorkerRunner } from "./src/deep-scan/worker-runner.ts";', + 'export { DeepScanCoordinator } from "./src/deep-scan/coordinator.ts";', 'export { createDeepScanArtifacts, ensureDeepScanDirectories } from "./src/deep-scan/artifacts.ts";' ].join("\n") } }); -const { WorkbenchDeepScanStore, DeepScanWorkerRunner, createDeepScanArtifacts, ensureDeepScanDirectories } = +const { WorkbenchDeepScanStore, DeepScanWorkerRunner, DeepScanCoordinator, createDeepScanArtifacts, ensureDeepScanDirectories } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`); const execute = promisify(execFile); for (const responseLosses of [3, 1]) await testResponseLoss(responseLosses); @@ -96,6 +97,20 @@ async function testResponseLoss(responseLosses) { assert.equal(counts.get("merge"), 2); assert.equal(executions, 3); assert.deepEqual(JSON.parse(await readFile(merged.resultPath, "utf8")), merged.result); + const snapshot = await store.get(run.scanId, "fixture-owner"); + const resumed = new DeepScanCoordinator({ + run: snapshot, store, pluginRoot: plugin, + executor: { run: async () => assert.fail("accepted recovery must not execute another model") }, + }); + const beforeRecovery = await readFile(merged.resultPath, "utf8"); + const recovered = await resumed.recoverAcceptedDiscoveries(); + assert.deepEqual(recovered.map(worker => worker.resultPath), [discovery.worker.resultPath, second.worker.resultPath]); + await rm(path.join(path.dirname(merged.resultPath), "..", "result.json")); + const reducers = await resumed.recoverCompletedReducers(recovered); + assert.equal(reducers.reducers[0].resultPath, merged.resultPath); + assert.deepEqual(reducers.result, merged.result); + assert.equal(await readFile(merged.resultPath, "utf8"), beforeRecovery, "recovery cannot rewrite accepted bytes"); + } finally { await rm(root, { recursive: true, force: true }); } From 7aeb8ae1449a77a6f90ef599536edaa3b435d20e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:27:28 +0000 Subject: [PATCH 054/251] fix: stop selected publication on cost limits and client close --- sdk/typescript/src/api.ts | 11 +- .../tests-ts/deep-finalization.test.ts | 126 +++++++++++++++++- 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index edcdf27a97..2833406acd 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2423,12 +2423,15 @@ export class CodexSecurity { ]); } } - // A failed attachment must not turn a resumable coordinator into a terminal failure. - // Deep Scan orchestration persists its own terminal failures and cancellations. + // Publication failures remain resumable. A cost stop or explicit client close + // still uses the existing failure path to retain partial results and stop work. if ( activeScan !== null && - options.resumeScanId === undefined && - !selectedDeepFinalization + ((options.resumeScanId === undefined && !selectedDeepFinalization) || + (selectedDeepFinalization && + !options.signal?.aborted && + (failure instanceof ScanCostLimitExceededError || + this.#abortController.signal.aborted))) ) { if ( options.validationPrompt !== undefined && diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index b4b35c6567..b26efdfe72 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, expect, test } from "bun:test"; @@ -25,9 +25,17 @@ for (const outcome of [ "canceled-before-publication", "canceled-during-publication", "published-before-cancellation", + "closed-during-publication", + "budget-during-publication", + "budget-after-deep-finish", + "budget-during-resumed-publication", + "closed-during-resumed-publication", ] as const) { - const restart = outcome === "restart"; - test(`SDK completes a selected aggregate ${restart ? "after restart" : `after the parent turn ${outcome}`}`, async () => { + const resumedStop = outcome.includes("-resumed-"); + const restart = outcome === "restart" || resumedStop; + const closed = outcome.startsWith("closed-"); + const budgeted = outcome.startsWith("budget-"); + test(`SDK handles selected aggregate: ${outcome}`, async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const scanDir = join(root, "scan"); @@ -50,6 +58,15 @@ for (const outcome of [ let publicationFails = restart; const modelInputs: string[] = []; const commands: string[] = []; + const usagePath = join( + codexHome, + "sessions", + "2026", + "01", + "01", + `rollout-${threadId}.jsonl`, + ); + let closePromise: Promise | undefined; const makeClient = () => new TestClient( {}, @@ -80,6 +97,40 @@ for (const outcome of [ throw new Error("Synthetic publication write failure"); } const result = await runWorkbench(options, args, input); + if ( + (args[0] === "write-scan-draft" && + (outcome === "budget-during-publication" || + outcome === "budget-during-resumed-publication")) || + (args[0] === "finish-deep-scan" && + outcome === "budget-after-deep-finish") + ) { + await appendFile( + usagePath, + JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }, + }, + }, + }) + "\n", + ); + await new Promise((resolve) => { + if (options.signal?.aborted) resolve(); + else + options.signal!.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + } + if (args[0] === "write-scan-draft" && closed) { + closePromise = client.close(); + } if ( args[0] === "write-scan-draft" && outcome === "canceled-during-publication" @@ -249,10 +300,16 @@ for (const outcome of [ }, ); let client = makeClient(); + // Native usage polling is unref'ed; the transport double has no child process. + const keepAlive = setTimeout(() => {}, 30_000); try { if (restart) { await expect( - client.run(repository, { mode: "deep", postScanPrompt: followUp }), + client.run(repository, { + mode: "deep", + postScanPrompt: resumedStop ? undefined : followUp, + ...(budgeted ? { maxCostUsd: 0.004 } : {}), + }), ).rejects.toThrow("Synthetic publication write failure"); const pending = await runWorkbench(workbenchOptions!, [ "get-deep-scan", @@ -269,6 +326,66 @@ for (const outcome of [ await client.close(); client = makeClient(); } + if (budgeted || closed) { + const running = client.run(repository, { + mode: "deep", + ...(budgeted ? { maxCostUsd: 0.004 } : {}), + ...(resumedStop ? { resumeScanId: scanId, outputDir: scanDir } : {}), + postScanPrompt: followUp, + }); + if (outcome === "budget-after-deep-finish") { + const result = await running; + expect(result.coverage.completeness).toBe("partial"); + expect(JSON.stringify(result.coverage)).toContain("cost limit"); + expect(result.cost?.estimatedUsd).toBeGreaterThan(0.004); + expect(result.threadId).toBe(threadId); + expect(modelInputs).toHaveLength(1); + expect(commands).toContain("complete-budget-exhausted-scan"); + expect(commands).not.toContain("fail-scan"); + return; + } + await expect(running).rejects.toThrow( + budgeted ? /estimated cost.*exceeded/ : /closed/, + ); + await closePromise; + const stopped = await runWorkbench( + { ...workbenchOptions!, signal: undefined }, + ["get-scan", "--scan-id", scanId], + ); + const deep = await runWorkbench( + { ...workbenchOptions!, signal: undefined }, + ["get-deep-scan", "--scan-id", scanId, "--thread-id", threadId], + ); + expect(stopped["scan"]).toMatchObject({ + progress: { status: "failed" }, + findingCount: 1, + reportAvailable: true, + }); + expect(deep["deepScan"]).toMatchObject({ + status: "failed", + finalizationInput: { terminalReason: "saturated" }, + }); + expect(await readFile(join(scanDir, "report.md"), "utf8")).toContain( + "Validate the resolved destination", + ); + expect( + JSON.parse(await readFile(join(scanDir, "coverage.json"), "utf8")) + .completeness, + ).toBe("partial"); + expect(commands).toContain("fail-scan"); + expect(modelInputs).toHaveLength(1); + await client.close(); + client = makeClient(); + await expect( + client.run(repository, { + mode: "deep", + resumeScanId: scanId, + outputDir: scanDir, + }), + ).rejects.toThrow(); + expect(modelInputs).toHaveLength(1); + return; + } if (outcome.startsWith("canceled-")) { await expect( client.run(repository, { mode: "deep", signal: cancellation.signal }), @@ -341,6 +458,7 @@ for (const outcome of [ ); expect(commands).not.toContain("fail-scan"); } finally { + clearTimeout(keepAlive); await client.close(); } }, 30_000); From 9610d25e6fe691fe57b2cce0eab383581bc938c0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:02:00 +0000 Subject: [PATCH 055/251] Test selected publication and stop recovery across process loss --- .../test_deep_scan_publication_replay.py | 16 +- .../test_publication_stop_interleavings.py | 416 ++++++++++++++++++ .../test_selected_publication_authority.py | 3 +- 3 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 plugins/codex-security/tests/test_publication_stop_interleavings.py diff --git a/plugins/codex-security/tests/test_deep_scan_publication_replay.py b/plugins/codex-security/tests/test_deep_scan_publication_replay.py index af7a629bee..e7c361569b 100644 --- a/plugins/codex-security/tests/test_deep_scan_publication_replay.py +++ b/plugins/codex-security/tests/test_deep_scan_publication_replay.py @@ -8,9 +8,11 @@ from pathlib import Path import pytest +from test_accepted_publication_references import accept_reducer from test_deep_scan_publication_authority import stage_publication from test_deep_scan_successful_publication import add_worker from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_publication_stop_interleavings import saved_selection _CRASH_PUBLICATION = """ import json, os, runpy, sqlite3, sys @@ -55,11 +57,21 @@ def crash_after_write(root, relative, contents): "boundary", ["findings.json", "coverage.json", "scan-manifest.json", "sqlite-before", "sqlite-after"], ) +@pytest.mark.parametrize("selection_reason", [None, "saturated", "capped"]) def test_publication_crash_replays_selected_input_without_stale_overwrite( - workbench_api, workbench_db, publication_scan, tmp_path, boundary + workbench_api, workbench_db, publication_scan, tmp_path, boundary, selection_reason ): scan = publication_scan() - result_path = add_worker(workbench_db, scan) + if selection_reason is None: + result_path = add_worker(workbench_db, scan) + else: + _, result_path, _ = accept_reducer(workbench_db, scan) + saved_selection(workbench_db, scan, result_path, reason=selection_reason) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET terminal_reason = ? WHERE scan_id = ?", + (selection_reason, scan.scan_id), + ) with workbench_db: workbench_db.execute( "UPDATE deep_scan_runs SET coordinator_generation = 3 WHERE scan_id = ?", diff --git a/plugins/codex-security/tests/test_publication_stop_interleavings.py b/plugins/codex-security/tests/test_publication_stop_interleavings.py new file mode 100644 index 0000000000..46b4a37975 --- /dev/null +++ b/plugins/codex-security/tests/test_publication_stop_interleavings.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import json +import sqlite3 +import subprocess +import sys +from argparse import Namespace +from pathlib import Path + +import pytest +from test_accepted_publication_references import accept_reducer +from test_checkpoint_publication_authority import save_disposition +from test_deep_scan_publication_authority import stage_publication +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan + + +def saved_selection(connection, scan, accepted, omitted=None, *, reason="saturated"): + selection = { + "version": 1, + "resultPath": accepted.relative_to(scan.scan_dir).as_posix(), + "resultSha256": accepted.stem, + "terminalReason": reason, + "omittedWorkerIds": [omitted.parent.name] if omitted is not None else [], + "selectedAt": scan.timestamp, + } + with connection: + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "finalization_input_json = ? WHERE scan_id = ?", + (json.dumps(selection), scan.scan_id), + ) + return selection + + +def stop_scan(api, connection, scan, cause): + if cause == "cancel": + return api["cancel_scan"](connection, Namespace(scan_id=scan.scan_id, thread_id=None)) + return api["fail_scan"]( + connection, + Namespace( + scan_id=scan.scan_id, + claim_token=None, + cost_json=None, + message="Scan stopped after reaching the configured cost limit.", + ), + ) + + +def published_bytes(scan): + return { + path.relative_to(scan.scan_dir).as_posix(): path.read_bytes() + for path in scan.scan_dir.rglob("*") + if path.is_file() and "drafts" not in path.relative_to(scan.scan_dir).parts + } + + +_CRASH_STOPPED_PUBLICATION = """ +import os, runpy, sqlite3, sys +from argparse import Namespace + +api = runpy.run_path(sys.argv[1], run_name="stopped_publication_crash_test") +scan_id, cause, boundary = sys.argv[3:] + +class CrashConnection(sqlite3.Connection): + def __exit__(self, *args): + sealing = self.execute( + "SELECT seal_manifest_digest FROM scans WHERE id = ?", (scan_id,) + ).fetchone()[0] is not None + if sealing and boundary == "sqlite-before": + os._exit(72) + result = super().__exit__(*args) + if sealing and boundary == "sqlite-after": + os._exit(73) + return result + +connection = sqlite3.connect(sys.argv[2], factory=CrashConnection) +connection.row_factory = sqlite3.Row +connection.execute("PRAGMA foreign_keys = ON") +import finalize_scan_contract as contract +original_write = contract.write_scan_local_bytes +def crash_after_write(root, relative, contents, **kwargs): + original_write(root, relative, contents, **kwargs) + if relative == boundary: + os._exit(71) +contract.write_scan_local_bytes = crash_after_write +if cause == "cancel": + api["cancel_scan"](connection, Namespace(scan_id=scan_id, thread_id=None)) +else: + api["fail_scan"](connection, Namespace( + scan_id=scan_id, claim_token=None, cost_json=None, + message="Scan stopped after reaching the configured cost limit." + )) +raise AssertionError("stop never reached the requested publication boundary") +""" + +_CRASH_SELECTION_RECOVERY = """ +import os, runpy, sqlite3, sys +from argparse import Namespace + +api = runpy.run_path(sys.argv[1], run_name="selection_recovery_crash_test") +deep = api["deep_scan"] +deep.configure(deep.DeepScanDependencies(**{ + name: api["preserve_stopped_results_after_transition" + if name == "preserve_stopped_results" else name] + for name in deep.DeepScanDependencies.__dataclass_fields__ +})) + +class CrashConnection(sqlite3.Connection): + def commit(self): + if sys.argv[4] == "before": + os._exit(72) + super().commit() + os._exit(73) + +connection = sqlite3.connect(sys.argv[2], factory=CrashConnection) +connection.row_factory = sqlite3.Row +connection.execute("PRAGMA foreign_keys = ON") +deep.claim_deep_scan_coordinator(connection, Namespace( + scan_id=sys.argv[3], thread_id="fixture-owner", + claim_token=None, coordinator_generation=None, +)) +raise AssertionError("recovery never reached the requested commit boundary") +""" + + +@pytest.mark.parametrize("cause", ["cancel", "cost"]) +@pytest.mark.parametrize("cut", ["before-selection", "selected", "published", "sealed"]) +def test_stop_and_publication_keep_the_winning_terminal_outcome( + workbench_api, workbench_db, publication_scan, tmp_path, cause, cut +): + scan = publication_scan() + _, accepted, coverage = accept_reducer(workbench_db, scan) + omitted = add_worker(workbench_db, scan) + rejected = save_disposition(scan, omitted.parent, "reported") + omitted.write_text(json.dumps(rejected)) + save_disposition(scan, omitted.parent, "rejected") + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_workers SET merge_state = 'buffered' WHERE id = ?", + (omitted.parent.name,), + ) + if cut in {"before-selection", "selected"}: + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'reducing', " + "terminal_reason = NULL, completed_at = NULL WHERE scan_id = ?", + (scan.scan_id,), + ) + selection = ( + None + if cut == "before-selection" + else saved_selection(workbench_db, scan, accepted, omitted) + ) + staged = stage_publication( + scan, generation=3, result_path=accepted, title="Selected accepted aggregate" + ) + # The stop path must recover the accepted bytes and the rejection disposition. + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + evidence = {accepted: accepted.read_bytes(), omitted: omitted.read_bytes()} + database_path = tmp_path / "stop-publication.sqlite3" + with sqlite3.connect(database_path) as connection: + workbench_db.backup(connection) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + if cut in {"published", "sealed"}: + workbench_api["write_scan_draft"](connection, staged) + complete_args = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + if cut == "sealed": + workbench_api["complete_scan"](connection, complete_args) + before_stop = published_bytes(scan) + if cut == "sealed": + with pytest.raises(SystemExit, match="running|completed"): + stop_scan(workbench_api, connection, scan, cause) + assert published_bytes(scan) == before_stop + else: + stop_scan(workbench_api, connection, scan, cause) + + # Reconnect after the winning commit, then deliver the old publisher response. + with sqlite3.connect(database_path) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + row = dict(connection.execute("SELECT * FROM scans").fetchone()) + run = dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) + assert row["status"] == ("complete" if cut == "sealed" else "failed") + assert bool(row["canceled_at"]) == (cause == "cancel" and cut != "sealed") + if cause == "cost" and cut != "sealed": + assert row["failure_message"] == ( + "Scan stopped after reaching the configured cost limit." + ) + assert json.loads(run["finalization_input_json"] or "null") == selection + if cut != "sealed" and cause == "cancel": + assert run["status"] == "canceled" + if cut in {"published", "sealed"}: + assert run["terminal_reason"] == "saturated" + frozen = published_bytes(scan) + with pytest.raises(SystemExit, match="stopped"): + workbench_api["write_scan_draft"](connection, staged) + if cut == "sealed": + workbench_api["complete_scan"](connection, complete_args) + else: + with pytest.raises(SystemExit): + workbench_api["complete_scan"](connection, complete_args) + workbench_api["preserve_scan_results"]( + connection, + Namespace( + scan_id=scan.scan_id, + claim_token=None, + thread_id=None, + coordinator_generation=None, + ), + ) + assert published_bytes(scan) == frozen + assert dict(connection.execute("SELECT * FROM scans").fetchone()) == row + assert dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) == run + assert all(path.read_bytes() == contents for path, contents in evidence.items()) + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert len(findings) == 1 + assert all( + finding.get("extensions", {}).get("candidateId") != "candidate-disposition" + for finding in findings + ) + if cut != "sealed": + published_coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert coverage["deferred"][0] in published_coverage["deferred"] + assert published_coverage["completeness"] == "partial" + + +@pytest.mark.parametrize("cause", ["cancel", "cost"]) +@pytest.mark.parametrize( + "boundary", + ["findings.json", "coverage.json", "scan-manifest.json", "sqlite-before", "sqlite-after"], +) +def test_stopped_publication_process_loss_keeps_frozen_rejection_and_original_selection( + workbench_api, workbench_db, publication_scan, tmp_path, cause, boundary +): + scan = publication_scan() + _, accepted, _ = accept_reducer(workbench_db, scan) + omitted = add_worker(workbench_db, scan) + reported = save_disposition(scan, omitted.parent, "reported") + omitted.write_text(json.dumps(reported)) + save_disposition(scan, omitted.parent, "rejected") + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_workers SET merge_state = 'buffered' WHERE id = ?", + (omitted.parent.name,), + ) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'reducing', " + "terminal_reason = NULL, completed_at = NULL WHERE scan_id = ?", + (scan.scan_id,), + ) + selection = saved_selection(workbench_db, scan, accepted, omitted) + staged = stage_publication( + scan, generation=3, result_path=accepted, title="Obsolete selected publication" + ) + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + evidence = {accepted: accepted.read_bytes(), omitted: omitted.read_bytes()} + database_path = tmp_path / "stopped-crash.sqlite3" + with sqlite3.connect(database_path) as connection: + workbench_db.backup(connection) + child = subprocess.run( + [ + sys.executable, + "-c", + _CRASH_STOPPED_PUBLICATION, + str(Path(__file__).resolve().parents[1] / "scripts" / "workbench_db.py"), + str(database_path), + scan.scan_id, + cause, + boundary, + ], + capture_output=True, + text=True, + ) + assert child.returncode == {"sqlite-before": 72, "sqlite-after": 73}.get(boundary, 71), ( + child.stdout, + child.stderr, + ) + with sqlite3.connect(database_path) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + row = connection.execute("SELECT * FROM scans").fetchone() + assert row["status"] == "failed" + assert bool(row["canceled_at"]) == (cause == "cancel") + assert bool(row["seal_manifest_digest"]) == (boundary == "sqlite-after") + frozen_sources = row["retained_source_digests_json"] + frozen_heads = row["retained_checkpoint_heads_json"] + assert frozen_sources and frozen_heads + run = dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) + workers = [dict(row) for row in connection.execute("SELECT * FROM deep_scan_workers")] + assert json.loads(run["finalization_input_json"]) == selection + assert run["status"] == ("canceled" if cause == "cancel" else "failed") + # The replacement process sees a different live head, but replays the + # already committed stopped selection instead of restoring the candidate. + save_disposition(scan, omitted.parent, "reported") + interrupted = published_bytes(scan) + with pytest.raises(SystemExit, match="stopped"): + workbench_api["write_scan_draft"](connection, staged) + assert published_bytes(scan) == interrupted + args = Namespace( + scan_id=scan.scan_id, + claim_token=None, + thread_id=None, + coordinator_generation=None, + ) + workbench_api["preserve_scan_results"](connection, args) + row = connection.execute("SELECT * FROM scans").fetchone() + assert row["status"] == "failed" + assert bool(row["canceled_at"]) == (cause == "cancel") + assert row["retained_source_digests_json"] == frozen_sources + assert row["retained_checkpoint_heads_json"] == frozen_heads + assert row["seal_manifest_digest"] + assert dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) == run + assert [ + dict(row) for row in connection.execute("SELECT * FROM deep_scan_workers") + ] == workers + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert len(findings) == 1 + assert findings[0].get("extensions", {}).get("candidateId") != "candidate-disposition" + assert all(path.read_bytes() == contents for path, contents in evidence.items()) + sealed = published_bytes(scan) + workbench_api["preserve_scan_results"](connection, args) + assert published_bytes(scan) == sealed + assert connection.execute("SELECT COUNT(*) FROM finding_occurrences").fetchone()[0] == 1 + + +@pytest.mark.parametrize("cut", ["before", "after"]) +def test_interrupted_selection_recovery_fences_observers_and_keeps_original_deadline( + workbench_api, workbench_db, publication_scan, tmp_path, cut +): + scan = publication_scan() + _, accepted, _ = accept_reducer(workbench_db, scan) + omitted = add_worker(workbench_db, scan) + selection = saved_selection(workbench_db, scan, accepted, omitted) + with workbench_db: + workbench_db.execute( + "UPDATE scans SET deep_scan_owner_thread_id = 'fixture-owner' WHERE id = ?", + (scan.scan_id,), + ) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'reducing', " + "terminal_reason = NULL, completed_at = NULL, max_time_hours = 1, " + "created_at = '2000-01-01T00:00:00Z', updated_at = '2000-01-01T00:00:00Z' " + "WHERE scan_id = ?", + (scan.scan_id,), + ) + workbench_db.execute( + "UPDATE deep_scan_workers SET merge_state = 'buffered' WHERE id = ?", + (omitted.parent.name,), + ) + database_path = tmp_path / "recovery.sqlite3" + with sqlite3.connect(database_path) as connection: + workbench_db.backup(connection) + original = "\n".join(connection.iterdump()) + before = published_bytes(scan) + child = subprocess.run( + [ + sys.executable, + "-c", + _CRASH_SELECTION_RECOVERY, + str(Path(__file__).resolve().parents[1] / "scripts" / "workbench_db.py"), + str(database_path), + scan.scan_id, + cut, + ], + capture_output=True, + text=True, + ) + assert child.returncode == (72 if cut == "before" else 73), (child.stdout, child.stderr) + deep = workbench_api["deep_scan"] + args = Namespace( + scan_id=scan.scan_id, + thread_id="fixture-owner", + claim_token=None, + coordinator_generation=None, + ) + with sqlite3.connect(database_path) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + if cut == "before": + assert "\n".join(connection.iterdump()) == original + run = connection.execute("SELECT * FROM deep_scan_runs").fetchone() + assert run["coordinator_generation"] == (3 if cut == "before" else 4) + workers = [dict(row) for row in connection.execute("SELECT * FROM deep_scan_workers")] + attempts = [dict(row) for row in connection.execute("SELECT * FROM deep_scan_attempts")] + replayed = deep.claim_deep_scan_coordinator(connection, args) + assert replayed["coordinatorDisposition"] == ("adopted" if cut == "before" else "observing") + assert replayed["deepScan"]["coordinatorGeneration"] == 4 + assert replayed["deepScan"]["finalizationInput"] == selection + assert replayed["deepScan"]["createdAt"] == "2000-01-01T00:00:00Z" + assert replayed["deepScan"]["config"]["maxTimeHours"] == 1 + assert replayed["deepScan"]["phase"] == "reducing" + assert deep.deep_scan_deadline_reached( + connection.execute("SELECT * FROM deep_scan_runs").fetchone() + ) + stable = "\n".join(connection.iterdump()) + stale_claim = Namespace(**{**vars(args), "coordinator_generation": 3}) + with pytest.raises(SystemExit, match="generation"): + deep.claim_deep_scan_coordinator(connection, stale_claim) + assert "\n".join(connection.iterdump()) == stable + stale = stage_publication(scan, generation=3, result_path=accepted, title="Old coordinator") + with pytest.raises(SystemExit, match="generation"): + workbench_api["write_scan_draft"](connection, stale) + assert published_bytes(scan) == before + assert [ + dict(row) for row in connection.execute("SELECT * FROM deep_scan_workers") + ] == workers + assert [ + dict(row) for row in connection.execute("SELECT * FROM deep_scan_attempts") + ] == attempts + current = stage_publication( + scan, generation=4, result_path=accepted, title="Recovered selected aggregate" + ) + workbench_api["write_scan_draft"](connection, current) + assert accepted.read_bytes() == before[accepted.relative_to(scan.scan_dir).as_posix()] + assert "\n".join(connection.iterdump()) == stable diff --git a/plugins/codex-security/tests/test_selected_publication_authority.py b/plugins/codex-security/tests/test_selected_publication_authority.py index 4571855535..b3bd24ea24 100644 --- a/plugins/codex-security/tests/test_selected_publication_authority.py +++ b/plugins/codex-security/tests/test_selected_publication_authority.py @@ -42,7 +42,8 @@ def test_publication_uses_committed_finalization_selection( (str(result),), ) workbench_db.execute( - "UPDATE deep_scan_runs SET coordinator_generation = ?, finalization_input_json = ? " + "UPDATE deep_scan_runs SET coordinator_generation = ?, finalization_input_json = ?, " + "workflow_version = 'deep-security-scan/v2' " "WHERE scan_id = ?", (1 if publication == "unfenced" else 3, json.dumps(selection), scan.scan_id), ) From d932ab6a2d8003d4457ffcb1c04785e9bdfb8a1f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:02:00 +0000 Subject: [PATCH 056/251] Test cost completion and cancellation ordering with saved selections --- .../test_budget_selection_publication.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 plugins/codex-security/tests/test_budget_selection_publication.py diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py new file mode 100644 index 0000000000..40b7e91b58 --- /dev/null +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +import sqlite3 +from argparse import Namespace + +import pytest +from test_accepted_publication_references import accept_reducer +from test_deep_scan_publication_authority import stage_publication +from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_publication_stop_interleavings import published_bytes, saved_selection +from test_workbench_db import BUDGET_COST + + +@pytest.mark.parametrize("selected", [False, True], ids=["legacy-v1", "selected-v2"]) +@pytest.mark.parametrize("reason", ["saturated", "capped"]) +@pytest.mark.parametrize("cancel_first", [False, True], ids=["budget-first", "cancel-first"]) +def test_budget_completion_and_cancel_keep_the_committed_outcome( + workbench_api, workbench_db, publication_scan, tmp_path, selected, reason, cancel_first +): + scan = publication_scan() + _, accepted, coverage = accept_reducer(workbench_db, scan) + selection = saved_selection(workbench_db, scan, accepted, reason=reason) if selected else None + scan.coverage = coverage + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute("UPDATE deep_scan_runs SET terminal_reason = ?", (reason,)) + staged = stage_publication( + scan, generation=3, result_path=accepted, title="Selected accepted aggregate" + ) + database_path = tmp_path / "budget-publication.sqlite3" + with sqlite3.connect(database_path) as connection: + workbench_db.backup(connection) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + workbench_api["write_scan_draft"](connection, staged) + accepted_bytes = accepted.read_bytes() + warning = "Scan stopped after reaching its configured cost limit." + budget_args = Namespace( + scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=warning + ) + cancel_args = Namespace(scan_id=scan.scan_id, thread_id=None) + if cancel_first: + workbench_api["cancel_scan"](connection, cancel_args) + frozen = published_bytes(scan) + with pytest.raises(SystemExit, match="running"): + workbench_api["complete_budget_exhausted_scan"](connection, budget_args) + else: + workbench_api["complete_budget_exhausted_scan"](connection, budget_args) + frozen = published_bytes(scan) + with pytest.raises(SystemExit, match="running"): + workbench_api["cancel_scan"](connection, cancel_args) + assert published_bytes(scan) == frozen + + with sqlite3.connect(database_path) as connection: + connection.row_factory = sqlite3.Row + row = connection.execute("SELECT * FROM scans").fetchone() + run = connection.execute("SELECT * FROM deep_scan_runs").fetchone() + assert row["status"] == ("failed" if cancel_first else "complete") + assert bool(row["canceled_at"]) == cancel_first + assert run["terminal_reason"] == reason + assert json.loads(run["finalization_input_json"] or "null") == selection + assert accepted.read_bytes() == accepted_bytes + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert coverage["completeness"] == "partial" + assert any(item["id"] == "accepted-follow-up" for item in coverage["deferred"]) + if not cancel_first: + assert any(item["id"] == "scan-cost-limit" for item in coverage["deferred"]) + assert warning in json.loads(row["completion_warnings_json"]) + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert len(findings) == 1 + with pytest.raises(SystemExit, match="stopped"): + workbench_api["write_scan_draft"](connection, staged) + assert published_bytes(scan) == frozen From f5b72e9bc4fa586d053ef2072079931ddc6093f7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:15:05 +0000 Subject: [PATCH 057/251] Verify selected terminal causes survive stop and recovery --- .../tests/test_publication_stop_interleavings.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/codex-security/tests/test_publication_stop_interleavings.py b/plugins/codex-security/tests/test_publication_stop_interleavings.py index 46b4a37975..d1f4c8323d 100644 --- a/plugins/codex-security/tests/test_publication_stop_interleavings.py +++ b/plugins/codex-security/tests/test_publication_stop_interleavings.py @@ -27,8 +27,9 @@ def saved_selection(connection, scan, accepted, omitted=None, *, reason="saturat with connection: connection.execute( "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " - "finalization_input_json = ? WHERE scan_id = ?", - (json.dumps(selection), scan.scan_id), + "finalization_input_json = ?, terminal_reason = ?, phase = 'terminal' " + "WHERE scan_id = ?", + (json.dumps(selection), reason, scan.scan_id), ) return selection @@ -188,6 +189,8 @@ def test_stop_and_publication_keep_the_winning_terminal_outcome( "Scan stopped after reaching the configured cost limit." ) assert json.loads(run["finalization_input_json"] or "null") == selection + if selection is not None: + assert run["terminal_reason"] == selection["terminalReason"] if cut != "sealed" and cause == "cancel": assert run["status"] == "canceled" if cut in {"published", "sealed"}: @@ -289,6 +292,7 @@ def test_stopped_publication_process_loss_keeps_frozen_rejection_and_original_se run = dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) workers = [dict(row) for row in connection.execute("SELECT * FROM deep_scan_workers")] assert json.loads(run["finalization_input_json"]) == selection + assert run["terminal_reason"] == selection["terminalReason"] assert run["status"] == ("canceled" if cause == "cancel" else "failed") # The replacement process sees a different live head, but replays the # already committed stopped selection instead of restoring the candidate. @@ -338,8 +342,8 @@ def test_interrupted_selection_recovery_fences_observers_and_keeps_original_dead (scan.scan_id,), ) workbench_db.execute( - "UPDATE deep_scan_runs SET status = 'running', phase = 'reducing', " - "terminal_reason = NULL, completed_at = NULL, max_time_hours = 1, " + "UPDATE deep_scan_runs SET status = 'running', " + "completed_at = NULL, max_time_hours = 1, " "created_at = '2000-01-01T00:00:00Z', updated_at = '2000-01-01T00:00:00Z' " "WHERE scan_id = ?", (scan.scan_id,), @@ -389,7 +393,8 @@ def test_interrupted_selection_recovery_fences_observers_and_keeps_original_dead assert replayed["deepScan"]["finalizationInput"] == selection assert replayed["deepScan"]["createdAt"] == "2000-01-01T00:00:00Z" assert replayed["deepScan"]["config"]["maxTimeHours"] == 1 - assert replayed["deepScan"]["phase"] == "reducing" + assert replayed["deepScan"]["phase"] == "terminal" + assert replayed["deepScan"]["terminalReason"] == selection["terminalReason"] assert deep.deep_scan_deadline_reached( connection.execute("SELECT * FROM deep_scan_runs").fetchone() ) From d0a5e9c9d9351760e703e5c27117c16e8c530a4b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:30:05 +0000 Subject: [PATCH 058/251] Use committed input attempts in reducer execution context --- .../mcp-app/src/deep-scan/worker-runner.ts | 2 +- .../tests/test_deep_scan_attempt_replay.mjs | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 6bf41818aa..98266396ee 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -292,7 +292,7 @@ export class DeepScanWorkerRunner { consumed = inputs.sort((a, b) => a.inputOrder - b.inputOrder).map((item) => { const discovery = consumed.find((worker) => worker.id === item.discoveryWorkerId); if (!discovery || !item.resultManifestPath) throw new Error("The reducer claim is missing an accepted input."); - return { ...discovery, resultPath: item.resultManifestPath }; + return { ...discovery, resultPath: item.resultManifestPath, attempt: item.attempt ?? discovery.attempt }; }); } this.options.log({ diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs index 7eda3581ee..0fbc88da17 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs @@ -68,6 +68,14 @@ async function testResponseLoss(responseLosses) { clock: { now: () => Date.now(), sleep: async () => {} }, executor: { async run(request) { executions++; + if (request.kind === "dedup") { + const workers = request.artifactContext.deepReducer.claimedWorkers; + const prompt = await readFile(request.promptPath, "utf8"); + const configuration = JSON.parse(prompt.match(/```json\n([\s\S]*?)\n```/)[1]); + assert.deepEqual(configuration.claimedWorkerIds, workers.map(worker => worker.id)); + assert.equal(workers.every(worker => worker.resultPath.includes("checkpoints")), true); + assert.deepEqual(workers.map(worker => worker.attempt), [1, 1], "execution uses the immutable claim attempts"); + } await request.onThreadStarted?.(`fixture-session-${executions}`); const draft = { scanId: run.scanId, findings: [], threatModel: { summary: "Synthetic fixture." } }; if (request.kind === "discovery") draft.coverage = { @@ -90,7 +98,13 @@ async function testResponseLoss(responseLosses) { const second = await runner.runDiscoveryWorker(randomUUID(), "discovery-2"); // Acceptance receipts are operation-specific, so only the first discovery loses a response. await rm(path.join(discovery.worker.artifactDir, "result.json")); - const merged = await runner.runReducer({ id: randomUUID(), label: "dedup-1", consumed: [discovery.worker, second.worker] }); + const merged = await runner.runReducer({ + id: randomUUID(), label: "dedup-1", + consumed: [discovery.worker, second.worker].map(worker => ({ + ...worker, resultPath: path.join(worker.artifactDir, "result.json"), attempt: 99 + })) + }); + assert.equal(merged.error, undefined, merged.error?.stack); assert.match(merged.resultPath, /checkpoints/); assert.equal(merged.newFindings, 0); assert.equal(merged.run.persistedDedupInputs.filter((input) => input.dedupWorkerId === merged.id).length, 2); From 7b6226892d9658684ed15f5108180084ff02c19c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:35:54 +0000 Subject: [PATCH 059/251] Check persisted reducer content separately from coverage projection --- .../mcp-app/tests/test_deep_scan_attempt_replay.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs index 0fbc88da17..70500c8f01 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs @@ -110,7 +110,9 @@ async function testResponseLoss(responseLosses) { assert.equal(merged.run.persistedDedupInputs.filter((input) => input.dedupWorkerId === merged.id).length, 2); assert.equal(counts.get("merge"), 2); assert.equal(executions, 3); - assert.deepEqual(JSON.parse(await readFile(merged.resultPath, "utf8")), merged.result); + const { sourceCoverage, ...persistedResult } = merged.result; + assert.deepEqual(JSON.parse(await readFile(merged.resultPath, "utf8")), persistedResult); + if (sourceCoverage) assert.equal(sourceCoverage.completeness, "complete"); const snapshot = await store.get(run.scanId, "fixture-owner"); const resumed = new DeepScanCoordinator({ run: snapshot, store, pluginRoot: plugin, From 98d419c291210f7aa95310c335a34ef81e314dc7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:39:27 +0000 Subject: [PATCH 060/251] Return legacy scan records in budget recovery fixtures --- sdk/typescript/tests-ts/api.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 227d137092..87b9fff2b5 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -4930,6 +4930,10 @@ describe("CodexSecurity orchestration", () => { input?: string, ): Promise => { commands.push(args); + if (args[0] === "get-scan") { + // Older workbench readers return a scan without execution attribution. + return { scan: { id: "scan_example_001" } }; + } if (args[0] !== "complete-budget-exhausted-scan") { return mockWorkbench(args, input); } From 2d3baaa22603edf30fb238b2b37a5e2917d0d08c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:34:23 +0000 Subject: [PATCH 061/251] test: attribute selected publication cost fixtures to native events --- .../tests-ts/deep-finalization.test.ts | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index b26efdfe72..feea670269 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -107,18 +107,29 @@ for (const outcome of [ await appendFile( usagePath, JSON.stringify({ - type: "event_msg", + timestamp: new Date().toISOString(), + type: "turn_context", payload: { - type: "token_count", - info: { - total_token_usage: { - input_tokens: 1_250, - cached_input_tokens: 200, - output_tokens: 30, + turn_id: "synthetic-scan-turn", + model: "gpt-5.6-sol", + }, + }) + + "\n" + + JSON.stringify({ + timestamp: new Date().toISOString(), + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }, }, }, - }, - }) + "\n", + }) + + "\n", ); await new Promise((resolve) => { if (options.signal?.aborted) resolve(); @@ -435,8 +446,8 @@ for (const outcome of [ ...(restart ? { resumeScanId: scanId, outputDir: scanDir } : {}), }); expect(result.threadId).toBe(threadId); - if (outcome === "completed") expect(result.cost?.estimatedUsd).toBe(0); - else expect(result.cost).toBeNull(); + // The synthetic accepted workers have no native usage receipts. + expect(result.cost).toBeNull(); expect(result.coverage.completeness).toBe("partial"); expect(result.findings.findings[0]?.remediation).toBe( "Validate the resolved destination before writing.", From 7909f9342844c7ac7d7167becffc9e73d3795207 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 04:42:03 +0000 Subject: [PATCH 062/251] test: cover managed scan and follow-up service tiers --- sdk/typescript/tests-ts/api.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 87b9fff2b5..d8910cf3a8 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -7343,6 +7343,7 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s model_reasoning_effort: "ultra", model_reasoning_summary: name === "first" ? "none" : "concise", + service_tier: name === "first" ? "flex" : "fast", features: { multi_agent_v2: { max_concurrent_threads_per_session: 4 }, }, @@ -7444,6 +7445,9 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(child.args).toContain( `model_reasoning_summary=${JSON.stringify(name === "first" ? "none" : "concise")}`, ); + expect(child.args).toContain( + `service_tier=${JSON.stringify(name === "first" ? "flex" : "fast")}`, + ); expect(child.args).toContain( "features.multi_agent_v2.max_concurrent_threads_per_session=4", ); From dcdcd7ed44bbdeaa1228555ade83cedb6d3a3564 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:36:09 +0000 Subject: [PATCH 063/251] fix: restore completed artifacts after follow-up cancellation --- sdk/typescript/src/api.ts | 4 +- .../tests-ts/deep-finalization.test.ts | 61 +++++++++++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 2833406acd..11fe28da6b 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2205,7 +2205,7 @@ export class CodexSecurity { let artifactRestorer: ScanArtifactRestorer | null = null; try { artifactRestorer = await prepareArtifactRestorer( - workbenchOptions, + { ...workbenchOptions, signal: undefined }, scanDir, ); await runScanEvents({ @@ -2222,7 +2222,6 @@ export class CodexSecurity { }); checkOpen(); } catch (error) { - if (signal.aborted || this.#closed) throw error; if (artifactRestorer !== null) { for (const artifact of completedArtifacts) { try { @@ -2239,6 +2238,7 @@ export class CodexSecurity { } } } + if (signal.aborted || this.#closed) throw error; await collectResult( result.turnResult, result.threadId, diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index feea670269..93c013f07c 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -4,7 +4,11 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, expect, test } from "bun:test"; import type { ThreadEvent } from "@openai/codex-sdk"; -import { runWorkbench, type WorkbenchCommandOptions } from "../src/runtime.js"; +import { + prepareScanArtifactRestorer, + runWorkbench, + type WorkbenchCommandOptions, +} from "../src/runtime.js"; import { TestClient } from "./support/api-client.js"; import { completedEvents, @@ -30,12 +34,17 @@ for (const outcome of [ "budget-after-deep-finish", "budget-during-resumed-publication", "closed-during-resumed-publication", + "followup-canceled", ] as const) { const resumedStop = outcome.includes("-resumed-"); const restart = outcome === "restart" || resumedStop; const closed = outcome.startsWith("closed-"); const budgeted = outcome.startsWith("budget-"); - test(`SDK handles selected aggregate: ${outcome}`, async () => { + const name = + outcome === "followup-canceled" + ? "SDK preserves a selected aggregate when its follow-up is canceled" + : `SDK handles selected aggregate: ${outcome}`; + const runCase = async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); const scanDir = join(root, "scan"); @@ -56,6 +65,7 @@ for (const outcome of [ let scanId = ""; let workbenchOptions: WorkbenchCommandOptions; let publicationFails = restart; + let acceptedReport = ""; const modelInputs: string[] = []; const commands: string[] = []; const usagePath = join( @@ -72,6 +82,7 @@ for (const outcome of [ {}, { environment, + prepareScanArtifactRestorer, prepareRuntime: async () => { const runtime = preparedRuntime(codexHome); const manifest = JSON.parse( @@ -165,8 +176,23 @@ for (const outcome of [ id: threadId, async runStreamed(input: string) { modelInputs.push(input); - if (input === followUp) + if (input === followUp) { + if (outcome === "followup-canceled") { + const reportPath = join(scanDir, "report.md"); + acceptedReport = await readFile(reportPath, "utf8"); + expect(acceptedReport).toContain( + "Validate the resolved destination", + ); + await writeFile( + reportPath, + "Incomplete follow-up report.\n", + ); + cancellation.abort( + "Synthetic cancellation during follow-up", + ); + } return { events: completedEvents(threadId) }; + } expect(modelInputs.length).toBe(1); async function* events(): AsyncGenerator { yield { type: "thread.started", thread_id: threadId }; @@ -438,6 +464,32 @@ for (const outcome of [ expect(modelInputs.length).toBe(1); return; } + if (outcome === "followup-canceled") { + await expect( + client.run(repository, { + mode: "deep", + signal: cancellation.signal, + postScanPrompt: followUp, + }), + ).rejects.toThrow(/interrupted/); + const completed = await runWorkbench( + { ...workbenchOptions!, signal: undefined }, + ["get-scan", "--scan-id", scanId], + ); + expect(completed["scan"]).toMatchObject({ + progress: { status: "complete" }, + findingCount: 1, + reportAvailable: true, + }); + expect(modelInputs.length).toBe(2); + expect(modelInputs[1]).toBe(followUp); + expect(commands).not.toContain("cancel-scan"); + expect(commands).not.toContain("fail-scan"); + expect(await readFile(join(scanDir, "report.md"), "utf8")).toBe( + acceptedReport, + ); + return; + } const result = await client.run(repository, { mode: "deep", signal: cancellation.signal, @@ -472,5 +524,6 @@ for (const outcome of [ clearTimeout(keepAlive); await client.close(); } - }, 30_000); + }; + test(name, runCase, 30_000); } From 39310393e545455e07e9cfb418acb23c19ecf8bd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:37:00 +0000 Subject: [PATCH 064/251] Cover recovery from recorded immutable inputs --- .../tests/deep_scan_coverage_fixture.mjs | 82 +++++++++++++------ .../test_deep_scan_recorded_recovery.mjs | 33 ++++++++ 2 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_recorded_recovery.mjs diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index e88449336f..a896170f71 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; @@ -23,7 +23,7 @@ const bundled = await build({ }, format: "esm", platform: "node", loader: { ".md": "text" }, write: false, }); -export async function publishCoverageFixture(root, completeness, { resume = false, continueAfterResume = false, immutableInputs = false, materialFindings = false } = {}) { +export async function publishCoverageFixture(root, completeness, { resume = false, continueAfterResume = false, immutableInputs = false, materialFindings = false, discardMutableResults = false, legacyAttempts = false, splitSeededReducers = false } = {}) { const runtimePath = path.join(root, "fixture-runtime.mjs"); await writeFile(runtimePath, bundled.outputFiles[0].contents); const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityDeepReduction, getCodexSecurityDeepReducerInputs, saveScanDraftCheckpoint } = await import(pathToFileURL(runtimePath).href); @@ -105,32 +105,65 @@ export async function publishCoverageFixture(root, completeness, { resume = fals const workerRoot = path.join(run.scanDir, "artifacts", "deep_discovery", "workers", `discovery-${String(index + 1).padStart(4, "0")}`); const artifactDir = path.join(workerRoot, "output"); const worker = { id: randomUUID(), scanId: run.scanId, kind: "discovery", promptPath: path.join(workerRoot, "prompt.md"), artifactDir, attempt: index === 0 ? 2 : 1 }; - const resultManifestPath = await writeDiscovery(artifactDir, index); + const writtenPath = await writeDiscovery(artifactDir, index); + const resultManifestPath = discardMutableResults ? path.join(artifactDir, "result.json") : writtenPath; await writeFile(worker.promptPath, "Synthetic discovery prompt.\n"); for (const status of ["queued", "running", "succeeded"]) { await store.updateWorker({ ...worker, status, ...(status === "succeeded" ? { resultManifestPath } : {}) }); } workers.push({ ...worker, resultPath: resultManifestPath }); } - const artifactDir = path.join(run.scanDir, "artifacts", "deep_discovery", "dedup", "dedup-0001", "output"); - const promptPath = path.join(path.dirname(artifactDir), "prompt.md"); - await mkdir(artifactDir, { recursive: true }); - await writeFile(promptPath, "Synthetic reducer prompt.\n"); - const id = randomUUID(); - await store.claimDedup({ id, scanId: run.scanId, workerIds: workers.map((worker) => worker.id), artifactDir, promptPath }); - const resultManifestPath = path.join(artifactDir, "result.json"); - // Legacy accepted reducers omitted coverage entirely. - if (materialFindings) { - await writeReduction({ - root: artifactDir, repoRoot: targetPath, scanId: run.scanId, layout: "reducer", - deepReducer: { scanRoot: run.scanDir, claimedWorkers: workers }, - }); - } else { - await writeFile(resultManifestPath, JSON.stringify({ scanId: run.scanId, findings: [] })); + const batches = splitSeededReducers ? [workers.slice(0, 2), workers.slice(2)] : [workers]; + let lastReducerId; + for (const [index, batch] of batches.entries()) { + const label = `dedup-${String(index + 1).padStart(4, "0")}`; + const artifactDir = path.join(run.scanDir, "artifacts", "deep_discovery", "dedup", label, "output"); + const promptPath = path.join(path.dirname(artifactDir), "prompt.md"); + await mkdir(artifactDir, { recursive: true }); + await writeFile(promptPath, "Synthetic reducer prompt.\n"); + const id = randomUUID(); + const claimed = await store.claimDedup({ id, scanId: run.scanId, workerIds: batch.map((worker) => worker.id), artifactDir, promptPath }); + if (discardMutableResults) { + await store.updateWorker({ id, scanId: run.scanId, kind: "dedup", status: "running", artifactDir, promptPath, attempt: 1 }); + } + const resultManifestPath = path.join(artifactDir, "result.json"); + // Legacy accepted reducers omitted coverage entirely. + if (materialFindings) { + await writeReduction({ + root: artifactDir, repoRoot: targetPath, scanId: run.scanId, layout: "reducer", + deepReducer: { + scanRoot: run.scanDir, claimedWorkers: batch, + previousReducerResultPath: claimed.persistedMergeClaims?.find((claim) => claim.workerId === id)?.previousResultPath, + }, + }); + } else { + await writeFile(resultManifestPath, JSON.stringify({ scanId: run.scanId, findings: [] })); + } + rawSources.set(resultManifestPath, await readFile(resultManifestPath, "utf8")); + await store.commitDedup({ id, scanId: run.scanId, newFindings: materialFindings && index === 0 ? 1 : 0, resultManifestPath }); + lastReducerId = id; + } + if (legacyAttempts) { + // Migrated discoveries and prior reducers can have frozen claims without attempt rows. + await exec(process.env.PYTHON || "python3", ["-c", [ + "import sqlite3, sys", + "with sqlite3.connect(sys.argv[1]) as db:", + " db.execute(\"DELETE FROM deep_scan_attempts WHERE worker_id != ?\", (sys.argv[2],))", + ].join("\n"), path.join(root, "state", "workbench.sqlite3"), lastReducerId]); } - rawSources.set(resultManifestPath, await readFile(resultManifestPath, "utf8")); - await store.commitDedup({ id, scanId: run.scanId, newFindings: materialFindings ? 1 : 0, resultManifestPath }); run = await store.get(run.scanId, threadId); + if (discardMutableResults) { + for (const worker of run.persistedWorkers) { + const acceptedPath = worker.acceptedResultPath ?? run.persistedDedupInputs + .find((input) => input.discoveryWorkerId === worker.id)?.resultManifestPath + ?? run.persistedMergeClaims.find((claim) => claim.previousWorkerId === worker.id)?.previousResultPath; + assert.ok(acceptedPath, "the real store retains an accepted reference"); + assert.notEqual(acceptedPath, worker.resultManifestPath); + rawSources.set(acceptedPath, await readFile(acceptedPath, "utf8")); + rawSources.delete(worker.resultManifestPath); + await rm(worker.resultManifestPath); + } + } } let discoveryCalls = 0; const executor = { @@ -148,7 +181,7 @@ export async function publishCoverageFixture(root, completeness, { resume = fals const current = await store.get(run.scanId, threadId); for (const claimed of request.artifactContext.deepReducer.claimedWorkers) { const accepted = current.persistedWorkers.find((worker) => worker.id === claimed.id); - assert.equal(claimed.resultPath, accepted.resultManifestPath, "the reducer uses the exact accepted input"); + assert.equal(claimed.resultPath, accepted.acceptedResultPath ?? accepted.resultManifestPath, "the reducer uses the exact accepted input"); assert.equal(claimed.artifactDir, accepted.artifactDir, "receipts retain their original output owner"); } } @@ -166,12 +199,15 @@ export async function publishCoverageFixture(root, completeness, { resume = fals coordinator.start(); const terminal = await coordinator.wait(undefined, 30_000); assert.equal(terminal?.status, "succeeded", terminal?.error); - assert.equal(terminal.noNewStreak, materialFindings ? (resume && !continueAfterResume ? 0 : 1) : statuses.length, + assert.equal(terminal.noNewStreak, materialFindings ? (resume && !continueAfterResume && !splitSeededReducers ? 0 : 1) : statuses.length, "source coverage must not change stopping policy"); assert.equal(discoveryCalls, resume ? (continueAfterResume ? 1 : 0) : statuses.length + 1); const accepted = await store.get(run.scanId, threadId); for (const worker of accepted.persistedWorkers.filter((worker) => worker.kind === "dedup")) { - const result = JSON.parse(await readFile(worker.resultManifestPath, "utf8")); + const resultPath = worker.acceptedResultPath + ?? accepted.persistedMergeClaims.find((claim) => claim.previousWorkerId === worker.id)?.previousResultPath + ?? worker.resultManifestPath; + const result = JSON.parse(await readFile(resultPath, "utf8")); assert.equal(Object.hasOwn(result, "sourceCoverage"), false, "v1 reducers remain readable by earlier binaries"); if (!rawSources.has(worker.resultManifestPath)) { for (const name of await readdir(path.join(worker.artifactDir, "checkpoints"))) { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recorded_recovery.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recorded_recovery.mjs new file mode 100644 index 0000000000..8a7693dc6c --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recorded_recovery.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { materialRemediations, materialRemediationTests, publishCoverageFixture } from "./deep_scan_coverage_fixture.mjs"; + +for (const [continueAfterResume, legacyAttempts, splitSeededReducers] of [[false, false, false], [true, false, false], [true, true, false], [false, false, true], [false, true, true]]) { + test(`recorded inputs preserve fixes and coverage after mutable outputs disappear (continued: ${continueAfterResume}, legacy attempts: ${legacyAttempts}, reducer chain: ${splitSeededReducers})`, async () => { + const root = await mkdtemp(path.join(tmpdir(), "recorded-coverage-recovery-")); + try { + const fixture = path.join(root, "fixture"); + await mkdir(fixture, { mode: 0o700 }); + const { scanDir } = await publishCoverageFixture(fixture, "partial", { + resume: true, continueAfterResume, legacyAttempts, splitSeededReducers, + immutableInputs: true, materialFindings: true, discardMutableResults: true, + }); + const report = await readFile(path.join(scanDir, "report.md"), "utf8"); + for (const fix of [...materialRemediations, ...materialRemediationTests]) { + assert.equal(report.split(fix).length - 1, 1); + } + const coverage = JSON.parse(await readFile(path.join(scanDir, "coverage.json"), "utf8")); + assert.equal(coverage.completeness, "partial"); + assert.deepEqual(coverage.reviews.map((review) => review.completeness), ["partial", "complete", "unknown"]); + assert.equal(new Set(coverage.deferred.map((item) => item.candidateId)).size, 2); + for (const surface of coverage.surfaces) { + assert.equal(await readFile(path.join(scanDir, surface.receiptRefs[0]), "utf8"), "Synthetic review evidence.\n"); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} From 4883ec3caf2eb5b266c3f78d06249c011ba31eca Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:23:31 +0000 Subject: [PATCH 065/251] Test original native turn selections and missing recovery history --- .../mcp-app/tests/test_deep_scan_executor.mjs | 16 +++++++++++----- .../test_deep_scan_recovery_settings.mjs | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index c3b8cf0dd5..41b9d052d6 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -759,7 +759,9 @@ async function testIsolatedReconstructedWorkers() { model_reasoning_summary: name === "first" ? "none" : "concise", service_tier: name === "first" ? "flex" : "fast" }; - await writeFile(configPath, Object.entries(config).filter(([key]) => name !== "first" || key !== "model_provider").map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); + await writeFile(configPath, Object.entries(config).filter(([key]) => name !== "first" + || !["model_provider", "model_reasoning_summary"].includes(key)) + .map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); await copyFile(process.execPath, executable); @@ -783,10 +785,14 @@ async function testIsolatedReconstructedWorkers() { parentSandbox: trustedParentSandboxWithDenials }; await mkdir(path.join(codexHome, "sessions")); - await writeFile(path.join(codexHome, "sessions", "owner.jsonl"), JSON.stringify({ - type: "session_meta", timestamp: "2026-01-01T00:00:00Z", - payload: { id: `fixture-${name}-owner`, model_provider: config.model_provider } - }) + "\n"); + await writeFile(path.join(codexHome, "sessions", "owner.jsonl"), [ + { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", + payload: { id: `fixture-${name}-owner`, model_provider: config.model_provider } }, + { type: "turn_context", timestamp: "2026-01-01T00:00:01Z", + payload: { model: "native-parent-model", effort: "medium", summary: config.model_reasoning_summary } }, + { type: "turn_context", timestamp: "2026-01-01T00:02:00Z", + payload: { model: "later-parent-model", effort: "low", summary: "detailed" } } + ].map(JSON.stringify).join("\n") + "\n"); const saved = await loadOrCaptureDeepScanExecutionSettings(fixture.root, () => captureDeepScanExecutionSettings(settings, settings.parentSandbox, { ...codexOptions.env, CODEX_CLI_PATH: executable }, { threadId: `fixture-${name}-owner`, startedAt: "2026-01-01T00:01:00Z" })); const snapshotPath = path.join(fixture.root, "artifacts", "deep_discovery", "execution-settings.json"); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 7e242d9904..4ef6ecce8a 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -95,6 +95,25 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(parentSettings.modelProvider, "openai"); assert.equal(parentSettings.reasoningSummary, "none", "later owner turns are not original discovery settings"); assert.equal(parentSettings.reasoningEffort, "high"); + await writeFile(join(root, "config.toml"), ""); + const parentEnvironment = { CODEX_CLI_PATH: process.execPath, CODEX_HOME: root }; + const [originalParent, otherParent, unavailableParent] = await Promise.all([ + captureSettings({}, { filesystemDenies: [] }, parentEnvironment, + { threadId: "fixture-parent", startedAt: "2026-01-01T00:00:01Z" }), + captureSettings({}, { filesystemDenies: [] }, parentEnvironment, + { threadId: "fixture-other", startedAt: "2026-01-01T00:00:01Z" }), + captureSettings({ model: "stored-model", reasoningEffort: "ultra" }, { filesystemDenies: [] }, parentEnvironment, + { threadId: "fixture-unavailable", startedAt: "2026-01-01T00:00:01Z" }) + ]); + assert.equal(originalParent.model, "parent-model"); + assert.equal(originalParent.reasoningSummary, "none", "the original turn is included at its timestamp"); + assert.equal(otherParent.modelProvider, "other-provider"); + assert.equal(otherParent.model, undefined, "concurrent scans do not borrow another parent's model"); + assert.equal(otherParent.reasoningSummary, undefined); + assert.equal(unavailableParent.model, "stored-model"); + assert.equal(unavailableParent.reasoningEffort, "ultra"); + assert.equal(unavailableParent.modelProvider, undefined, "missing history does not establish a provider"); + assert.equal(unavailableParent.reasoningSummary, undefined); const unsupported = JSON.stringify({ version: 99, settings }); await writeFile(savedPath, unsupported); await assert.rejects(loadSettings(join(root, "one"), async () => assert.fail()), /unsupported/); From 9b3f703197b740fe4aafc391cc26d604f62ca034 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:23:31 +0000 Subject: [PATCH 066/251] Verify selected workflow ownership and observer compatibility --- .../tests/test_deep_scan_compatibility.mjs | 74 ++++++++++++++++--- .../tests/test_deep_scan_compatibility.py | 4 +- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs index 3263052fae..d001192dd2 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_compatibility.mjs @@ -14,6 +14,27 @@ const { startOrJoinDeepScanCoordinator, DeepScanRemoteCoordinator } = await impo ); await testUnsupportedWorkflowDoesNotAcquireOwnership(); +await testUnsupportedSelectionDoesNotAcquireOwnership(); + +async function testUnsupportedSelectionDoesNotAcquireOwnership() { + for (const selection of [ + { workflowVersion: "deep-security-scan/v1", finalizationInput: { version: 1 } }, + { workflowVersion: "deep-security-scan/v2", finalizationInput: { version: 99 } } + ]) { + await assert.rejects(startOrJoinDeepScanCoordinator({ + begin: { run: { scanId: "fixture", schemaVersion: 1, ...selection }, shouldStart: false }, + registry: { + get: () => assert.fail("unsupported selection inspected a live coordinator"), + start: () => assert.fail("unsupported selection started a coordinator") + }, + options: { + threadId: "fixture-thread", + prepareExecutor: async () => assert.fail("unsupported selection resolved settings"), + store: { claimCoordinator: async () => assert.fail("unsupported selection acquired ownership") } + } + }), /finalization input version/); + } +} async function testUnsupportedWorkflowDoesNotAcquireOwnership() { for (const version of [ @@ -38,21 +59,52 @@ async function testUnsupportedWorkflowDoesNotAcquireOwnership() { // A joining client must not resolve or replace the live executor's settings. -for (const local of [true, false]) { - let preparations = 0; - const run = { scanId: "fixture", status: "running", workflowVersion: "deep-scan-mcp/v1" }; - const options = { - threadId: "fixture-thread", - executor: { marker: "observer" }, - prepareExecutor: async () => { preparations += 1; return {}; }, - store: { claimCoordinator: async () => ({ run, acquired: false }) } +for (const workflowVersion of ["deep-scan-mcp/v1", "deep-security-scan/v1", "deep-security-scan/v2"]) { + for (const local of [true, false]) { + let preparations = 0; + const run = { scanId: "fixture", status: "running", workflowVersion }; + const options = { + threadId: "fixture-thread", + executor: { marker: "observer" }, + prepareExecutor: async () => { preparations += 1; return {}; }, + store: { claimCoordinator: async () => ({ run, acquired: false }) } + }; + await startOrJoinDeepScanCoordinator({ + begin: { run, shouldStart: false }, + registry: { get: () => local ? {} : undefined, start: () => assert.fail("observer started") }, + options + }); + assert.equal(preparations, 0); + } +} + +// Selected publication has no worker launch and must not need current settings. +for (const selected of [false, true]) { + const run = { + scanId: "fixture", status: "running", workflowVersion: "deep-security-scan/v2", + ...(selected ? { finalizationInput: { version: 1 } } : {}) }; + let preparations = 0; + const fallback = {}; + const restored = {}; await startOrJoinDeepScanCoordinator({ begin: { run, shouldStart: false }, - registry: { get: () => local ? {} : undefined, start: () => assert.fail("observer started") }, - options + registry: { + get: () => undefined, + start: (options) => { + assert.equal(options.executor, selected ? fallback : restored); + assert.equal(options.run, run); + return {}; + } + }, + options: { + threadId: "fixture-thread", + executor: fallback, + prepareExecutor: async () => { preparations += 1; return restored; }, + store: { claimCoordinator: async () => ({ run, acquired: true }) } + } }); - assert.equal(preparations, 0); + assert.equal(preparations, selected ? 0 : 1); } const originalNow = Date.now; diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py index b9305b87a0..4d84e96c3e 100644 --- a/plugins/codex-security/tests/test_deep_scan_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_compatibility.py @@ -16,7 +16,9 @@ def snapshot(state_dir: Path) -> str: return "\n".join(connection.iterdump()) -@pytest.mark.parametrize("version", ["deep-security-scan/v1", "deep-scan-mcp/v1"]) +@pytest.mark.parametrize( + "version", ["deep-security-scan/v1", "deep-scan-mcp/v1", "deep-security-scan/v2"] +) def test_supported_workflows_keep_their_identity(tmp_path: Path, version: str) -> None: target = tmp_path / "target" target.mkdir() From 4495367e9f11ef15e2850a5364a79cad2f051b95 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:23:31 +0000 Subject: [PATCH 067/251] Verify original discovery context and deadline through restart --- .../tests/test_deep_scan_stdio_lifecycle.mjs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 8067869a7f..67e0993485 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -442,7 +442,7 @@ async function testDeepScanStdioLifecycle() { const sessionId = opened.result.structuredContent.workspace.id; assertNoError(await server.request(25, "tools/call", toolCall( "submit_codex_security_setup", - { sessionId, targetPath, scope: ".", mode: "deep" }, + { sessionId, targetPath, scope: ".", mode: "deep", userContext: "Original discovery focus" }, resumedThreadId ))); const started = await server.request(26, "tools/call", toolCall( @@ -487,6 +487,14 @@ async function testDeepScanStdioLifecycle() { const completedDraft = JSON.parse(await readFile(completedWorker.resultManifestPath, "utf8")); assert.equal(completedDraft.scanId, resumedScanId); assert.deepEqual(completedDraft.findings, []); + assert.equal(partial.userContext, "Original discovery focus"); + const settingsPath = path.join(resumedScan.scanDir, "artifacts", "deep_discovery", "execution-settings.json"); + const originalSettings = await readFile(settingsPath, "utf8"); + assertNoError(await server.request(30, "tools/call", toolCall( + "update_codex_security_scan_context", + { scanId: resumedScanId, handoffClaimToken, userContext: "Later result discussion" }, + resumedThreadId + ))); await server.stop(); assert.throws(() => process.kill(server.pid, 0), "the original MCP server must have exited"); const paused = await runWorkbench(environment, ["get-scan", "--scan-id", resumedScanId]); @@ -544,6 +552,10 @@ async function testDeepScanStdioLifecycle() { assert.equal(finished.status, "succeeded"); assert.equal(finished.coordinatorGeneration, partial.coordinatorGeneration + 1); assert.equal(finished.dispatchedCount, 2); + assert.equal(finished.userContext, partial.userContext); + assert.equal(finished.createdAt, partial.createdAt, "recovery retains the original deadline origin"); + assert.equal(finished.config.maxTimeHours, partial.config.maxTimeHours); + assert.equal(await readFile(settingsPath, "utf8"), originalSettings); const successfulDiscoveries = finished.workers.filter((worker) => ( worker.kind === "discovery" && worker.status === "succeeded" )); @@ -562,6 +574,8 @@ async function testDeepScanStdioLifecycle() { const executions = (await readJsonLines(startLogPath)).slice(restartStartIndex); for (const execution of executions) { assert.equal(execution.argv.includes('model_reasoning_summary="none"'), true); + const context = discoveryPromptContext(execution.stdin); + if (context.workerLabel) assert.equal(context.userContext, "Original discovery focus"); } assert.equal(executions.filter((execution) => ( discoveryPromptContext(execution.stdin).workerLabel === "discovery-0001" From d3956fb1af5236fc840eba9d6d18a7a5442fc440 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:34:50 +0000 Subject: [PATCH 068/251] Recover worker selections from the recorded original owner turn --- .../mcp-app/src/deep-scan/recovery-settings.ts | 13 ++++++++++--- .../mcp-app/src/deep-scan/store.ts | 11 +++++++++++ .../mcp-app/src/deep-scan/types.ts | 2 ++ .../mcp-app/tests/test_deep_scan_executor.mjs | 11 ++++++++--- .../tests/test_deep_scan_recovery_settings.mjs | 17 +++++++++++++++-- .../mcp-app/tests/test_deep_scan_store.mjs | 9 +++++++++ 6 files changed, 55 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 5554250664..884486a7f4 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -9,6 +9,7 @@ import { readScanLogs } from "../../../../../sdk/typescript/src/scan-logs.js"; import { writeJsonAtomic } from "./artifacts.js"; import { resolveCodexPath } from "./executor.js"; import type { DeepWorkerParentSandbox } from "./parent-sandbox.js"; +import type { DeepScanRunState } from "./types.js"; /** Credentials and arbitrary environment/configuration stay with Codex. */ export interface DeepScanExecutionSettings { @@ -24,7 +25,7 @@ export interface DeepScanExecutionSettings { } export async function captureDeepScanExecutionSettings( - original: { model?: string; reasoningEffort?: string }, + original: Pick, parentSandbox: DeepWorkerParentSandbox, environment: NodeJS.ProcessEnv = process.env, parent?: { threadId: string; startedAt?: string } @@ -40,7 +41,12 @@ export async function captureDeepScanExecutionSettings( } // Reuse the SDK projection: custom provider credentials belong in the native home. const selected = scanPreflightCodexConfig(resolveCodexProfile(config)); - const native = parent === undefined ? {} : await originalParentSettings(codexHome, parent); + // A recovered scan can have a different continuation. Only its recorded owner + // establishes original history; null means that historical binding is missing. + const owner = original.usageOwner === undefined ? parent : original.usageOwner; + const native = !owner?.threadId ? {} : await originalParentSettings(codexHome, { + ...owner, threadId: owner.threadId, startedAt: parent?.startedAt ?? owner.startedAt + }); return executionSettings({ codexPath: resolveCodexPath(environment, process.platform, process.arch, process.cwd()), codexHome: !isAbsolute(codexHome) @@ -58,7 +64,7 @@ export async function captureDeepScanExecutionSettings( async function originalParentSettings( codexHome: string, - parent: { threadId: string; startedAt?: string } + parent: { threadId: string; turnId?: string | null; startedAt?: string } ): Promise> { // Native config/read represents omitted selections as null. The existing // parent record contains the provider and summary actually used by that turn. @@ -81,6 +87,7 @@ async function originalParentSettings( settings.modelProvider = context.model_provider; } if (event.type === "turn_context") { + if (parent.turnId && context.turn_id !== parent.turnId) continue; if (typeof context.model === "string") settings.model = context.model; if (typeof context.effort === "string") settings.reasoningEffort = context.effort; if (typeof context.summary === "string") settings.reasoningSummary = context.summary; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index 4246962f72..357e910ed1 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -619,6 +619,7 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { schemaVersion: optionalPositiveInteger(value.schemaVersion), workflowVersion: optionalString(value.workflowVersion), finalizationInput: parseFinalizationInput(value.finalizationInput), + usageOwner: parseUsageOwner(value.usageOwner), status, phase: deepScanPhase(value.phase), coordinatorGeneration: optionalPositiveInteger(value.coordinatorGeneration), @@ -658,6 +659,16 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { }; } +function parseUsageOwner(value: unknown): DeepScanRunState["usageOwner"] { + if (value === undefined || value === null) return null; + const owner = objectValue(value, "deepScan.usageOwner"); + return { + threadId: optionalString(owner.threadId) ?? null, + turnId: optionalString(owner.turnId) ?? null, + startedAt: requiredString(owner.startedAt, "deepScan.usageOwner.startedAt") + }; +} + function parseFinalizationInput(value: unknown): DeepScanRunState["finalizationInput"] { if (value === undefined || value === null) return undefined; const input = objectValue(value, "deepScan.finalizationInput"); diff --git a/plugins/codex-security/mcp-app/src/deep-scan/types.ts b/plugins/codex-security/mcp-app/src/deep-scan/types.ts index 9e0a22fe8b..505b3722d2 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/types.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/types.ts @@ -1,4 +1,5 @@ import type { DeepReducerContext } from "../artifact-io.js"; +import type { ScanExecutionAttribution } from "../../../../../sdk/typescript/src/scan-sessions.js"; export type DeepScanTerminalReason = "saturated" | "capped"; @@ -51,6 +52,7 @@ export interface DeepScanRunState { schemaVersion?: number; workflowVersion?: string; finalizationInput?: DeepScanFinalizationInput; + usageOwner?: ScanExecutionAttribution["owner"] | null; status: DeepScanRunStatus; phase?: "setup" | "discovery" | "reducing" | "terminal"; coordinatorGeneration?: number; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 41b9d052d6..3f98f69740 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -782,6 +782,7 @@ async function testIsolatedReconstructedWorkers() { codexOptions, model: `fixture-${name}-override`, reasoningEffort: "ultra", + usageOwner: { threadId: `fixture-${name}-owner`, turnId: "original-turn", startedAt: "2026-01-01T00:00:00Z" }, parentSandbox: trustedParentSandboxWithDenials }; await mkdir(path.join(codexHome, "sessions")); @@ -789,12 +790,16 @@ async function testIsolatedReconstructedWorkers() { { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: `fixture-${name}-owner`, model_provider: config.model_provider } }, { type: "turn_context", timestamp: "2026-01-01T00:00:01Z", - payload: { model: "native-parent-model", effort: "medium", summary: config.model_reasoning_summary } }, + payload: { turn_id: "original-turn", model: "native-parent-model", effort: "medium", summary: config.model_reasoning_summary } }, { type: "turn_context", timestamp: "2026-01-01T00:02:00Z", - payload: { model: "later-parent-model", effort: "low", summary: "detailed" } } + payload: { turn_id: "later-turn", model: "later-parent-model", effort: "low", summary: "detailed" } } ].map(JSON.stringify).join("\n") + "\n"); + await writeFile(path.join(codexHome, "sessions", "observer.jsonl"), JSON.stringify({ + type: "session_meta", timestamp: "2026-01-01T00:00:00Z", + payload: { id: `fixture-${name}-observer`, model_provider: "observer-provider" } + }) + "\n"); const saved = await loadOrCaptureDeepScanExecutionSettings(fixture.root, () => - captureDeepScanExecutionSettings(settings, settings.parentSandbox, { ...codexOptions.env, CODEX_CLI_PATH: executable }, { threadId: `fixture-${name}-owner`, startedAt: "2026-01-01T00:01:00Z" })); + captureDeepScanExecutionSettings(settings, settings.parentSandbox, { ...codexOptions.env, CODEX_CLI_PATH: executable }, { threadId: `fixture-${name}-observer`, startedAt: "2026-01-01T00:01:00Z" })); const snapshotPath = path.join(fixture.root, "artifacts", "deep_discovery", "execution-settings.json"); const snapshot = await readFile(snapshotPath, "utf8"); assert.equal(snapshot.includes("synthetic-"), false); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 4ef6ecce8a..f36d044335 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -82,8 +82,8 @@ http_headers = { Authorization = "synthetic-secret" } await mkdir(sessionDirectory); await writeFile(join(sessionDirectory, "parent.jsonl"), [ { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: "fixture-parent", model_provider: "openai" } }, - { type: "turn_context", timestamp: "2026-01-01T00:00:01Z", payload: { model: "parent-model", effort: "high", summary: "none" } }, - { type: "turn_context", timestamp: "2026-01-01T00:02:00Z", payload: { model: "later-model", effort: "low", summary: "detailed" } } + { type: "turn_context", timestamp: "2026-01-01T00:00:01Z", payload: { turn_id: "original-turn", model: "parent-model", effort: "high", summary: "none" } }, + { type: "turn_context", timestamp: "2026-01-01T00:02:00Z", payload: { turn_id: "later-turn", model: "later-model", effort: "low", summary: "detailed" } } ].map(JSON.stringify).join("\n") + "\n"); await writeFile(join(sessionDirectory, "other.jsonl"), JSON.stringify({ type: "session_meta", payload: { id: "fixture-other", model_provider: "other-provider" } @@ -114,6 +114,19 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(unavailableParent.reasoningEffort, "ultra"); assert.equal(unavailableParent.modelProvider, undefined, "missing history does not establish a provider"); assert.equal(unavailableParent.reasoningSummary, undefined); + const originalOwner = { threadId: "fixture-parent", turnId: "original-turn", startedAt: "2026-01-01T00:00:00Z" }; + const [rebound, unboundLegacy] = await Promise.all([ + captureSettings({ usageOwner: originalOwner }, { filesystemDenies: [] }, parentEnvironment, + { threadId: "fixture-other", startedAt: "2026-01-01T00:03:00Z" }), + captureSettings({ model: "stored-model", usageOwner: null }, { filesystemDenies: [] }, parentEnvironment, + { threadId: "fixture-other", startedAt: "2026-01-01T00:03:00Z" }) + ]); + assert.equal(rebound.modelProvider, "openai", "takeover uses the recorded owner, not the invoking conversation"); + assert.equal(rebound.model, "parent-model", "the bound turn takes precedence over later turns"); + assert.equal(rebound.reasoningSummary, "none"); + assert.equal(unboundLegacy.model, "stored-model"); + assert.equal(unboundLegacy.modelProvider, undefined, "unrecorded legacy ownership cannot recover caller selections"); + assert.equal(unboundLegacy.reasoningSummary, undefined); const unsupported = JSON.stringify({ version: 99, settings }); await writeFile(savedPath, unsupported); await assert.rejects(loadSettings(join(root, "one"), async () => assert.fail()), /unsupported/); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs index d56bee20a6..cebf94c9c6 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs @@ -36,6 +36,15 @@ await testPersistenceRetryExhaustionPreservesDiagnostics(); await testDeterministicPersistenceFailuresAreNotRetried(); await testNonIdempotentMutationsAreNotRetried(); testInvalidPersistedConfig(); +testOriginalUsageOwnerParsing(); + +function testOriginalUsageOwnerParsing() { + const value = stateResult(randomUUID()); + const usageOwner = { threadId: "original-thread", turnId: "original-turn", startedAt: "2026-01-01T00:00:00Z" }; + assert.deepEqual(parseDeepScan({ deepScan: { ...value.deepScan, usageOwner } }).usageOwner, usageOwner); + assert.equal(parseDeepScan({ deepScan: { ...value.deepScan, usageOwner: null } }).usageOwner, null); + assert.equal(parseDeepScan(value).usageOwner, null, "old readers do not establish an original owner"); +} async function testBeginProtocolAndParsing() { const scanId = randomUUID(); From 858da4c3130dcd95b552af462a182eb48ac77d8f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:39:35 +0000 Subject: [PATCH 069/251] Verify original settings and usage survive continuation handoff --- .../tests/test_deep_scan_stdio_lifecycle.mjs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 67e0993485..33a81f946a 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -432,7 +432,7 @@ async function testDeepScanStdioLifecycle() { "the MCP server must remain responsive after canceling one scan" ); - const resumedThreadId = "deep-scan-stdio-resumed-thread"; + let resumedThreadId = "deep-scan-stdio-resumed-thread"; const opened = await server.request(24, "tools/call", toolCall( "open_codex_security_workspace", { targetPath, scope: ".", mode: "deep" }, @@ -453,7 +453,7 @@ async function testDeepScanStdioLifecycle() { assertNoError(started); const resumedScan = started.result.structuredContent.workspace.results; const resumedScanId = resumedScan.scanId; - const handoffClaimToken = randomUUID(); + let handoffClaimToken = randomUUID(); for (const [id, name, arguments_] of [ [27, "claim_codex_security_scan_handoff_delivery", { scanId: resumedScanId, claimToken: handoffClaimToken @@ -488,6 +488,7 @@ async function testDeepScanStdioLifecycle() { assert.equal(completedDraft.scanId, resumedScanId); assert.deepEqual(completedDraft.findings, []); assert.equal(partial.userContext, "Original discovery focus"); + assert.equal(partial.usageOwner.threadId, resumedThreadId); const settingsPath = path.join(resumedScan.scanDir, "artifacts", "deep_discovery", "execution-settings.json"); const originalSettings = await readFile(settingsPath, "utf8"); assertNoError(await server.request(30, "tools/call", toolCall( @@ -523,6 +524,18 @@ async function testDeepScanStdioLifecycle() { path.join(stateDir, "workbench.sqlite3"), resumedScanId ]); + await runWorkbench(environment, [ + "release-handoff-delivery", "--scan-id", resumedScanId, "--claim-token", handoffClaimToken + ]); + handoffClaimToken = randomUUID(); + resumedThreadId = "deep-scan-stdio-replacement-thread"; + await runWorkbench(environment, [ + "claim-handoff-delivery", "--scan-id", resumedScanId, "--claim-token", handoffClaimToken + ]); + await runWorkbench(environment, [ + "attach-scan-continuation-thread", "--scan-id", resumedScanId, + "--claim-token", handoffClaimToken, "--thread-id", resumedThreadId + ]); await writeFile(restartControlPath, "after-restart"); // A replacement caller's configuration must not replace the original selection. await writeFile(runtimeConfigPath, 'model_reasoning_summary = "detailed"\n'); @@ -555,6 +568,7 @@ async function testDeepScanStdioLifecycle() { assert.equal(finished.userContext, partial.userContext); assert.equal(finished.createdAt, partial.createdAt, "recovery retains the original deadline origin"); assert.equal(finished.config.maxTimeHours, partial.config.maxTimeHours); + assert.deepEqual(finished.usageOwner, partial.usageOwner, "a replacement continuation does not rebind original usage"); assert.equal(await readFile(settingsPath, "utf8"), originalSettings); const successfulDiscoveries = finished.workers.filter((worker) => ( worker.kind === "discovery" && worker.status === "succeeded" From dfe353efc4a0b403d8a616cb36a24e99a6ddbad7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:22:26 +0000 Subject: [PATCH 070/251] test(sdk): preserve installed Deep result conversations --- .../scripts/fixtures/package-deep-scan.mjs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index 248d6031bb..c774c95675 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -354,6 +354,10 @@ async function runInstalledSdk(pluginRoot, executable) { await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), ); const owner = "package-sdk-owner"; + const postScanPrompt = "Explain the completed synthetic scan."; + const prompts = []; + let threadCount = 0; + let manifestBeforeFollowUp; let scanId; const client = new sdk.CodexSecurity( { pythonPath: f.env.PYTHON }, @@ -377,12 +381,33 @@ async function runInstalledSdk(pluginRoot, executable) { createCodex({ env, apiKey }) { return { startThread() { + threadCount += 1; return { id: owner, - async runStreamed() { + async runStreamed(prompt) { + prompts.push(prompt); return { events: (async function* () { yield { type: "thread.started", thread_id: owner }; + if (prompts.length > 1) { + assert.equal(prompt, postScanPrompt); + manifestBeforeFollowUp = await readFile( + join(env.CODEX_SECURITY_SCAN_DIR, "scan-manifest.json"), + "utf8", + ); + const completed = JSON.parse(manifestBeforeFollowUp); + assert.equal(completed.scan.status, "completed"); + assert.ok(completed.scan.sealedAt); + yield { + type: "turn.completed", + usage: { + input_tokens: 100_000, + cached_input_tokens: 0, + output_tokens: 100_000, + }, + }; + return; + } scanId = env.CODEX_SECURITY_SCAN_ID; // The pinned SDK maps its apiKey option to this child variable. const rpc = await server(f, { @@ -424,13 +449,23 @@ async function runInstalledSdk(pluginRoot, executable) { subagents: 0, maxDiscoveryRuns: 2, stopAfterNoNew: 1, + postScanPrompt, outputDir: join(f.directory, "output"), }); + assert.equal(threadCount, 1); + assert.equal(prompts.length, 2); + assert.equal(prompts[1], postScanPrompt); assert.equal(result.threadId, owner); assert.equal(result.manifest.scan.status, "completed"); assert.ok(result.manifest.scan.sealedAt); assert.equal(result.manifest.scan.id, scanId); assert.deepEqual(result.findings.findings, []); + assert.equal( + await readFile(result.manifestPath, "utf8"), + manifestBeforeFollowUp, + ); + assert.ok(result.cost === null || result.cost.inputTokens < 100_000); + assert.equal(result.toJSON().threadId, owner); assert.ok( (await readFile(join(f.directory, "output", "report.md"), "utf8")) .length > 0, From fd0e3dfa1b80c275d1f134a2bd986231410ad97f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:38:58 +0000 Subject: [PATCH 071/251] test(plugin): account for immutable accepted checkpoint fixtures --- .../tests/test_workbench_standard_deep_results.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/tests/test_workbench_standard_deep_results.py b/plugins/codex-security/tests/test_workbench_standard_deep_results.py index de2b5ec220..144cf2a33c 100644 --- a/plugins/codex-security/tests/test_workbench_standard_deep_results.py +++ b/plugins/codex-security/tests/test_workbench_standard_deep_results.py @@ -366,6 +366,9 @@ def test_explicit_recovery_preserves_sealed_parent_with_empty_source_map( state_dir, codex_home, target, scan_dir, scan_id = deep_scan_fixture(tmp_path) _, result_path = accepted_standard_worker(state_dir, codex_home, scan_dir, scan_id) result_path.unlink() + # Remove the immutable accepted copy too, leaving no recoverable worker source. + for checkpoint in (result_path.parent / "checkpoints").glob("*.json"): + checkpoint.unlink() contract_dir = tmp_path / "contract" contract_dir.mkdir() scripts_dir = Path(__file__).resolve().parents[1] / "scripts" @@ -1012,6 +1015,9 @@ def test_canceled_scan_reports_noop_coordinator_publication(tmp_path: Path) -> N state_dir, codex_home, _, scan_dir, scan_id = deep_scan_fixture(tmp_path) _, result_path = accepted_standard_worker(state_dir, codex_home, scan_dir, scan_id) result_path.write_text("{incomplete") + # A valid immutable copy would let publication recover despite this corruption. + for checkpoint in (result_path.parent / "checkpoints").glob("*.json"): + checkpoint.unlink() scripts_dir = Path(__file__).resolve().parents[1] / "scripts" wrapper = tmp_path / "fail_before_canceled_sources_are_frozen.py" @@ -1584,7 +1590,7 @@ def test_complete_worker_supersedes_obsolete_checkpoint_coverage(tmp_path: Path) }, } checkpoints = result_path.parent / "checkpoints" - checkpoints.mkdir() + checkpoints.mkdir(exist_ok=True) (checkpoints / ("0" * 64 + ".json")).write_text(json.dumps(checkpoint)) run_workbench( @@ -1826,7 +1832,7 @@ def test_recovery_selects_strongest_same_finding_checkpoint(tmp_path: Path) -> N strong["confidence"]["level"] = "high" strong["summary"] = "Later strong checkpoint evidence." checkpoint_dir = result_path.parent / "checkpoints" - checkpoint_dir.mkdir() + checkpoint_dir.mkdir(exist_ok=True) for name, finding in (("0" * 64, weak), ("f" * 64, strong)): (checkpoint_dir / f"{name}.json").write_text( json.dumps( From 8d05203bc4831138a8e31af83a499c39c4ac0949 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:50:07 +0000 Subject: [PATCH 072/251] Recover legacy accepted inputs from frozen merge references --- .../mcp-app/src/deep-scan/coordinator.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 1e98eff65d..03f9d58fb5 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -960,7 +960,13 @@ export class DeepScanCoordinator { const recovered: AcceptedDiscovery[] = []; for (const worker of this.state.persistedWorkers ?? []) { if (worker.kind !== "discovery" || worker.status !== "succeeded") continue; - const resultPath = worker.acceptedResultPath ?? worker.resultManifestPath; + // Migrated workers can have frozen merge inputs without an attempt record. + const claimedInput = this.state.persistedDedupInputs?.find((input) => ( + input.discoveryWorkerId === worker.id + && (input.attempt === undefined || input.attempt === worker.attempt) + && input.resultManifestPath + )); + const resultPath = worker.acceptedResultPath ?? claimedInput?.resultManifestPath ?? worker.resultManifestPath; if (!resultPath || !worker.completionSequence) { throw new Error(`Accepted discovery ${worker.id} has incomplete persisted evidence.`); } @@ -997,7 +1003,12 @@ export class DeepScanCoordinator { )); let noNewStreak = 0; for (const worker of completedReducers) { - const resultPath = worker.acceptedResultPath ?? worker.resultManifestPath; + // A later merge claim can retain a legacy aggregate's accepted reference. + const resultPath = worker.acceptedResultPath + ?? this.state.persistedMergeClaims?.find((claim) => ( + claim.previousWorkerId === worker.id && claim.previousResultPath + ))?.previousResultPath + ?? worker.resultManifestPath; if (!resultPath) { throw new Error(`Completed reducer ${worker.id} has no persisted result manifest.`); } From ea566079d03105da41b0de8b898b5bedd7a5528e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:50:54 +0000 Subject: [PATCH 073/251] Verify selected finalization preserves coverage and material fixes --- .../tests/deep_scan_coverage_fixture.mjs | 95 ++++++++++++++++--- .../test_deep_scan_selected_coverage.mjs | 36 +++++++ 2 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_selected_coverage.mjs diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index a896170f71..fba7de5b04 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -14,6 +14,8 @@ const bundled = await build({ stdin: { contents: [ 'export { DeepScanCoordinator } from "./src/deep-scan/coordinator.ts";', + 'export { createDeepScanArtifacts } from "./src/deep-scan/artifacts.ts";', + 'export { validateDiscoveryArtifacts } from "./src/deep-scan/artifact-validation.ts";', 'export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts";', 'export { createScanArtifactContext } from "./src/artifact-context.ts";', 'export { recordCodexSecurityScanDraftViaWorkbench, saveScanDraftCheckpoint } from "./src/artifact-scan-draft.ts";', @@ -23,10 +25,19 @@ const bundled = await build({ }, format: "esm", platform: "node", loader: { ".md": "text" }, write: false, }); -export async function publishCoverageFixture(root, completeness, { resume = false, continueAfterResume = false, immutableInputs = false, materialFindings = false, discardMutableResults = false, legacyAttempts = false, splitSeededReducers = false } = {}) { +export async function publishCoverageFixture(root, completeness, { + resume = false, + continueAfterResume = false, + immutableInputs = false, + materialFindings = false, + discardMutableResults = false, + legacyAttempts = false, + splitSeededReducers = false, + selectedRecovery = false, +} = {}) { const runtimePath = path.join(root, "fixture-runtime.mjs"); await writeFile(runtimePath, bundled.outputFiles[0].contents); - const { DeepScanCoordinator, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityDeepReduction, getCodexSecurityDeepReducerInputs, saveScanDraftCheckpoint } = await import(pathToFileURL(runtimePath).href); + const { DeepScanCoordinator, createDeepScanArtifacts, validateDiscoveryArtifacts, WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityDeepReduction, getCodexSecurityDeepReducerInputs, saveScanDraftCheckpoint } = await import(pathToFileURL(runtimePath).href); const targetPath = path.join(root, "target"); const codexHome = path.join(root, "codex-home"); const scanRoot = path.join(root, "scans"); @@ -39,14 +50,29 @@ export async function publishCoverageFixture(root, completeness, { resume = fals await writeFile(path.join(targetPath, "source.py"), "# Synthetic source\n"); await writeFile(path.join(codexHome, "codex-security", "config.toml"), `[deep_scan]\nworkers = 1\nsubagents = 0\nstop_after_no_new = ${statuses.length}\nmax_discovery_runs = ${statuses.length}\n`); - const runWorkbench = async (args) => { - const { stdout } = await exec(process.env.PYTHON || "python3", [path.join(pluginRoot, "scripts", "workbench_db.py"), ...args], { + const runWorkbench = async (args, input, selectFinalization = false) => { + const script = path.join(pluginRoot, "scripts", "workbench_db.py"); + const pythonArgs = selectFinalization + ? ["-c", "import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](select_finalization=True)", script, ...args] + : [script, ...args]; + const execution = exec(process.env.PYTHON || "python3", pythonArgs, { env: { ...process.env, CODEX_HOME: codexHome, CODEX_SECURITY_STATE_DIR: path.join(root, "state") }, }); + if (input !== undefined) execution.child.stdin.end(input); + const { stdout } = await execution; return JSON.parse(stdout); }; const store = new WorkbenchDeepScanStore(runWorkbench); let { run } = await store.begin({ targetPath, scope: ".", threadId, scanRoot }); + if (selectedRecovery) { + // Exercise an existing v2 run without enabling the new-run writer. + await exec(process.env.PYTHON || "python3", ["-c", [ + "import sqlite3, sys", + "with sqlite3.connect(sys.argv[1]) as db:", + " db.execute(\"UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2' WHERE scan_id = ?\", (sys.argv[2],))", + ].join("\n"), path.join(root, "state", "workbench.sqlite3"), run.scanId]); + ({ run } = await store.claimCoordinator({ scanId: run.scanId, threadId })); + } const context = await createScanArtifactContext(run.scanId, runWorkbench, { requireRunning: true }); const rawSources = new Map(); const writeReduction = async (context) => { @@ -115,6 +141,7 @@ export async function publishCoverageFixture(root, completeness, { resume = fals } const batches = splitSeededReducers ? [workers.slice(0, 2), workers.slice(2)] : [workers]; let lastReducerId; + let lastReducerReference; for (const [index, batch] of batches.entries()) { const label = `dedup-${String(index + 1).padStart(4, "0")}`; const artifactDir = path.join(run.scanDir, "artifacts", "deep_discovery", "dedup", label, "output"); @@ -132,7 +159,12 @@ export async function publishCoverageFixture(root, completeness, { resume = fals await writeReduction({ root: artifactDir, repoRoot: targetPath, scanId: run.scanId, layout: "reducer", deepReducer: { - scanRoot: run.scanDir, claimedWorkers: batch, + scanRoot: run.scanDir, + claimedWorkers: batch.map((worker) => { + const input = claimed.persistedDedupInputs.find((input) => input.dedupWorkerId === id && input.discoveryWorkerId === worker.id); + return { ...worker, resultPath: input.resultManifestPath ?? worker.resultPath, attempt: input.attempt ?? worker.attempt }; + }), + persistSourceCoverage: selectedRecovery, previousReducerResultPath: claimed.persistedMergeClaims?.find((claim) => claim.workerId === id)?.previousResultPath, }, }); @@ -140,7 +172,8 @@ export async function publishCoverageFixture(root, completeness, { resume = fals await writeFile(resultManifestPath, JSON.stringify({ scanId: run.scanId, findings: [] })); } rawSources.set(resultManifestPath, await readFile(resultManifestPath, "utf8")); - await store.commitDedup({ id, scanId: run.scanId, newFindings: materialFindings && index === 0 ? 1 : 0, resultManifestPath }); + const committed = await store.commitDedup({ id, scanId: run.scanId, newFindings: materialFindings && index === 0 ? 1 : 0, resultManifestPath }); + lastReducerReference = committed.committedMerge.resultManifestPath; lastReducerId = id; } if (legacyAttempts) { @@ -164,6 +197,12 @@ export async function publishCoverageFixture(root, completeness, { resume = fals await rm(worker.resultManifestPath); } } + if (selectedRecovery) { + run = await store.selectFinalization({ + scanId: run.scanId, reason: "capped", manifestPath: path.join(run.scanDir, "scan-manifest.json"), + resultPath: lastReducerReference, omittedWorkerIds: [], + }); + } } let discoveryCalls = 0; const executor = { @@ -190,14 +229,44 @@ export async function publishCoverageFixture(root, completeness, { resume = fals return { threadId: thread, finalResponse: "Audit finished." }; }, }; - const coordinator = new DeepScanCoordinator({ + let publicationCalls = 0; + const options = { run, store, executor, pluginRoot, retryDelaysMs: [1], - onComplete: async (draft, signal) => { - await recordCodexSecurityScanDraftViaWorkbench(context, draft, runWorkbench, signal); + onComplete: async (draft, signal, publication) => { + publicationCalls++; + if (selectedRecovery && publicationCalls === 1) throw new Error("Synthetic selected publication failure"); + await recordCodexSecurityScanDraftViaWorkbench(context, draft, runWorkbench, signal, selectedRecovery ? publication : undefined); }, - }); + }; + const coordinator = new DeepScanCoordinator(options); coordinator.start(); - const terminal = await coordinator.wait(undefined, 30_000); + let terminal; + if (selectedRecovery) { + await assert.rejects(coordinator.wait(undefined, 30_000), /Synthetic selected publication failure/); + const pending = await store.get(run.scanId, threadId); + assert.equal(pending.status, "running"); + assert.deepEqual(pending.finalizationInput, run.finalizationInput); + const worker = pending.persistedWorkers.find((worker) => worker.kind === "discovery"); + const rejected = { scanId: run.scanId, complete: false, findings: [], coverage: { + completeness: "complete", surfaces: [], explicitExclusions: [], deferred: [], + } }; + await saveScanDraftCheckpoint({ root: worker.artifactDir, repoRoot: targetPath, layout: "worker" }, rejected); + const replacement = path.join(worker.artifactDir, "result.json"); + await writeFile(replacement, JSON.stringify(rejected)); + await assert.rejects(validateDiscoveryArtifacts(createDeepScanArtifacts(run.scanDir), replacement, run.scanId), /only a checkpoint/); + const headPath = path.join(worker.artifactDir, "checkpoint-head.json"); + const head = JSON.parse(await readFile(headPath, "utf8")); + for (const file of [replacement, headPath, path.join(worker.artifactDir, "checkpoints", head.checkpoint)]) { + rawSources.set(file, await readFile(file, "utf8")); + } + const restarted = new DeepScanCoordinator({ ...options, run: pending }); + restarted.start(); + terminal = await restarted.wait(undefined, 30_000); + assert.deepEqual(terminal.finalizationInput, run.finalizationInput); + assert.equal(publicationCalls, 2); + } else { + terminal = await coordinator.wait(undefined, 30_000); + } assert.equal(terminal?.status, "succeeded", terminal?.error); assert.equal(terminal.noNewStreak, materialFindings ? (resume && !continueAfterResume && !splitSeededReducers ? 0 : 1) : statuses.length, "source coverage must not change stopping policy"); @@ -208,11 +277,11 @@ export async function publishCoverageFixture(root, completeness, { resume = fals ?? accepted.persistedMergeClaims.find((claim) => claim.previousWorkerId === worker.id)?.previousResultPath ?? worker.resultManifestPath; const result = JSON.parse(await readFile(resultPath, "utf8")); - assert.equal(Object.hasOwn(result, "sourceCoverage"), false, "v1 reducers remain readable by earlier binaries"); + assert.equal(Object.hasOwn(result, "sourceCoverage"), selectedRecovery, "coverage persistence follows the accepted workflow version"); if (!rawSources.has(worker.resultManifestPath)) { for (const name of await readdir(path.join(worker.artifactDir, "checkpoints"))) { const checkpoint = JSON.parse(await readFile(path.join(worker.artifactDir, "checkpoints", name), "utf8")); - assert.equal(Object.hasOwn(checkpoint, "sourceCoverage"), false, "v1 checkpoints remain readable by earlier binaries"); + assert.equal(Object.hasOwn(checkpoint, "sourceCoverage"), selectedRecovery, "checkpoint coverage follows the accepted workflow version"); } } } diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_coverage.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_coverage.mjs new file mode 100644 index 0000000000..d805d6f74e --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_coverage.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { materialRemediations, materialRemediationTests, publishCoverageFixture } from "./deep_scan_coverage_fixture.mjs"; + +for (const splitSeededReducers of [false, true]) { + test(`selected publication retains fixes and coverage after failure and a rejected checkpoint (reducer chain: ${splitSeededReducers})`, async () => { + const root = await mkdtemp(path.join(tmpdir(), "selected-coverage-publication-")); + try { + const fixture = path.join(root, "fixture"); + await mkdir(fixture, { mode: 0o700 }); + const { scanDir, terminal } = await publishCoverageFixture(fixture, "partial", { + resume: true, immutableInputs: true, materialFindings: true, discardMutableResults: true, + splitSeededReducers, selectedRecovery: true, + }); + assert.equal(terminal.workflowVersion, "deep-security-scan/v2"); + assert.match(terminal.finalizationInput.resultPath, /checkpoints/); + const report = await readFile(path.join(scanDir, "report.md"), "utf8"); + for (const fix of [...materialRemediations, ...materialRemediationTests]) { + assert.equal(report.split(fix).length - 1, 1); + } + const coverage = JSON.parse(await readFile(path.join(scanDir, "coverage.json"), "utf8")); + assert.equal(coverage.completeness, "partial"); + assert.deepEqual(coverage.reviews.map((review) => review.completeness), ["partial", "complete", "unknown"]); + assert.equal(new Set(coverage.deferred.map((item) => item.candidateId)).size, 2); + for (const item of coverage.deferred) assert.ok(report.includes(item.reason)); + for (const surface of coverage.surfaces) { + assert.equal(await readFile(path.join(scanDir, surface.receiptRefs[0]), "utf8"), "Synthetic review evidence.\n"); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} From d93ca5a5e65f9903dfd3b56089b30029180442a9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:51:58 +0000 Subject: [PATCH 074/251] Recover missing original selections in existing execution snapshots --- plugins/codex-security/mcp-app/server.ts | 2 +- .../src/deep-scan/recovery-settings.ts | 31 ++++++++++++++++--- .../mcp-app/tests/test_deep_scan_executor.mjs | 15 ++++++--- .../test_deep_scan_recovery_settings.mjs | 20 ++++++++++++ 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 51e77e4f2b..93204e9da0 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -754,7 +754,7 @@ export function createCodexSecurityServer(): McpServer { prepareExecutor: async (run) => new CodexSdkWorkerExecutor({ ...restoredDeepScanWorkerSettings( await loadOrCaptureDeepScanExecutionSettings(run.scanDir, () => - captureDeepScanExecutionSettings(run, parentSandbox, process.env, { threadId, startedAt: run.createdAt })), + captureDeepScanExecutionSettings(run, parentSandbox, process.env, { threadId, startedAt: run.createdAt }), run), parentSandbox ), artifactContext: { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 884486a7f4..c14bacfcc1 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -102,21 +102,42 @@ async function originalParentSettings( /** Called by the acquired coordinator before it starts any worker. */ export async function loadOrCaptureDeepScanExecutionSettings( scanDir: string, - capture: () => Promise + capture: () => Promise, + original?: Pick ): Promise { const path = join(scanDir, "artifacts", "deep_discovery", "execution-settings.json"); + let settings: DeepScanExecutionSettings; try { const saved = JSON.parse(await fs.readFile(path, "utf8")); if (saved.version !== 1) { throw new Error("This Deep Scan uses an unsupported execution settings version."); } - return executionSettings(saved.settings); + settings = executionSettings(saved.settings); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + settings = executionSettings(await capture()); + await writeJsonAtomic(path, { version: 1, settings }); + return settings; } - const settings = executionSettings(await capture()); - await writeJsonAtomic(path, { version: 1, settings }); - return settings; + if (!original || (settings.model !== undefined && settings.reasoningEffort !== undefined + && settings.modelProvider !== undefined && settings.reasoningSummary !== undefined)) return settings; + // Earlier snapshots can omit native selections. Recover only from the saved + // home and recorded owner; the continuation's current config is not history. + const owner = original.usageOwner; + const native = !owner?.threadId ? {} : await originalParentSettings(settings.codexHome, { + ...owner, threadId: owner.threadId, startedAt: original.createdAt + }); + const recovered = executionSettings({ + ...settings, + model: settings.model ?? original.model ?? native.model, + reasoningEffort: settings.reasoningEffort ?? original.reasoningEffort ?? native.reasoningEffort, + modelProvider: settings.modelProvider ?? native.modelProvider, + reasoningSummary: settings.reasoningSummary ?? native.reasoningSummary + }); + if (JSON.stringify(recovered) !== JSON.stringify(settings)) { + await writeJsonAtomic(path, { version: 1, settings: recovered }); + } + return recovered; } export function restoredDeepScanWorkerSettings( diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 3f98f69740..c52af0f952 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -815,14 +815,21 @@ async function testIsolatedReconstructedWorkers() { }; syncBuiltinESMExports(); - for (const phase of ["fresh", "resume", "reconstructed"]) { - if (phase === "reconstructed") { + for (const phase of ["fresh", "resume", "reconstructed", "incomplete"]) { + if (phase === "reconstructed" || phase === "incomplete") { for (const scan of scans) { // The caller restores recorded selections. Its old config file need // not exist; current credentials still come from the selected home/env. - await rm(scan.configPath); + if (phase === "reconstructed") await rm(scan.configPath); + if (phase === "incomplete") { + const saved = JSON.parse(scan.snapshot); + for (const key of ["model", "reasoningEffort", "modelProvider", "reasoningSummary"]) delete saved.settings[key]; + await writeFile(scan.snapshotPath, JSON.stringify(saved)); + } const recorded = await loadOrCaptureDeepScanExecutionSettings(scan.fixture.root, () => - assert.fail("reconstruction must not recapture current settings")); + assert.fail("reconstruction must not recapture current settings"), { + ...scan.settings, createdAt: "2026-01-01T00:01:00Z" + }); const restored = restoredDeepScanWorkerSettings(recorded, scan.settings.parentSandbox, () => scan.runtimeEnvironment); restored.codexOptions.baseUrl = scan.settings.codexOptions.baseUrl; scan.executor = new CodexSdkWorkerExecutor(restored); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index f36d044335..7d2c0ecf81 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -127,6 +127,26 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(unboundLegacy.model, "stored-model"); assert.equal(unboundLegacy.modelProvider, undefined, "unrecorded legacy ownership cannot recover caller selections"); assert.equal(unboundLegacy.reasoningSummary, undefined); + const incompleteDir = join(root, "incomplete"); + const incomplete = { codexPath: process.execPath, codexHome: root, serviceTier: "flex" }; + await loadSettings(incompleteDir, async () => incomplete); + await writeFile(join(root, "config.toml"), 'model_provider = "observer-provider"\nmodel_reasoning_summary = "detailed"\n'); + const originalRun = { model: "stored-model", reasoningEffort: "ultra", usageOwner: originalOwner, + createdAt: "2026-01-01T00:01:00Z" }; + const repaired = await loadSettings(incompleteDir, async () => assert.fail("existing settings must not recapture current config"), originalRun); + assert.deepEqual(repaired, { ...incomplete, model: "stored-model", reasoningEffort: "ultra", + modelProvider: "openai", reasoningSummary: "none" }); + const repairedPath = join(incompleteDir, "artifacts", "deep_discovery", "execution-settings.json"); + const repairedBytes = await readFile(repairedPath, "utf8"); + await rm(sessionDirectory, { recursive: true }); + assert.deepEqual(await loadSettings(incompleteDir, async () => assert.fail(), originalRun), repaired); + assert.equal(await readFile(repairedPath, "utf8"), repairedBytes, "recovered selections survive unavailable history"); + const unknownDir = join(root, "unknown"); + await loadSettings(unknownDir, async () => incomplete); + const unknown = await loadSettings(unknownDir, async () => assert.fail(), { ...originalRun, usageOwner: null }); + assert.equal(unknown.model, "stored-model"); + assert.equal(unknown.modelProvider, undefined, "missing original ownership is not current config"); + assert.equal(unknown.reasoningSummary, undefined); const unsupported = JSON.stringify({ version: 99, settings }); await writeFile(savedPath, unsupported); await assert.rejects(loadSettings(join(root, "one"), async () => assert.fail()), /unsupported/); From 3a3bb349a0dab3517452adf4362a2a6334e41a89 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 05:43:59 +0000 Subject: [PATCH 075/251] Test selection commit loss before and after stopped replay --- ...est_finalization_selection_process_loss.py | 168 ++++++++++++++++++ .../test_workbench_standard_deep_results.py | 8 +- 2 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 plugins/codex-security/tests/test_finalization_selection_process_loss.py diff --git a/plugins/codex-security/tests/test_finalization_selection_process_loss.py b/plugins/codex-security/tests/test_finalization_selection_process_loss.py new file mode 100644 index 0000000000..c5ffafa619 --- /dev/null +++ b/plugins/codex-security/tests/test_finalization_selection_process_loss.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import io +import json +import sqlite3 +import subprocess +import sys +from argparse import Namespace +from pathlib import Path + +import pytest +from test_accepted_publication_references import accept_reducer +from test_checkpoint_publication_authority import save_disposition +from test_deep_scan_publication_authority import stage_publication +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_publication_stop_interleavings import published_bytes, stop_scan + +_CRASH_SELECTION = """ +import io, json, os, runpy, sqlite3, sys +from argparse import Namespace + +api = runpy.run_path(sys.argv[1], run_name="selection_commit_crash_test") +deep = api["deep_scan"] +deep.configure(deep.DeepScanDependencies(**{ + name: api["preserve_stopped_results_after_transition" + if name == "preserve_stopped_results" else name] + for name in deep.DeepScanDependencies.__dataclass_fields__ +})) +class CrashConnection(sqlite3.Connection): + def commit(self): + if sys.argv[4] == "before": + os._exit(72) + super().commit() + os._exit(73) +connection = sqlite3.connect(sys.argv[2], factory=CrashConnection) +connection.row_factory = sqlite3.Row +connection.execute("PRAGMA foreign_keys = ON") +sys.stdin = io.StringIO(json.dumps({"resultPath": sys.argv[5]})) +deep.finish_deep_scan(connection, Namespace(**json.loads(sys.argv[3])), select_finalization=True) +raise AssertionError("selection never reached its commit boundary") +""" + + +@pytest.mark.parametrize("reason", ["saturated", "capped"]) +@pytest.mark.parametrize("cut", ["before", "after"]) +@pytest.mark.parametrize("cause", ["cancel", "cost"]) +@pytest.mark.parametrize("stop_before_replay", [False, True]) +def test_selection_commit_loss_replays_accepted_identity_before_stopping( + workbench_api, + workbench_db, + publication_scan, + tmp_path, + monkeypatch, + reason, + cut, + cause, + stop_before_replay, +): + scan = publication_scan() + result, accepted, _ = accept_reducer(workbench_db, scan) + omissions = [] + if reason == "saturated": + omitted = add_worker(workbench_db, scan) + omitted.write_text(json.dumps(save_disposition(scan, omitted.parent, "reported"))) + save_disposition(scan, omitted.parent, "rejected") + omissions.append(omitted.parent.name) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_workers SET merge_state = 'buffered' WHERE id = ?", + (omitted.parent.name,), + ) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "status = 'running', phase = 'reducing', terminal_reason = NULL, completed_at = NULL, " + "consecutive_no_new = stop_after_no_new, discovery_runs_dispatched = max_discovery_runs " + "WHERE scan_id = ?", + (scan.scan_id,), + ) + args = Namespace( + scan_id=scan.scan_id, + coordinator_generation=3, + terminal_reason=reason, + manifest_path=str(scan.scan_dir / "scan-manifest.json"), + staged_manifest_path=None, + omitted_worker_id=omissions, + ) + database = tmp_path / "selection.sqlite3" + with sqlite3.connect(database) as connection: + workbench_db.backup(connection) + before = published_bytes(scan) + child = subprocess.run( + [ + sys.executable, + "-c", + _CRASH_SELECTION, + str(Path(__file__).resolve().parents[1] / "scripts" / "workbench_db.py"), + str(database), + json.dumps(vars(args)), + cut, + str(result), + ], + capture_output=True, + text=True, + ) + assert child.returncode == (72 if cut == "before" else 73), (child.stdout, child.stderr) + assert published_bytes(scan) == before + with sqlite3.connect(database) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + run = connection.execute("SELECT * FROM deep_scan_runs").fetchone() + selected = json.loads(run["finalization_input_json"]) if cut == "after" else None + if cut == "before": + assert run["finalization_input_json"] is None + assert run["terminal_reason"] is None + # The request names the deleted output. Selection resolves its committed + # accepted attempt rather than reading that replaceable file again. + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({"resultPath": str(result)}))) + if stop_before_replay: + stop_scan(workbench_api, connection, scan, cause) + stopped = published_bytes(scan) + stopped_database = "\n".join(connection.iterdump()) + with pytest.raises(SystemExit, match="running|stopped|failed|canceled"): + workbench_api["deep_scan"].finish_deep_scan( + connection, args, select_finalization=True + ) + assert "\n".join(connection.iterdump()) == stopped_database + assert published_bytes(scan) == stopped + run = connection.execute("SELECT * FROM deep_scan_runs").fetchone() + assert run["status"] == ("canceled" if cause == "cancel" else "failed") + assert run["terminal_reason"] == (reason if cut == "after" else None) + assert ( + json.loads(run["finalization_input_json"]) if cut == "after" else None + ) == selected + assert accepted.read_bytes() == before[accepted.relative_to(scan.scan_dir).as_posix()] + return + replayed = workbench_api["deep_scan"].finish_deep_scan( + connection, args, select_finalization=True + )["deepScan"] + selection = replayed["finalizationInput"] + if selected is not None: + assert selection == selected + assert selection["resultPath"] == accepted.relative_to(scan.scan_dir).as_posix() + assert selection["resultSha256"] == accepted.stem + assert selection["terminalReason"] == reason + assert selection["omittedWorkerIds"] == omissions + assert replayed["terminalReason"] == reason + assert replayed["status"] == "running" + assert published_bytes(scan) == before + stale = stage_publication(scan, generation=2, result_path=accepted, title="Stale aggregate") + with pytest.raises(SystemExit, match="generation"): + workbench_api["write_scan_draft"](connection, stale) + assert published_bytes(scan) == before + stop_scan(workbench_api, connection, scan, cause) + stopped = published_bytes(scan) + late = stage_publication(scan, generation=3, result_path=accepted, title="Late aggregate") + with pytest.raises(SystemExit, match="stopped"): + workbench_api["write_scan_draft"](connection, late) + assert published_bytes(scan) == stopped + run = connection.execute("SELECT * FROM deep_scan_runs").fetchone() + assert run["status"] == ("canceled" if cause == "cancel" else "failed") + assert run["terminal_reason"] == reason + assert json.loads(run["finalization_input_json"]) == selection + assert accepted.read_bytes() == before[selection["resultPath"]] + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert len(findings) == 1 + assert findings[0].get("extensions", {}).get("candidateId") != "candidate-disposition" diff --git a/plugins/codex-security/tests/test_workbench_standard_deep_results.py b/plugins/codex-security/tests/test_workbench_standard_deep_results.py index 144cf2a33c..a88e1680b3 100644 --- a/plugins/codex-security/tests/test_workbench_standard_deep_results.py +++ b/plugins/codex-security/tests/test_workbench_standard_deep_results.py @@ -47,8 +47,10 @@ def test_stopped_deep_scan_ignores_late_worker_checkpoints_without_reducer( # The latest incomplete attempt need not be parseable for a saved checkpoint to survive. result_path.write_text("{incomplete") with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + # This is new incomplete work, not a rewrite of the accepted attempt. connection.execute( - "UPDATE deep_scan_workers SET status = 'running' WHERE id = ?", (worker_id,) + "UPDATE deep_scan_workers SET status = 'running', attempt = 2 WHERE id = ?", + (worker_id,), ) environment = {"CODEX_HOME": str(codex_home)} if termination == "canceled": @@ -1401,6 +1403,7 @@ def test_failure_preserves_last_committed_reducer_without_parent_draft(tmp_path: state_dir, codex_home, scan_dir, scan_id, worker_id, result_path ) reduced = json.loads(reducer_path.read_text()) + accepted_summary = reduced["findings"][0]["summary"] reduced["findings"][0]["summary"] = ( "The reducer retained additional independently reviewed evidence." ) @@ -1417,7 +1420,8 @@ def test_failure_preserves_last_committed_reducer_without_parent_draft(tmp_path: failed = run_workbench(state_dir, "get-scan", "--scan-id", scan_id)["scan"] assert failed["progress"]["status"] == "failed" assert failed["findingCount"] == 1 - assert failed["findings"][0]["summary"] == reduced["findings"][0]["summary"] + assert failed["findings"][0]["summary"] == accepted_summary + assert json.loads(reducer_path.read_text()) == reduced def test_stopped_rejection_recovers_malformed_parent_surfaces(tmp_path: Path) -> None: From 1e7ecbd7f69244ac03a37c4cde2867308633f2a8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:07:34 +0000 Subject: [PATCH 076/251] Recover original selections from native applied settings records --- .../src/deep-scan/recovery-settings.ts | 26 ++++++++++++--- .../mcp-app/tests/test_deep_scan_executor.mjs | 14 +++++--- .../test_deep_scan_recovery_settings.mjs | 33 +++++++++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index c14bacfcc1..38b9675700 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -56,7 +56,7 @@ export async function captureDeepScanExecutionSettings( reasoningEffort: original.reasoningEffort ?? (selected.model_reasoning_effort as string | undefined) ?? native.reasoningEffort, modelProvider: (selected.model_provider as string | undefined) ?? native.modelProvider, reasoningSummary: (selected.model_reasoning_summary as string | undefined) ?? native.reasoningSummary, - serviceTier: selected.service_tier as string | undefined, + serviceTier: (selected.service_tier as string | undefined) ?? native.serviceTier, providerConfig: selected.model_providers as JsonObject | undefined, parentSandbox }); @@ -75,6 +75,7 @@ async function originalParentSettings( codexHome, allowMissingRoot: true }); const settings: Partial = {}; + let applied: Partial | undefined; const cutoff = parent.startedAt === undefined ? Infinity : Date.parse(parent.startedAt); for (const entry of log.events) { const event = entry.event as Record; @@ -83,6 +84,19 @@ async function originalParentSettings( const payload = event.payload; if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue; const context = payload as Record; + if (event.type === "event_msg" && context.type === "thread_settings_applied") { + if (typeof context.thread_id === "string" && context.thread_id !== parent.threadId) continue; + const snapshot = context.thread_settings; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) continue; + const value = snapshot as Record; + applied = { + model: typeof value.model === "string" ? value.model : undefined, + modelProvider: typeof value.model_provider_id === "string" ? value.model_provider_id : undefined, + reasoningEffort: typeof value.reasoning_effort === "string" ? value.reasoning_effort : undefined, + reasoningSummary: typeof value.reasoning_summary === "string" ? value.reasoning_summary : undefined, + serviceTier: typeof value.service_tier === "string" ? value.service_tier : undefined + }; + } if (event.type === "session_meta" && typeof context.model_provider === "string") { settings.modelProvider = context.model_provider; } @@ -93,7 +107,9 @@ async function originalParentSettings( if (typeof context.summary === "string") settings.reasoningSummary = context.summary; } } - return settings; + // Applied snapshots contain native selected values. Newer turn-context + // summaries are only a compatibility field, not the active selection. + return { ...settings, ...applied }; } catch { return {}; } @@ -120,7 +136,8 @@ export async function loadOrCaptureDeepScanExecutionSettings( return settings; } if (!original || (settings.model !== undefined && settings.reasoningEffort !== undefined - && settings.modelProvider !== undefined && settings.reasoningSummary !== undefined)) return settings; + && settings.modelProvider !== undefined && settings.reasoningSummary !== undefined + && settings.serviceTier !== undefined)) return settings; // Earlier snapshots can omit native selections. Recover only from the saved // home and recorded owner; the continuation's current config is not history. const owner = original.usageOwner; @@ -132,7 +149,8 @@ export async function loadOrCaptureDeepScanExecutionSettings( model: settings.model ?? original.model ?? native.model, reasoningEffort: settings.reasoningEffort ?? original.reasoningEffort ?? native.reasoningEffort, modelProvider: settings.modelProvider ?? native.modelProvider, - reasoningSummary: settings.reasoningSummary ?? native.reasoningSummary + reasoningSummary: settings.reasoningSummary ?? native.reasoningSummary, + serviceTier: settings.serviceTier ?? native.serviceTier }); if (JSON.stringify(recovered) !== JSON.stringify(settings)) { await writeJsonAtomic(path, { version: 1, settings: recovered }); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index c52af0f952..a38eb10eec 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -756,11 +756,11 @@ async function testIsolatedReconstructedWorkers() { model: `fixture-${name}-inherited`, model_provider: `fixture-${name}-provider`, model_reasoning_effort: "medium", - model_reasoning_summary: name === "first" ? "none" : "concise", - service_tier: name === "first" ? "flex" : "fast" + model_reasoning_summary: "concise", + service_tier: name === "first" ? "default" : "fast" }; await writeFile(configPath, Object.entries(config).filter(([key]) => name !== "first" - || !["model_provider", "model_reasoning_summary"].includes(key)) + || !["model_provider", "model_reasoning_summary", "service_tier"].includes(key)) .map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); @@ -789,8 +789,13 @@ async function testIsolatedReconstructedWorkers() { await writeFile(path.join(codexHome, "sessions", "owner.jsonl"), [ { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: `fixture-${name}-owner`, model_provider: config.model_provider } }, + { type: "event_msg", timestamp: "2026-01-01T00:00:00Z", + payload: { type: "thread_settings_applied", thread_id: `fixture-${name}-owner`, thread_settings: { + model: "native-parent-model", model_provider_id: config.model_provider, service_tier: "default", + reasoning_effort: "medium", reasoning_summary: config.model_reasoning_summary + } } }, { type: "turn_context", timestamp: "2026-01-01T00:00:01Z", - payload: { turn_id: "original-turn", model: "native-parent-model", effort: "medium", summary: config.model_reasoning_summary } }, + payload: { turn_id: "original-turn", model: "native-parent-model", effort: "medium", summary: "none" } }, { type: "turn_context", timestamp: "2026-01-01T00:02:00Z", payload: { turn_id: "later-turn", model: "later-parent-model", effort: "low", summary: "detailed" } } ].map(JSON.stringify).join("\n") + "\n"); @@ -824,6 +829,7 @@ async function testIsolatedReconstructedWorkers() { if (phase === "incomplete") { const saved = JSON.parse(scan.snapshot); for (const key of ["model", "reasoningEffort", "modelProvider", "reasoningSummary"]) delete saved.settings[key]; + if (scan.name === "first") delete saved.settings.serviceTier; await writeFile(scan.snapshotPath, JSON.stringify(saved)); } const recorded = await loadOrCaptureDeepScanExecutionSettings(scan.fixture.root, () => diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 7d2c0ecf81..419291d430 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -127,6 +127,39 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(unboundLegacy.model, "stored-model"); assert.equal(unboundLegacy.modelProvider, undefined, "unrecorded legacy ownership cannot recover caller selections"); assert.equal(unboundLegacy.reasoningSummary, undefined); + await writeFile(join(sessionDirectory, "applied.jsonl"), [ + { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: "fixture-applied", model_provider: "openai" } }, + { type: "event_msg", timestamp: "2026-01-01T00:00:01Z", payload: { type: "thread_settings_applied", thread_id: "fixture-applied", + thread_settings: { model: "applied-model", model_provider_id: "openai", service_tier: "default", reasoning_effort: "high", reasoning_summary: "concise" } } }, + { type: "turn_context", timestamp: "2026-01-01T00:00:02Z", payload: { turn_id: "applied-turn", model: "applied-model", effort: "high", summary: "none" } }, + { type: "event_msg", timestamp: "2026-01-01T00:00:03Z", payload: { type: "thread_settings_applied", thread_id: "fixture-copied-owner", + thread_settings: { model: "copied-model", model_provider_id: "copied-provider", service_tier: "flex", reasoning_summary: "detailed" } } }, + { type: "event_msg", timestamp: "2026-01-01T00:02:00Z", payload: { type: "thread_settings_applied", thread_id: "fixture-applied", + thread_settings: { model: "later-model", model_provider_id: "later-provider", service_tier: "fast", reasoning_summary: "detailed" } } } + ].map(JSON.stringify).join("\n") + "\n"); + const appliedOwner = { threadId: "fixture-applied", turnId: "applied-turn", startedAt: "2026-01-01T00:00:00Z" }; + const applied = await captureSettings({ usageOwner: appliedOwner }, { filesystemDenies: [] }, parentEnvironment, + { threadId: "fixture-other", startedAt: "2026-01-01T00:01:00Z" }); + assert.equal(applied.serviceTier, "default", "original explicit standard routing survives later and copied snapshots"); + assert.equal(applied.reasoningSummary, "concise", "native applied summary overrides the legacy compatibility field"); + assert.equal(applied.modelProvider, "openai"); + const tierDir = join(root, "missing-tier"); + const { serviceTier: omittedTier, ...withoutTier } = applied; + assert.equal(omittedTier, "default"); + await loadSettings(tierDir, async () => withoutTier); + const repairedTier = await loadSettings(tierDir, async () => assert.fail(), { + usageOwner: appliedOwner, createdAt: "2026-01-01T00:01:00Z" + }); + assert.equal(repairedTier.serviceTier, "default"); + await writeFile(join(sessionDirectory, "applied.jsonl"), (await readFile(join(sessionDirectory, "applied.jsonl"), "utf8")) + + JSON.stringify({ type: "event_msg", timestamp: "2026-01-01T00:00:04Z", payload: { + type: "thread_settings_applied", thread_id: "fixture-applied", + thread_settings: { model: "applied-model", model_provider_id: "openai", reasoning_effort: "high" } + } }) + "\n"); + const nativeDefaults = await captureSettings({ usageOwner: appliedOwner }, { filesystemDenies: [] }, parentEnvironment, + { threadId: "fixture-other", startedAt: "2026-01-01T00:01:00Z" }); + assert.equal(nativeDefaults.serviceTier, undefined, "native model-default selection is not explicit standard routing"); + assert.equal(nativeDefaults.reasoningSummary, undefined, "a compatibility summary is not a recorded native default"); const incompleteDir = join(root, "incomplete"); const incomplete = { codexPath: process.execPath, codexHome: root, serviceTier: "flex" }; await loadSettings(incompleteDir, async () => incomplete); From 1b41a967c7b7c201de8702471567a751112cb57f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:04:28 +0000 Subject: [PATCH 077/251] fix(qa): inspect compressed package assets as decoded text --- sdk/typescript/scripts/check-package.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index a751d59deb..6c9209972d 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -331,7 +331,17 @@ assertExpectedGitHead( const internalMarker = /(?:internal\.api\.openai\.org|gateway\.[a-z0-9.-]*internal|\.openai\.org|openai\.firewall\.socket\.dev|socket\x2dfirewall\x2dregistry|openai\.(?:enterprise\.)?slack\.com|app\.slack\.com\/client|(?:app\.notion\.com\/p|notion\.so)\/openai|linear\.app\/openai|(?:github\.com[:/]|api\.github\.com\/repos\/|raw\.githubusercontent\.com\/)openai\/openai(?:\.git)?(?:[^a-z0-9_-]|$)|LicenseRef\x2dProprietary|\/Users\/|\/home\/dev-user|flow\.apps\.openai\.org|(?:^|[^a-z0-9_-])go\/[a-z0-9_-]+)/iu; -const payloads = [archiveBytes.toString("utf8")]; +// Scan compressed assets after decoding them below. Their binary bytes can +// coincidentally match text references; keep scanning every tar header and all +// other entry contents. +const readableArchive = Buffer.from(archiveBytes); +for (const file of files) { + if (!/\.br(?:\.part-[0-9]+)?$/iu.test(file)) continue; + const bytes = archiveFile(file); + const start = bytes.byteOffset - archiveBytes.byteOffset; + readableArchive.fill(0, start, start + bytes.byteLength); +} +const payloads = [readableArchive.toString("utf8")]; const compressedFiles = [...files].filter((file) => /\.br$/iu.test(file)); const compressedParts = new Map(); for (const file of files) { From 440af3908653f7294a885ee1400e11e9c29d6e12 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:20:50 +0000 Subject: [PATCH 078/251] Enable persisted Deep finalization for new scans --- .../mcp-app/src/deep-scan/store.ts | 2 +- .../scripts/deep_scan_workbench.py | 4 +-- ...st_deep_scan_finalization_compatibility.py | 32 +++++++++++++++++++ sdk/typescript/README.md | 6 ++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index 357e910ed1..ba411a9b57 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -32,7 +32,7 @@ export type WorkbenchRunner = ( selectFinalization?: boolean, ) => Promise; -const WORKFLOW_VERSION = "deep-scan-mcp/v1"; +const WORKFLOW_VERSION = "deep-security-scan/v2"; const MAX_IDEMPOTENT_PERSISTENCE_ATTEMPTS = 3; const PERSISTENCE_RETRY_BASE_DELAY_MS = 100; diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 306057099f..7cd2a7e731 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -39,11 +39,11 @@ "invalid_discovery_artifacts", ) DEEP_SCAN_TERMINAL_REASONS = ("saturated", "capped") -DEEP_SCAN_WORKFLOW_VERSION = "deep-security-scan/v1" +DEEP_SCAN_WORKFLOW_VERSION = "deep-security-scan/v2" SUPPORTED_DEEP_SCAN_WORKFLOWS = { DEEP_SCAN_WORKFLOW_VERSION, "deep-scan-mcp/v1", - "deep-security-scan/v2", + "deep-security-scan/v1", } DEEP_SCAN_COORDINATOR_LEASE_SECONDS = 30 DEEP_SCAN_LEGACY_COORDINATOR_GRACE_SECONDS = 120 diff --git a/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py b/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py index 2c409463e8..6e1cd3a026 100644 --- a/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py @@ -10,6 +10,38 @@ from workbench_test_support import run_workbench +@pytest.mark.parametrize("legacy_version", [None, "deep-security-scan/v1", "deep-scan-mcp/v1"]) +def test_new_workflow_default_preserves_existing_run_version( + tmp_path: Path, legacy_version: str | None +) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + created = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + *(["--workflow-version", legacy_version] if legacy_version else []), + )["deepScan"] + expected_version = legacy_version or "deep-security-scan/v2" + assert created["workflowVersion"] == expected_version + resumed = run_workbench( + state, + "begin-deep-scan", + "--scan-id", + created["scanId"], + "--thread-id", + "fixture-thread", + )["deepScan"] + assert resumed["workflowVersion"] == expected_version + assert resumed["createdAt"] == created["createdAt"] + + def selected_scan(tmp_path: Path, version: int) -> tuple[Path, str, dict[str, object]]: target = tmp_path / "target" target.mkdir() diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 98d72cbd07..67ee33cfd5 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -799,6 +799,12 @@ four workers. Unknown keys are rejected. `max_time_hours` accepts positive values up to 96, including fractional hours. At the deadline, discovery stops; the scan combines and returns completed findings. +New Deep scans save the accepted aggregate before publishing the result. If +publication is interrupted, recovering the same scan reuses that aggregate and +its original stop reason without another discovery or reducer run. Explicit +cancellation and cost stops retain their stopped or partial-result behavior. +Scans created by earlier versions keep their original workflow when resumed. + `scan --workers` controls discovery workers within one deep scan; `bulk-scan --workers` controls how many repositories are scanned concurrently. From a7a20c4e6fff8401f4de4e1aa8bcb2db7bf342b4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:30:20 +0000 Subject: [PATCH 079/251] Align managed audit admission with persisted worker semantics --- .../codex-security/mcp-app/helpers-main.ts | 1 + .../mcp-app/src/artifact-scan-draft.ts | 104 +++-- sdk/typescript/src/accepted-audit.ts | 30 +- sdk/typescript/src/api.ts | 26 +- .../tests-ts/api-audit-admission.test.ts | 395 ++++++++++++++++++ 5 files changed, 495 insertions(+), 61 deletions(-) create mode 100644 sdk/typescript/tests-ts/api-audit-admission.test.ts diff --git a/plugins/codex-security/mcp-app/helpers-main.ts b/plugins/codex-security/mcp-app/helpers-main.ts index db7c7e9636..9793f9d43d 100644 --- a/plugins/codex-security/mcp-app/helpers-main.ts +++ b/plugins/codex-security/mcp-app/helpers-main.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +export { parseCanonicalScanDraft } from "./src/artifact-scan-draft.js"; export { resumeSelectedDeepScan } from "./src/deep-scan/finalization.js"; import { resolveSecurityMdCommand } from "./src/helpers/resolve-security-md"; import { decodePosixBytes } from "./src/helpers/posix-path"; diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index 125a02ce9e..651ff1f108 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -4,6 +4,7 @@ import { dirname, join, sep } from "node:path"; import type * as z from "zod/v4"; import commonSchema from "../../schemas/definitions/artifact-common.schema.json"; import scanDraftDocument from "../../schemas/tools/scan-draft.schema.json"; +import scanManifestDocument from "../../schemas/scan-manifest.schema.json"; import type { ArtifactContext } from "./artifact-context.js"; import type { RunArtifactWorkbench } from "./artifact-context.js"; import { @@ -17,17 +18,10 @@ import { type SchemaDocument, } from "./artifact-schema-loader.js"; -type JsonObject = Record; +import type { ScanDraftInput } from "../../../../sdk/typescript/src/accepted-audit.js"; +export type { ScanDraftInput } from "../../../../sdk/typescript/src/accepted-audit.js"; -export interface ScanDraftInput { - scanId: string; - complete?: boolean; - handoffClaimToken?: string; - scope?: JsonObject; - threatModel?: JsonObject; - findings: JsonObject[]; - coverage: JsonObject; -} +type JsonObject = Record; export interface CompletedScanInput { scanId: string; @@ -75,6 +69,19 @@ export const scanDraftInputSchema = loadArtifactZodSchema( "scanDraftInput", ) as z.ZodType; +// Sealed documents retain the existing public ID contract; live drafts require UUIDs. +const canonicalScanDraftInputSchema = loadArtifactZodSchema( + [commonSchema, { + ...scanDraftDocument, + $defs: { + ...scanDraftDocument.$defs, + scanId: scanManifestDocument.properties.scan.properties.id, + }, + }] as SchemaDocument[], + scanDraftDocument.$id, + "scanDraftInput", +) as z.ZodType; + export const completedScanInputSchema = loadArtifactZodSchema( schemaDocuments, scanDraftDocument.$id, @@ -558,32 +565,9 @@ async function readPreviousScanDraft( const manifest = parseJsonObject(contents[0]!, "previous scan draft manifest"); const findings = parseJsonObject(contents[1]!, "previous scan draft findings"); const coverage = parseJsonObject(contents[2]!, "previous scan draft coverage"); - const scan = requireObject(manifest.scan, "previous scan draft.scan"); - const semanticScope = isObject(scan.scope) ? { ...scan.scope } : undefined; - if (semanticScope) { - delete semanticScope.includePaths; - delete semanticScope.excludePaths; - } - const semanticCoverage = { ...coverage }; - for (const field of ["documentType", "schemaVersion", "scanId", "mode", "includePaths", "excludePaths", "receiptRefs", "inventoryStrategy"]) delete semanticCoverage[field]; return { digest, - input: parsePersistedScanDraft({ - scanId: context.scanId, - ...(scan.complete === false ? { complete: false } : {}), - ...(semanticScope && Object.keys(semanticScope).length > 0 - ? { scope: semanticScope } - : {}), - ...(isObject(scan.threatModel) - ? { threatModel: structuredClone(scan.threatModel) } - : {}), - findings: (findings.findings as JsonObject[]).map((finding) => { - const semantic = { ...finding }; - for (const field of ["findingId", "occurrenceId", "fingerprints"]) delete semantic[field]; - return semantic; - }), - coverage: semanticCoverage, - }), + input: parseCanonicalScanDraft({ scanId: context.scanId, manifest, findings, coverage }), }; } @@ -983,8 +967,35 @@ export async function getCodexSecurityCompletedScan( return { scanId: parsed.scanId, manifest, findings, coverage }; } +/** Project canonical documents through the same semantic parser as worker drafts. */ +export function parseCanonicalScanDraft(input: { + scanId?: string; + manifest: JsonObject; + findings: JsonObject; + coverage: JsonObject; +}): ScanDraftInput { + const scan = requireObject(input.manifest.scan, "scan draft manifest.scan"); + for (const scanId of [scan.id, input.findings.scanId, input.coverage.scanId]) { + if (scanId !== undefined && scanId !== input.scanId) { + throw new Error("scan draft: canonical documents belong to a different scan."); + } + } + return parsePersistedCheckpoint({ + scanId: input.scanId, + ...(scan.complete === undefined ? {} : { complete: scan.complete }), + ...(scan.scope === undefined ? {} : { scope: scan.scope }), + ...(scan.threatModel === undefined ? {} : { threatModel: scan.threatModel }), + findings: input.findings.findings, + coverage: input.coverage, + }, canonicalScanDraftInputSchema); +} + export function parseScanDraft(input: ScanDraftInput): ScanDraftInput { - const parsed = scanDraftInputSchema.parse(input); + return parseSemanticScanDraft(input, scanDraftInputSchema); +} + +function parseSemanticScanDraft(input: unknown, schema: z.ZodType): ScanDraftInput { + const parsed = schema.parse(input); validateFindingSemantics(parsed.findings); validateCoverageSemantics(parsed.coverage); return parsed; @@ -994,18 +1005,23 @@ export function parseScanDraft(input: ScanDraftInput): ScanDraftInput { export function parsePersistedScanDraft( input: Record ): ScanDraftInput { + return parsePersistedDraft(input, scanDraftInputSchema); +} + +function parsePersistedDraft(input: Record, schema: z.ZodType): ScanDraftInput { const compatible = structuredClone(input); - if (!Array.isArray(compatible.findings)) { - return parseScanDraft(compatible as unknown as ScanDraftInput); - } - for (const finding of compatible.findings) { - if (!isObject(finding)) continue; - normalizePersistedFindingDetails(finding); + if (Array.isArray(compatible.findings)) { + for (const finding of compatible.findings) { + if (isObject(finding)) normalizePersistedFindingDetails(finding); + } } - return parseScanDraft(compatible as unknown as ScanDraftInput); + return parseSemanticScanDraft(compatible, schema); } -function parsePersistedCheckpoint(input: Record): ScanDraftInput { +function parsePersistedCheckpoint( + input: Record, + schema = scanDraftInputSchema, +): ScanDraftInput { const compatible = structuredClone(input); if (isObject(compatible.scope)) { delete compatible.scope.includePaths; @@ -1032,7 +1048,7 @@ function parsePersistedCheckpoint(input: Record): ScanDraftInpu delete finding.fingerprints; } } - return parsePersistedScanDraft(compatible); + return parsePersistedDraft(compatible, schema); } function normalizePersistedFindingDetails(finding: JsonObject): void { diff --git a/sdk/typescript/src/accepted-audit.ts b/sdk/typescript/src/accepted-audit.ts index a5a679beaa..ee526a955f 100644 --- a/sdk/typescript/src/accepted-audit.ts +++ b/sdk/typescript/src/accepted-audit.ts @@ -1,21 +1,31 @@ +export interface ScanDraftInput { + scanId: string; + complete?: boolean; + handoffClaimToken?: string; + scope?: Record; + threatModel?: Record; + findings: Record[]; + coverage: Record; +} + /** Accepted evidence may still describe partial or unknown source coverage. */ -export interface AuditEvidence { - checkpoint?: Result; - accepted?: Result; +export interface AuditEvidence { + checkpoint?: ScanDraftInput; + accepted?: ScanDraftInput; } -export type AuditOutcome = AuditEvidence & +export type AuditOutcome = AuditEvidence & ( - | { status: "accepted"; execution: Execution; accepted: Result } + | { status: "accepted"; execution: Execution; accepted: ScanDraftInput } | { status: "checkpoint"; execution: Execution } ); /** One attempt; enclosing callers own retries and public completion. */ -export async function runAcceptedAudit(input: { +export async function runAcceptedAudit(input: { signal: AbortSignal; execute: () => Promise; - accept: (execution: Execution) => Promise>; -}): Promise> { + accept: (execution: Execution) => Promise; +}): Promise> { input.signal.throwIfAborted(); const execution = await input.execute(); input.signal.throwIfAborted(); @@ -32,9 +42,7 @@ export async function runAcceptedAudit(input: { } /** Process completion alone does not accept an unfinished audit checkpoint. */ -export function auditEvidence( - checkpoint: Result, -): AuditEvidence { +export function auditEvidence(checkpoint: ScanDraftInput): AuditEvidence { return checkpoint.complete === false ? { checkpoint } : { checkpoint, accepted: checkpoint }; diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 11fe28da6b..828457aa74 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -3,7 +3,12 @@ import { scanPreflightCodexConfig } from "./preflight-config.js"; export { scanPreflightCodexConfig } from "./preflight-config.js"; import { resumeSelectedDeepScan } from "./deep-scan-finalization.js"; -import { auditEvidence, runAcceptedAudit } from "./accepted-audit.js"; +import { + auditEvidence, + runAcceptedAudit, + type ScanDraftInput, +} from "./accepted-audit.js"; +import { pathToFileURL } from "node:url"; import { statSync } from "node:fs"; import { chmod, @@ -1979,6 +1984,7 @@ export class CodexSecurity { events, signal, scanDir, + scanId, pluginRoot: runtime.plugin.installedRoot, expectation, authentication, @@ -2213,6 +2219,7 @@ export class CodexSecurity { events: (await followUp()).events, signal, scanDir, + scanId, pluginRoot: runtime.plugin.installedRoot, expectation, model, @@ -3698,6 +3705,7 @@ async function removeTargetPathsFile(path: string | null): Promise { } interface ScanEventRunOptions { + scanId?: string; savedCompletion?: Awaited>; recoverCompletion?: () => Promise @@ -3842,8 +3850,7 @@ export async function runScanEvents( return (completedTurn = { ...turn, threadId, status }); }; const accept = async () => { - // The plugin's existing writer accepts these semantic documents. Matching, - // custom validation and the canonical seal remain in the enclosing owner. + // Matching, custom validation and the canonical seal remain with the caller. const [manifest, findings, coverage] = await Promise.all( ["scan-manifest.json", "findings.json", "coverage.json"].map( async (name) => @@ -3854,11 +3861,18 @@ export async function runScanEvents( ), ), ); - return auditEvidence({ - complete: manifest.scan.complete, - findings: findings.findings, + const helper = ( + await import( + pathToFileURL(join(options.pluginRoot, "mcp/helpers.mjs")).href + ) + ).default; + const draft: ScanDraftInput = helper.parseCanonicalScanDraft({ + scanId: options.scanId ?? manifest.scan.id, + manifest, + findings, coverage, }); + return auditEvidence(draft); }; let audit; try { diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts new file mode 100644 index 0000000000..0bfc8c2695 --- /dev/null +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -0,0 +1,395 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, expect, test } from "bun:test"; +import { build } from "esbuild"; +import { runScanEvents } from "../src/api.js"; +import type { ScanDraftInput } from "../src/accepted-audit.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; +import { + completedEvents, + createApiTestFixtures, +} from "./support/api-events.js"; + +const { temporaryDirectory, copyCompletedScan, cleanup } = + createApiTestFixtures(); +afterEach(cleanup); + +const bundle = await build({ + stdin: { + resolveDir: fileURLToPath( + new URL("../../../plugins/codex-security/mcp-app/", import.meta.url), + ), + contents: `export * from "./src/artifact-scan-draft.ts"; + export * from "./src/deep-scan/artifacts.ts"; + export * from "./src/deep-scan/artifact-validation.ts"; + export * from "./src/deep-scan/worker-runner.ts";`, + }, + bundle: true, + format: "esm", + platform: "node", + loader: { ".md": "text" }, + write: false, +}); +const bundlePath = join(await temporaryDirectory(), "deep-admission.mjs"); +await writeFile(bundlePath, bundle.outputFiles[0]!.contents); +const { + createDeepScanArtifacts, + recordCodexSecurityScanDraft, + readDiscoveryAuditDraft, + DeepScanWorkerRunner, +} = await import(pathToFileURL(bundlePath).href); + +const scanId = "811aef98-3709-4c2d-8b7a-742977521865"; +type Mutation = + | "missing-findings" + | "contradictory-coverage" + | "inverted-lines" + | "wrong-scan" + | "legacy-details"; +const cases: { + name: string; + coverage: "complete" | "partial" | "unknown"; + complete?: boolean; + mutation?: Mutation; + accepted: boolean; +}[] = [ + { + name: "complete coverage", + coverage: "complete", + complete: true, + accepted: true, + }, + { + name: "partial coverage", + coverage: "partial", + complete: true, + accepted: true, + }, + { + name: "unknown coverage", + coverage: "unknown", + complete: true, + accepted: true, + }, + { name: "omitted completion marker", coverage: "partial", accepted: true }, + { + name: "persisted legacy details", + coverage: "partial", + mutation: "legacy-details", + accepted: true, + }, + { + name: "unfinished checkpoint", + coverage: "partial", + complete: false, + accepted: false, + }, + { + name: "missing findings", + coverage: "partial", + mutation: "missing-findings", + accepted: false, + }, + { + name: "complete coverage with deferred work", + coverage: "partial", + mutation: "contradictory-coverage", + accepted: false, + }, + { + name: "inverted finding lines", + coverage: "partial", + mutation: "inverted-lines", + accepted: false, + }, + { + name: "mismatched canonical scan ID", + coverage: "partial", + mutation: "wrong-scan", + accepted: false, + }, +]; + +for (const scenario of cases) { + test(`Standard and Deep production admission: ${scenario.name}`, async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const standardRoot = join(root, "standard"); + const deepRoot = join(root, "deep"); + await Promise.all([ + mkdir(repository), + mkdir(standardRoot, { mode: 0o700 }), + mkdir(deepRoot, { mode: 0o700 }), + ]); + const semantic: ScanDraftInput = { + scanId, + ...(scenario.complete === undefined + ? {} + : { complete: scenario.complete }), + scope: { summary: "Archive extraction." }, + threatModel: { summary: "An untrusted caller supplies archive entries." }, + findings: [ + { + ruleId: "path-traversal.archive", + title: "Unsafe archive extraction", + summary: "An archive entry reaches a filesystem write.", + severity: { level: "high" }, + confidence: { level: "high", rationale: "Source review." }, + taxonomy: { category: "path-traversal", cwe: ["CWE-22"] }, + locations: [{ path: "extract.py", startLine: 4, endLine: 7 }], + remediation: "Validate the resolved output path before writing.", + provenance: { source: "local_plugin", candidateId: "archive-entry" }, + }, + ], + coverage: { + completeness: scenario.coverage, + surfaces: [{ label: "Archive extraction", disposition: "reported" }], + explicitExclusions: [], + deferred: + scenario.coverage === "complete" + ? [] + : [ + { + id: "deployment", + reason: "Deployment controls remain unverified.", + }, + ], + }, + }; + await recordCodexSecurityScanDraft( + { + root: standardRoot, + repoRoot: repository, + layout: "scan", + scanId, + mode: "standard", + status: "running", + scope: ".", + targetContract: { + target: { + allowedKinds: ["directory_snapshot"], + targetId: "target_example", + displayName: "example", + requiredSnapshotDigest: `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`, + }, + scope: { requiredIncludePaths: ["."], requiredExcludePaths: [] }, + diffTarget: null, + }, + }, + semantic, + ); + const submitted = mutateDraft(semantic, scenario.mutation); + const findings = { + scanId: submitted.scanId, + findings: submitted.findings?.map((finding) => ({ + ...finding, + findingId: "finding_example", + occurrenceId: "occurrence_example", + fingerprints: { identity: "synthetic" }, + })), + }; + const coverage = JSON.parse( + await readFile(join(standardRoot, "coverage.json"), "utf8"), + ); + Object.assign(coverage, submitted.coverage); + await Promise.all([ + writeFile(join(standardRoot, "findings.json"), JSON.stringify(findings)), + writeFile(join(standardRoot, "coverage.json"), JSON.stringify(coverage)), + ]); + const standard = await observeStandardAdmission( + root, + repository, + standardRoot, + scanId, + ); + + const artifacts = createDeepScanArtifacts(deepRoot); + const acceptedPaths: string[] = []; + let executions = 0; + const runner = new DeepScanWorkerRunner({ + run: { + scanId, + scanDir: deepRoot, + targetPath: repository, + scope: ".", + config: { subagents: 0 }, + }, + artifacts, + pluginRoot: PLUGIN_ROOT, + signal: new AbortController().signal, + retryDelaysMs: [], + random: () => 0, + log: () => {}, + clock: { now: () => Date.now(), sleep: async () => {} }, + executor: { + async run(request: { + artifactContext: { root: string }; + onThreadStarted?: (id: string) => Promise; + }) { + executions++; + await request.onThreadStarted?.("deep-thread"); + await writeFile( + join(request.artifactContext.root, "result.json"), + JSON.stringify(submitted), + ); + return { threadId: "deep-thread" }; + }, + }, + store: { + async updateWorker(update: { + status: string; + resultManifestPath?: string; + }) { + if (update.status === "succeeded") { + acceptedPaths.push(update.resultManifestPath!); + return { ...update, completionSequence: 1 }; + } + return update; + }, + }, + }); + const deepResult = await runner.runDiscoveryWorker( + "worker-1", + "discovery-1", + ); + expect(executions).toBe(1); + if (scenario.accepted) { + expect(standard.error).toBe(standard.finalization); + expect(standard.finalizations).toBe(1); + expect(standard.drafts).toHaveLength(1); + expect(deepResult.status).toBe("succeeded"); + expect(acceptedPaths).toEqual([deepResult.worker.resultPath]); + const deepDraft: ScanDraftInput = await readDiscoveryAuditDraft( + artifacts, + deepResult.worker.resultPath, + scanId, + ); + expect(standard.drafts[0]!.findings).toEqual(deepDraft.findings); + expect(standard.drafts[0]!.coverage).toEqual(deepDraft.coverage); + expect(standard.drafts[0]!.scope).toEqual(deepDraft.scope); + expect(standard.drafts[0]!.threatModel).toEqual(deepDraft.threatModel); + if (scenario.mutation === "legacy-details") { + expect(deepDraft.findings[0]!["validation"]).toEqual({ + limitations: ["Legacy persisted limitation."], + }); + } + } else { + expect(standard.error).toBeInstanceOf(Error); + expect(standard.error).not.toBe(standard.finalization); + expect(standard.finalizations).toBe(0); + expect(deepResult.status).toBe("failed"); + expect(acceptedPaths).toHaveLength(0); + } + expect(standard.calls).toBe(1); + const manifest = JSON.parse( + await readFile(join(standardRoot, "scan-manifest.json"), "utf8"), + ); + expect(manifest.scan.sealedAt).toBeUndefined(); + await expect(readFile(join(standardRoot, "report.md"))).rejects.toThrow(); + }); +} + +test("Standard admission preserves existing canonical scan IDs", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + const scanDir = await copyCompletedScan(root); + const manifest = JSON.parse( + await readFile(join(scanDir, "scan-manifest.json"), "utf8"), + ); + const standard = await observeStandardAdmission(root, repository, scanDir); + expect(manifest.scan.id).toBe("scan_example_001"); + expect(standard.error).toBe(standard.finalization); + expect(standard.finalizations).toBe(1); + expect(standard.drafts).toHaveLength(1); + expect(standard.calls).toBe(1); + expect(standard.drafts[0]!.scanId).toBe(manifest.scan.id); +}); + +async function observeStandardAdmission( + root: string, + repository: string, + scanDir: string, + scanId?: string, +) { + const pluginRoot = join(root, "observed-plugin"); + const helperPath = join(pluginRoot, "mcp", "helpers.mjs"); + await mkdir(join(pluginRoot, "mcp"), { recursive: true }); + // The bundled exports are immutable getters. Observe the real parser through + // a local delegator instead of mocking its module or copying its semantics. + await writeFile( + helperPath, + `import helpers from ${JSON.stringify(pathToFileURL(join(PLUGIN_ROOT, "mcp", "helpers.mjs")).href)}; +export const drafts = []; +export let calls = 0; +export default { ...helpers, parseCanonicalScanDraft(input) { + calls++; + const draft = helpers.parseCanonicalScanDraft(input); + drafts.push(draft); + return draft; +} };`, + ); + const observed: { drafts: ScanDraftInput[]; calls: number } = await import( + pathToFileURL(helperPath).href + ); + const finalization = new Error("The enclosing finalizer owns the next step."); + let finalizations = 0; + const error = await runScanEvents({ + scanId, + thread: { + id: "standard-thread", + async runStreamed() { + return { events: completedEvents("standard-thread") }; + }, + }, + events: completedEvents("standard-thread"), + signal: new AbortController().signal, + scanDir, + pluginRoot, + expectation: { + repository, + repositoryRevision: null, + target: { kind: "repository", paths: [] }, + mode: "standard", + pluginVersion: "0.1.0", + }, + onFinalize: async () => { + finalizations++; + throw finalization; + }, + }).catch((error: unknown) => error); + return { + error, + finalization, + finalizations, + drafts: observed.drafts, + calls: observed.calls, + }; +} + +function mutateDraft( + input: ScanDraftInput, + mutation?: Mutation, +): ScanDraftInput { + const draft = structuredClone(input); + if (mutation === "missing-findings") + return { ...draft, findings: undefined } as unknown as ScanDraftInput; + if (mutation === "wrong-scan") + draft.scanId = "553a0c18-dcdf-4a3b-8e39-2751a8187bce"; + if (mutation === "contradictory-coverage") + draft.coverage["completeness"] = "complete"; + if (mutation === "inverted-lines") { + const locations = draft.findings[0]!["locations"] as Record< + string, + unknown + >[]; + locations[0]!["endLine"] = 1; + } + if (mutation === "legacy-details") + draft.findings[0]!["validation"] = { + method: null, + limitations: "Legacy persisted limitation.", + }; + return draft; +} From f2ebd74243b4f185a2ca231e4fd8770bd6a690c2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:31:41 +0000 Subject: [PATCH 080/251] Verify accepted bytes during stopped result recovery --- .../scripts/workbench_saved_results.py | 94 +++++++++++++++--- .../tests/test_stopped_accepted_digests.py | 97 +++++++++++++++++++ 2 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 plugins/codex-security/tests/test_stopped_accepted_digests.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 088147d407..2a6321ddbe 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -121,6 +121,18 @@ def _saved_workers(connection: Any, scan_id: str) -> list[dict[str, Any]]: ] +def _accepted_source_digests(connection: Any, scan_id: str) -> dict[str, str]: + return { + row["accepted_result_path"]: row["accepted_result_sha256"] + for row in connection.execute( + "SELECT accepted_result_path, accepted_result_sha256 FROM deep_scan_attempts " + "WHERE scan_id = ? AND accepted_result_path IS NOT NULL " + "AND accepted_result_sha256 IS NOT NULL", + (scan_id,), + ) + } + + def _latest_successful_reducer(workers: list[Any]) -> Any | None: return max( ( @@ -178,9 +190,17 @@ def checkpoints(directory: str, kind: str | None = None) -> Iterator[tuple[str, def _read_saved_result( - scan_dir: Path, relative: str, scan_id: str, *, kind: str | None = None + scan_dir: Path, + relative: str, + scan_id: str, + *, + kind: str | None = None, + accepted_source_digests: dict[str, str] | None = None, ) -> tuple[dict[str, Any], str]: - draft = _read_scan_local_json(scan_dir, relative, "Saved scan checkpoint") + draft, contents = _read_scan_local_json_bytes(scan_dir, relative, "Saved scan checkpoint") + expected = (accepted_source_digests or {}).get(str(scan_dir / relative)) + if expected is not None and hashlib.sha256(contents).hexdigest() != expected: + raise ContractError("checkpoint changed after acceptance") if draft.get("scanId") != scan_id: raise ContractError("checkpoint belongs to a different scan") coverage = ( @@ -193,7 +213,12 @@ def _read_saved_result( return draft, _digest(draft) -def _worker_checkpoint_head(scan_dir: Path, directory: str, scan_id: str) -> str | None: +def _worker_checkpoint_head( + scan_dir: Path, + directory: str, + scan_id: str, + accepted_source_digests: dict[str, str] | None = None, +) -> str | None: relative = f"{directory}/checkpoint-head.json" try: (scan_dir / relative).lstat() @@ -206,11 +231,18 @@ def _worker_checkpoint_head(scan_dir: Path, directory: str, scan_id: str) -> str checkpoint = f"{directory}/checkpoints/{name}" # A committed head precedes replacement of result.json. Do not fall back to # that older result if the selected checkpoint cannot be read. - _read_saved_result(scan_dir, checkpoint, scan_id) + _read_saved_result( + scan_dir, checkpoint, scan_id, accepted_source_digests=accepted_source_digests + ) return checkpoint -def _worker_checkpoint_heads(scan_dir: Path, workers: list[Any], scan_id: str) -> dict[str, str]: +def _worker_checkpoint_heads( + scan_dir: Path, + workers: list[Any], + scan_id: str, + accepted_source_digests: dict[str, str] | None = None, +) -> dict[str, str]: heads: dict[str, str] = {} for worker in workers: if worker["kind"] != "discovery": @@ -227,7 +259,7 @@ def _worker_checkpoint_heads(scan_dir: Path, workers: list[Any], scan_id: str) - ] for directory in directories: relative = directory.as_posix() - head = _worker_checkpoint_head(scan_dir, relative, scan_id) + head = _worker_checkpoint_head(scan_dir, relative, scan_id, accepted_source_digests) if head is not None: heads[relative] = head return heads @@ -276,13 +308,20 @@ def _saved_results_changed(db: Any, connection: Any, scan: Any) -> bool: scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) manifest_path = db.artifact_path(scan_dir, db.ARTIFACTS["manifest"], required=False) workers = _saved_workers(connection, scan["id"]) + accepted_digests = _accepted_source_digests(connection, scan["id"]) paths = dict(_saved_result_paths(scan_dir, workers)) frozen_sources = scan["retained_source_digests_json"] def has_saved_source() -> bool: for path in paths: try: - _read_saved_result(scan_dir, path, scan["id"], kind=paths[path]) + _read_saved_result( + scan_dir, + path, + scan["id"], + kind=paths[path], + accepted_source_digests=accepted_digests, + ) return True except (ContractError, OSError, ValueError): continue @@ -310,15 +349,19 @@ def has_saved_source() -> bool: published_sources = _source_digests( manifest_scan.get("preservedSources", {}), "Published scan" ) - if _worker_checkpoint_heads(scan_dir, workers, scan["id"]) != manifest_scan.get( - "preservedCheckpointHeads", {} - ): + if _worker_checkpoint_heads( + scan_dir, workers, scan["id"], accepted_digests + ) != manifest_scan.get("preservedCheckpointHeads", {}): return True current_sources = dict(published_sources) for path in paths: try: _, current_sources[path] = _read_saved_result( - scan_dir, path, scan["id"], kind=paths[path] + scan_dir, + path, + scan["id"], + kind=paths[path], + accepted_source_digests=accepted_digests, ) except (ContractError, OSError, ValueError): continue @@ -368,12 +411,19 @@ def _recovery_source_digests( include_parent = True workers = _saved_workers(connection, scan["id"]) - checkpoint_heads = _worker_checkpoint_heads(scan_dir, workers, scan["id"]) + accepted_digests = _accepted_source_digests(connection, scan["id"]) + checkpoint_heads = _worker_checkpoint_heads(scan_dir, workers, scan["id"], accepted_digests) paths = dict(_saved_result_paths(scan_dir, workers)) recovery_sources = dict(frozen_sources or {}) for relative, expected_digest in recovery_sources.items(): try: - _, digest = _read_saved_result(scan_dir, relative, scan["id"], kind=paths.get(relative)) + _, digest = _read_saved_result( + scan_dir, + relative, + scan["id"], + kind=paths.get(relative), + accepted_source_digests=accepted_digests, + ) except (ContractError, OSError, ValueError) as exc: raise ContractError("Frozen stopped-scan checkpoint set is incomplete.") from exc if digest != expected_digest: @@ -382,7 +432,11 @@ def _recovery_source_digests( for relative in paths.keys() - recovery_sources.keys(): try: _, recovery_sources[relative] = _read_saved_result( - scan_dir, relative, scan["id"], kind=paths[relative] + scan_dir, + relative, + scan["id"], + kind=paths[relative], + accepted_source_digests=accepted_digests, ) except (ContractError, OSError, ValueError): continue @@ -530,11 +584,14 @@ def merge_saved_results( frozen_source_digests: dict[str, str] | None = None, checkpoint_heads: dict[str, str] | None = None, allow_frozen_legacy_parent: bool = False, + accepted_source_digests: dict[str, str] | None = None, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None: """Read only bound parent/worker files; return an unsealed loss-preserving union.""" initial_warnings = set(warnings) if checkpoint_heads is None: - checkpoint_heads = _worker_checkpoint_heads(scan_dir, workers, scan_id) + checkpoint_heads = _worker_checkpoint_heads( + scan_dir, workers, scan_id, accepted_source_digests + ) parent: dict[str, Any] | None = None parent_manifest: dict[str, Any] | None = None if frozen_source_digests is None or allow_frozen_legacy_parent: @@ -673,7 +730,11 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: for relative, worker_id in paths.items(): try: draft, digest = _read_saved_result( - scan_dir, relative, scan_id, kind="dedup" if relative in reducer_paths else None + scan_dir, + relative, + scan_id, + kind="dedup" if relative in reducer_paths else None, + accepted_source_digests=accepted_source_digests, ) if frozen_source_digests is not None and frozen_source_digests[relative] != digest: raise ContractError("checkpoint changed after the scan stopped") @@ -1354,6 +1415,7 @@ def record_publication(manifest: dict[str, Any], findings: dict[str, Any]) -> No ).strip(), frozen_source_digests=frozen_source_digests, checkpoint_heads=checkpoint_heads, + accepted_source_digests=_accepted_source_digests(connection, scan_id), allow_frozen_legacy_parent=( include_parent_with_recovery or ( diff --git a/plugins/codex-security/tests/test_stopped_accepted_digests.py b/plugins/codex-security/tests/test_stopped_accepted_digests.py new file mode 100644 index 0000000000..3777f85c0b --- /dev/null +++ b/plugins/codex-security/tests/test_stopped_accepted_digests.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import sqlite3 +from argparse import Namespace + +import pytest +from test_accepted_publication_references import accept_reducer +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan + + +@pytest.mark.parametrize("recorded", [True, False], ids=["accepted-receipt", "legacy"]) +@pytest.mark.parametrize("changed", [False, True], ids=["original", "changed"]) +@pytest.mark.parametrize("historical", [False, True], ids=["current-attempt", "prior-attempt"]) +def test_stopped_recovery_checks_recorded_accepted_bytes( + workbench_api, workbench_db, publication_scan, tmp_path, recorded, changed, historical +): + scan = publication_scan() + result, accepted, _ = accept_reducer(workbench_db, scan) + checkpoint = result.parent / "checkpoints" / accepted.name + checkpoint.parent.mkdir() + accepted.rename(checkpoint) + original = checkpoint.read_bytes() + digest = hashlib.sha256(original).hexdigest() + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_attempts SET accepted_result_path = ? WHERE worker_id = ?", + (str(checkpoint), result.parent.name), + ) + if not recorded: + workbench_db.execute( + "DELETE FROM deep_scan_attempts WHERE worker_id = ?", (result.parent.name,) + ) + if historical: + workbench_db.execute( + "UPDATE deep_scan_workers SET attempt = 2, status = 'running' WHERE id = ?", + (result.parent.name,), + ) + if changed: + damaged = json.loads(original) + damaged["findings"][0]["summary"] = "Unaccepted changed evidence." + checkpoint.write_text(json.dumps(damaged)) + accepted_bytes = checkpoint.read_bytes() + # An unrelated valid source must still survive stopped partial preservation. + healthy = add_worker(workbench_db, scan) + finding = copy.deepcopy(scan.findings[0]) + finding["summary"] = "Independent preserved evidence." + finding["identity"]["anchor"] += ".independent" + finding["locations"][0]["startLine"] = 2 + finding["locations"][0]["endLine"] = 2 + healthy.write_text( + json.dumps( + { + "scanId": scan.scan_id, + "complete": True, + "findings": [finding], + "coverage": scan.coverage, + } + ) + ) + healthy_bytes = healthy.read_bytes() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + database = tmp_path / "accepted-digests.sqlite3" + with sqlite3.connect(database) as connection: + workbench_db.backup(connection) + with sqlite3.connect(database) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + stopped = workbench_api["fail_scan"]( + connection, + Namespace( + scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped." + ), + )["scan"] + summaries = {finding["summary"] for finding in stopped["findings"]} + assert "Independent preserved evidence." in summaries + assert ("Unaccepted changed evidence." in summaries) == (changed and not recorded) + if not changed: + assert scan.findings[0]["summary"] in summaries + if recorded: + assert ( + connection.execute( + "SELECT accepted_result_sha256 FROM deep_scan_attempts WHERE worker_id = ?", + (result.parent.name,), + ).fetchone()[0] + == digest + ) + if recorded and changed: + warnings = json.loads( + connection.execute("SELECT completion_warnings_json FROM scans").fetchone()[0] + ) + assert any("changed after acceptance" in warning for warning in warnings) + assert checkpoint.read_bytes() == accepted_bytes + assert healthy.read_bytes() == healthy_bytes From 4ef35228ec22137c118f3add364a57a8362374b3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:26:22 +0000 Subject: [PATCH 081/251] Recover committed scan completion and finish selected cost stops --- sdk/typescript/src/api.ts | 78 ++++++++++++++----- .../tests-ts/deep-finalization.test.ts | 66 ++++++++++++++-- 2 files changed, 118 insertions(+), 26 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 828457aa74..a269224b6a 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2156,14 +2156,31 @@ export class CodexSecurity { onObserverError: options.onObserverError, }); checkOpen(); - const completion = await workbench(workbenchOptions, [ + const completionArgs = [ "complete-scan", "--scan-id", scanId, ...(completionCost === null ? [] : ["--cost-json", JSON.stringify(completionCost)]), - ]); + ]; + const completion = await workbench( + workbenchOptions, + completionArgs, + ).catch(async (error) => { + const saved = await workbench(workbenchOptions, [ + "get-scan", + "--scan-id", + scanId, + ]).catch(() => null); + const savedScan = saved?.["scan"]; + const progress = isRecord(savedScan) ? savedScan["progress"] : null; + if (!isRecord(progress) || progress["status"] !== "complete") + throw error; + // A lost response can follow a durable seal. The existing completion + // command validates that seal and returns its committed receipt. + return workbench(workbenchOptions, completionArgs); + }); activeScan = null; const completedScan = completion["scan"]; if (isRecord(completedScan) && Array.isArray(completedScan["warnings"])) { @@ -2339,18 +2356,46 @@ export class CodexSecurity { options.signal?.aborted !== true ) { try { - const completion = await workbench( - { ...activeScan.options, signal: undefined }, - [ - "complete-budget-exhausted-scan", - "--scan-id", - activeScan.id, - "--cost-json", - JSON.stringify(snapshot?.cost ?? failure.cost), - "--message", - failure.message.slice(0, 2400), - ], - ); + const completionSignal = AbortSignal.any([ + this.#abortController.signal, + ...(options.signal === undefined ? [] : [options.signal]), + ]); + const completionOptions = { + ...activeScan.options, + signal: completionSignal, + }; + const saved = await workbench(completionOptions, [ + "get-deep-scan", + "--scan-id", + activeScan.id, + "--thread-id", + budgetRecovery.threadId, + ]).catch(() => null); + const deep = saved?.["deepScan"]; + if ( + isRecord(deep) && + deep["status"] === "running" && + isRecord(deep["finalizationInput"]) + ) { + // Cost stops model work, but an already selected result can still + // finish through the local publisher. Caller cancellation remains live. + await resumeSelectedDeepScan({ + scanId: activeScan.id, + threadId: budgetRecovery.threadId, + pluginRoot: budgetRecovery.pluginRoot, + signal: completionSignal, + runWorkbench: (args) => workbench(completionOptions, args), + }); + } + const completion = await workbench(completionOptions, [ + "complete-budget-exhausted-scan", + "--scan-id", + activeScan.id, + "--cost-json", + JSON.stringify(snapshot?.cost ?? failure.cost), + "--message", + failure.message.slice(0, 2400), + ]); activeScan = null; runPostScan = null; const result = await collectResult( @@ -2363,10 +2408,7 @@ export class CodexSecurity { scanDir, budgetRecovery.pluginRoot, budgetRecovery.expectation, - AbortSignal.any([ - this.#abortController.signal, - ...(options.signal === undefined ? [] : [options.signal]), - ]), + completionSignal, true, ); if (result.coverage.completeness !== "partial") { diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 93c013f07c..c14fac6e09 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -34,6 +34,8 @@ for (const outcome of [ "budget-after-deep-finish", "budget-during-resumed-publication", "closed-during-resumed-publication", + "lost-completion-response", + "completion-before-commit-fails", "followup-canceled", ] as const) { const resumedStop = outcome.includes("-resumed-"); @@ -65,6 +67,8 @@ for (const outcome of [ let scanId = ""; let workbenchOptions: WorkbenchCommandOptions; let publicationFails = restart; + let completionReceiptLost = outcome === "lost-completion-response"; + let budgetTriggered = false; let acceptedReport = ""; const modelInputs: string[] = []; const commands: string[] = []; @@ -107,14 +111,25 @@ for (const outcome of [ publicationFails = false; throw new Error("Synthetic publication write failure"); } + if ( + args[0] === "complete-scan" && + outcome === "completion-before-commit-fails" + ) + throw new Error("Synthetic completion failure before commit"); const result = await runWorkbench(options, args, input); + if (args[0] === "complete-scan" && completionReceiptLost) { + completionReceiptLost = false; + throw new Error("Synthetic lost completion response"); + } if ( - (args[0] === "write-scan-draft" && + !budgetTriggered && + ((args[0] === "write-scan-draft" && (outcome === "budget-during-publication" || outcome === "budget-during-resumed-publication")) || - (args[0] === "finish-deep-scan" && - outcome === "budget-after-deep-finish") + (args[0] === "finish-deep-scan" && + outcome === "budget-after-deep-finish")) ) { + budgetTriggered = true; await appendFile( usagePath, JSON.stringify({ @@ -278,7 +293,9 @@ for (const outcome of [ "--coordinator-generation", "2", "--terminal-reason", - "saturated", + outcome === "lost-completion-response" + ? "capped" + : "saturated", "--manifest-path", join(scanDir, "scan-manifest.json"), ], @@ -370,7 +387,7 @@ for (const outcome of [ ...(resumedStop ? { resumeScanId: scanId, outputDir: scanDir } : {}), postScanPrompt: followUp, }); - if (outcome === "budget-after-deep-finish") { + if (budgeted) { const result = await running; expect(result.coverage.completeness).toBe("partial"); expect(JSON.stringify(result.coverage)).toContain("cost limit"); @@ -379,11 +396,20 @@ for (const outcome of [ expect(modelInputs).toHaveLength(1); expect(commands).toContain("complete-budget-exhausted-scan"); expect(commands).not.toContain("fail-scan"); + const completed = await runWorkbench(workbenchOptions!, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + threadId, + ]); + expect(completed["deepScan"]).toMatchObject({ + status: "succeeded", + finalizationInput: { terminalReason: "saturated" }, + }); return; } - await expect(running).rejects.toThrow( - budgeted ? /estimated cost.*exceeded/ : /closed/, - ); + await expect(running).rejects.toThrow(/closed/); await closePromise; const stopped = await runWorkbench( { ...workbenchOptions!, signal: undefined }, @@ -490,6 +516,25 @@ for (const outcome of [ ); return; } + if (outcome === "completion-before-commit-fails") { + await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( + "Synthetic completion failure before commit", + ); + expect( + commands.filter((command) => command === "complete-scan"), + ).toHaveLength(1); + expect(commands).not.toContain("fail-scan"); + expect(modelInputs).toHaveLength(1); + const saved = await runWorkbench(workbenchOptions!, [ + "get-scan", + "--scan-id", + scanId, + ]); + expect(saved["scan"]).toMatchObject({ + progress: { status: "running" }, + }); + return; + } const result = await client.run(repository, { mode: "deep", signal: cancellation.signal, @@ -498,6 +543,11 @@ for (const outcome of [ ...(restart ? { resumeScanId: scanId, outputDir: scanDir } : {}), }); expect(result.threadId).toBe(threadId); + if (outcome === "lost-completion-response") { + expect( + commands.filter((command) => command === "complete-scan"), + ).toHaveLength(2); + } // The synthetic accepted workers have no native usage receipts. expect(result.cost).toBeNull(); expect(result.coverage.completeness).toBe("partial"); From b8147b22f1bbd0fa6cb6545687e9926ec816b1aa Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:40:06 +0000 Subject: [PATCH 082/251] Cover canceled follow-up after recovering a completion receipt --- .../tests-ts/deep-finalization.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index c14fac6e09..66c610d5d4 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -35,6 +35,7 @@ for (const outcome of [ "budget-during-resumed-publication", "closed-during-resumed-publication", "lost-completion-response", + "lost-completion-response-followup-canceled", "completion-before-commit-fails", "followup-canceled", ] as const) { @@ -42,6 +43,8 @@ for (const outcome of [ const restart = outcome === "restart" || resumedStop; const closed = outcome.startsWith("closed-"); const budgeted = outcome.startsWith("budget-"); + const canceledFollowUp = outcome.endsWith("followup-canceled"); + const loseCompletionResponse = outcome.startsWith("lost-completion-response"); const name = outcome === "followup-canceled" ? "SDK preserves a selected aggregate when its follow-up is canceled" @@ -67,7 +70,7 @@ for (const outcome of [ let scanId = ""; let workbenchOptions: WorkbenchCommandOptions; let publicationFails = restart; - let completionReceiptLost = outcome === "lost-completion-response"; + let completionReceiptLost = loseCompletionResponse; let budgetTriggered = false; let acceptedReport = ""; const modelInputs: string[] = []; @@ -192,7 +195,7 @@ for (const outcome of [ async runStreamed(input: string) { modelInputs.push(input); if (input === followUp) { - if (outcome === "followup-canceled") { + if (canceledFollowUp) { const reportPath = join(scanDir, "report.md"); acceptedReport = await readFile(reportPath, "utf8"); expect(acceptedReport).toContain( @@ -293,9 +296,7 @@ for (const outcome of [ "--coordinator-generation", "2", "--terminal-reason", - outcome === "lost-completion-response" - ? "capped" - : "saturated", + loseCompletionResponse ? "capped" : "saturated", "--manifest-path", join(scanDir, "scan-manifest.json"), ], @@ -490,7 +491,7 @@ for (const outcome of [ expect(modelInputs.length).toBe(1); return; } - if (outcome === "followup-canceled") { + if (canceledFollowUp) { await expect( client.run(repository, { mode: "deep", @@ -509,6 +510,9 @@ for (const outcome of [ }); expect(modelInputs.length).toBe(2); expect(modelInputs[1]).toBe(followUp); + expect( + commands.filter((command) => command === "complete-scan"), + ).toHaveLength(loseCompletionResponse ? 2 : 1); expect(commands).not.toContain("cancel-scan"); expect(commands).not.toContain("fail-scan"); expect(await readFile(join(scanDir, "report.md"), "utf8")).toBe( From c17bd156e179eb21443549b1483fdf6b8d72b0e8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:44:37 +0000 Subject: [PATCH 083/251] Keep legacy coverage fixtures explicit after workflow activation --- .../mcp-app/tests/deep_scan_coverage_fixture.mjs | 10 +++++++--- .../mcp-app/tests/test_deep_scan_store.mjs | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index fba7de5b04..05cfb0b3ab 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -64,14 +64,18 @@ export async function publishCoverageFixture(root, completeness, { }; const store = new WorkbenchDeepScanStore(runWorkbench); let { run } = await store.begin({ targetPath, scope: ".", threadId, scanRoot }); + assert.equal(run.workflowVersion, "deep-security-scan/v2", "new scans use persisted finalization"); if (selectedRecovery) { - // Exercise an existing v2 run without enabling the new-run writer. + ({ run } = await store.claimCoordinator({ scanId: run.scanId, threadId })); + } else { + // Seed an existing v1 run for legacy direct publication and in-memory coverage recovery. await exec(process.env.PYTHON || "python3", ["-c", [ "import sqlite3, sys", "with sqlite3.connect(sys.argv[1]) as db:", - " db.execute(\"UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2' WHERE scan_id = ?\", (sys.argv[2],))", + " db.execute(\"UPDATE deep_scan_runs SET workflow_version = 'deep-scan-mcp/v1' WHERE scan_id = ?\", (sys.argv[2],))", ].join("\n"), path.join(root, "state", "workbench.sqlite3"), run.scanId]); - ({ run } = await store.claimCoordinator({ scanId: run.scanId, threadId })); + run = await store.get(run.scanId, threadId); + assert.equal(run.workflowVersion, "deep-scan-mcp/v1"); } const context = await createScanArtifactContext(run.scanId, runWorkbench, { requireRunning: true }); const rawSources = new Map(); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs index cebf94c9c6..1faa302915 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs @@ -81,7 +81,7 @@ async function testBeginProtocolAndParsing() { assert.equal(calls[0].input, "focus on archive parsing"); assert.equal(flagValue(calls[0].args, "--scan-root"), "/fixture/scans"); assert.equal(flagValue(calls[0].args, "--available-parallelism"), String(availableParallelism())); - assert.equal(flagValue(calls[0].args, "--workflow-version"), "deep-scan-mcp/v1"); + assert.equal(flagValue(calls[0].args, "--workflow-version"), "deep-security-scan/v2"); const claimToken = randomUUID(); let joinedArgs; From 261a8253fd9697a93d0905ec758a0244da94a051 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:44:52 +0000 Subject: [PATCH 084/251] test(sdk): verify persisted Deep package execution state --- .../scripts/fixtures/package-deep-scan.mjs | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index c774c95675..7027049034 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { chmod, copyFile, @@ -14,7 +14,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { startRpc } from "./package-rpc.mjs"; @@ -325,6 +325,7 @@ async function runDetachedPlugin(pluginRoot, executable) { } finally { await rpc.close(); } + await assertSavedState(f, scanId, owner); await assertExecutions(f, scanId, 4); } @@ -473,9 +474,79 @@ async function runInstalledSdk(pluginRoot, executable) { } finally { await client.close(); } + await assertSavedState(f, scanId, owner); await assertExecutions(f, scanId, 4); } +async function assertSavedState(f, scanId, owner) { + const { deepScan } = await workbench(f, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + owner, + ]); + assert.equal(deepScan.status, "succeeded"); + if (deepScan.workflowVersion === "deep-security-scan/v2") { + const selected = deepScan.finalizationInput; + assert.ok( + selected, + "The completed v2 scan retains its finalization input.", + ); + assert.equal(selected.version, 1); + assert.equal(selected.terminalReason, deepScan.terminalReason); + assert.deepEqual(selected.omittedWorkerIds, []); + await assertDigest( + resolve(deepScan.scanDir, selected.resultPath), + selected.resultSha256, + ); + const workers = deepScan.workers.filter( + (worker) => worker.status === "succeeded", + ); + assert.equal(workers.length, 3); + for (const worker of workers) { + const attempt = deepScan.attempts.find( + (entry) => + entry.workerId === worker.id && entry.attempt === worker.attempt, + ); + assert.ok(attempt, "Each accepted worker retains its execution attempt."); + assert.equal(attempt.status, "succeeded"); + await assertDigest( + attempt.acceptedResultPath, + attempt.acceptedResultSha256, + ); + if (worker.kind === "dedup") { + assert.equal(selected.resultSha256, attempt.acceptedResultSha256); + } else { + const input = deepScan.dedupInputs.find( + (entry) => entry.discoveryWorkerId === worker.id, + ); + assert.ok(input, "The reducer retains each accepted discovery input."); + assert.equal(input.attempt, worker.attempt); + assert.equal(input.resultManifestSha256, attempt.acceptedResultSha256); + await assertDigest( + input.resultManifestPath, + input.resultManifestSha256, + ); + } + } + assert.equal(deepScan.dedupInputs.length, 2); + } + console.log( + JSON.stringify({ + fixture: basename(f.directory), + workflowVersion: deepScan.workflowVersion, + attempts: deepScan.attempts?.length ?? null, + selectedFinalization: deepScan.finalizationInput != null, + }), + ); +} + +async function assertDigest(path, expected) { + const bytes = await readFile(path); + assert.equal(createHash("sha256").update(bytes).digest("hex"), expected); +} + async function assertDraft(path) { const document = JSON.parse(await readFile(path, "utf8")); const findings = JSON.parse( From 0a8b31dde0c8e3f31e8da0a22464712c08a81d0e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:54:54 +0000 Subject: [PATCH 085/251] test: assert v2 coverage in attempt replay --- .../mcp-app/tests/test_deep_scan_attempt_replay.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs index 70500c8f01..b18a93105e 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs @@ -110,9 +110,9 @@ async function testResponseLoss(responseLosses) { assert.equal(merged.run.persistedDedupInputs.filter((input) => input.dedupWorkerId === merged.id).length, 2); assert.equal(counts.get("merge"), 2); assert.equal(executions, 3); - const { sourceCoverage, ...persistedResult } = merged.result; - assert.deepEqual(JSON.parse(await readFile(merged.resultPath, "utf8")), persistedResult); - if (sourceCoverage) assert.equal(sourceCoverage.completeness, "complete"); + assert.equal(run.workflowVersion, "deep-security-scan/v2"); + assert.deepEqual(JSON.parse(await readFile(merged.resultPath, "utf8")), merged.result); + assert.equal(merged.result.sourceCoverage.completeness, "complete"); const snapshot = await store.get(run.scanId, "fixture-owner"); const resumed = new DeepScanCoordinator({ run: snapshot, store, pluginRoot: plugin, From 412fed646a4612c178e71abe4223502aeaca1b2b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:35:53 +0000 Subject: [PATCH 086/251] Test complete native selections against legacy turn fields --- .../tests/test_deep_scan_recovery_settings.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 419291d430..9df41f4fc9 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -128,10 +128,10 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(unboundLegacy.modelProvider, undefined, "unrecorded legacy ownership cannot recover caller selections"); assert.equal(unboundLegacy.reasoningSummary, undefined); await writeFile(join(sessionDirectory, "applied.jsonl"), [ - { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: "fixture-applied", model_provider: "openai" } }, + { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: "fixture-applied", model_provider: "previous-provider" } }, { type: "event_msg", timestamp: "2026-01-01T00:00:01Z", payload: { type: "thread_settings_applied", thread_id: "fixture-applied", thread_settings: { model: "applied-model", model_provider_id: "openai", service_tier: "default", reasoning_effort: "high", reasoning_summary: "concise" } } }, - { type: "turn_context", timestamp: "2026-01-01T00:00:02Z", payload: { turn_id: "applied-turn", model: "applied-model", effort: "high", summary: "none" } }, + { type: "turn_context", timestamp: "2026-01-01T00:00:02Z", payload: { turn_id: "applied-turn", model: "previous-model", effort: "low", summary: "none" } }, { type: "event_msg", timestamp: "2026-01-01T00:00:03Z", payload: { type: "thread_settings_applied", thread_id: "fixture-copied-owner", thread_settings: { model: "copied-model", model_provider_id: "copied-provider", service_tier: "flex", reasoning_summary: "detailed" } } }, { type: "event_msg", timestamp: "2026-01-01T00:02:00Z", payload: { type: "thread_settings_applied", thread_id: "fixture-applied", @@ -142,7 +142,9 @@ http_headers = { Authorization = "synthetic-secret" } { threadId: "fixture-other", startedAt: "2026-01-01T00:01:00Z" }); assert.equal(applied.serviceTier, "default", "original explicit standard routing survives later and copied snapshots"); assert.equal(applied.reasoningSummary, "concise", "native applied summary overrides the legacy compatibility field"); - assert.equal(applied.modelProvider, "openai"); + assert.equal(applied.modelProvider, "openai", "complete native snapshot replaces the session metadata provider"); + assert.equal(applied.model, "applied-model", "complete native snapshot replaces compatibility turn settings"); + assert.equal(applied.reasoningEffort, "high"); const tierDir = join(root, "missing-tier"); const { serviceTier: omittedTier, ...withoutTier } = applied; assert.equal(omittedTier, "default"); @@ -158,6 +160,9 @@ http_headers = { Authorization = "synthetic-secret" } } }) + "\n"); const nativeDefaults = await captureSettings({ usageOwner: appliedOwner }, { filesystemDenies: [] }, parentEnvironment, { threadId: "fixture-other", startedAt: "2026-01-01T00:01:00Z" }); + assert.equal(nativeDefaults.model, "applied-model", "absent optional selections do not erase the required model"); + assert.equal(nativeDefaults.modelProvider, "openai", "absent optional selections do not erase the required provider"); + assert.equal(nativeDefaults.reasoningEffort, "high"); assert.equal(nativeDefaults.serviceTier, undefined, "native model-default selection is not explicit standard routing"); assert.equal(nativeDefaults.reasoningSummary, undefined, "a compatibility summary is not a recorded native default"); const incompleteDir = join(root, "incomplete"); From 82248b231fa8fd56c47adaa99ad7ab23414cde5e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:42:44 +0000 Subject: [PATCH 087/251] Verify historical settings upgrades and default selected recovery --- .../mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs | 3 +++ .../tests/test_deep_scan_recovery_settings.py | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 33a81f946a..dac6ff9ad3 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -487,6 +487,7 @@ async function testDeepScanStdioLifecycle() { const completedDraft = JSON.parse(await readFile(completedWorker.resultManifestPath, "utf8")); assert.equal(completedDraft.scanId, resumedScanId); assert.deepEqual(completedDraft.findings, []); + assert.equal(partial.workflowVersion, "deep-security-scan/v2", "new scans use selected finalization by default"); assert.equal(partial.userContext, "Original discovery focus"); assert.equal(partial.usageOwner.threadId, resumedThreadId); const settingsPath = path.join(resumedScan.scanDir, "artifacts", "deep_discovery", "execution-settings.json"); @@ -563,6 +564,8 @@ async function testDeepScanStdioLifecycle() { environment, scanId: resumedScanId, threadId: resumedThreadId }); assert.equal(finished.status, "succeeded"); + assert.equal(finished.workflowVersion, partial.workflowVersion); + assert.equal(finished.finalizationInput.version, 1, "recovery selects a persisted finalization input"); assert.equal(finished.coordinatorGeneration, partial.coordinatorGeneration + 1); assert.equal(finished.dispatchedCount, 2); assert.equal(finished.userContext, partial.userContext); diff --git a/plugins/codex-security/tests/test_deep_scan_recovery_settings.py b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py index ba61361257..37976c3e13 100644 --- a/plugins/codex-security/tests/test_deep_scan_recovery_settings.py +++ b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py @@ -82,13 +82,18 @@ def test_reconstruction_preserves_discovery_input_settings_and_deadline( assert run["workflowVersion"] == begun["workflowVersion"] -def test_supported_old_run_snapshots_context_on_upgrade(tmp_path: Path) -> None: +@pytest.mark.parametrize("workflow_version", ["deep-security-scan/v1", "deep-scan-mcp/v1"]) +def test_supported_old_run_snapshots_context_on_upgrade( + tmp_path: Path, workflow_version: str +) -> None: target = tmp_path / "target" target.mkdir() state = tmp_path / "state" begun = run_workbench( state, "begin-deep-scan", + "--workflow-version", + workflow_version, "--thread-id", "fixture-thread", "--target-path", @@ -109,6 +114,7 @@ def test_supported_old_run_snapshots_context_on_upgrade(tmp_path: Path) -> None: "--thread-id", "fixture-thread", )["deepScan"] + assert upgraded["workflowVersion"] == workflow_version assert upgraded["userContext"] == "Legacy context" assert upgraded["config"] == begun["config"] assert upgraded["createdAt"] == begun["createdAt"] From 9ed7afca048709f6636fe4dc7fc56f8498b08107 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 06:54:40 +0000 Subject: [PATCH 088/251] Preserve recorded native absent tier with explicit provenance --- .../mcp-app/src/deep-scan/recovery-settings.ts | 15 +++++++++++++-- .../mcp-app/tests/test_deep_scan_executor.mjs | 3 ++- .../tests/test_deep_scan_recovery_settings.mjs | 16 +++++++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 38b9675700..e5ff33d3ec 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -20,6 +20,8 @@ export interface DeepScanExecutionSettings { reasoningEffort?: string; reasoningSummary?: string; serviceTier?: string; + /** The native snapshot recorded no request tier; serviceTier preserves its wire behavior. */ + nativeServiceTierAbsent?: true; providerConfig?: JsonObject; parentSandbox?: DeepWorkerParentSandbox; } @@ -57,6 +59,8 @@ export async function captureDeepScanExecutionSettings( modelProvider: (selected.model_provider as string | undefined) ?? native.modelProvider, reasoningSummary: (selected.model_reasoning_summary as string | undefined) ?? native.reasoningSummary, serviceTier: (selected.service_tier as string | undefined) ?? native.serviceTier, + ...(selected.service_tier === undefined && native.nativeServiceTierAbsent + ? { nativeServiceTierAbsent: true as const } : {}), providerConfig: selected.model_providers as JsonObject | undefined, parentSandbox }); @@ -94,7 +98,11 @@ async function originalParentSettings( modelProvider: typeof value.model_provider_id === "string" ? value.model_provider_id : undefined, reasoningEffort: typeof value.reasoning_effort === "string" ? value.reasoning_effort : undefined, reasoningSummary: typeof value.reasoning_summary === "string" ? value.reasoning_summary : undefined, - serviceTier: typeof value.service_tier === "string" ? value.service_tier : undefined + // A persisted native absent tier and explicit standard both omit the + // request tier. This does not infer a tier from missing history. + serviceTier: typeof value.service_tier === "string" ? value.service_tier + : value.service_tier === undefined ? "default" : undefined, + ...(value.service_tier === undefined ? { nativeServiceTierAbsent: true as const } : {}) }; } if (event.type === "session_meta" && typeof context.model_provider === "string") { @@ -150,7 +158,9 @@ export async function loadOrCaptureDeepScanExecutionSettings( reasoningEffort: settings.reasoningEffort ?? original.reasoningEffort ?? native.reasoningEffort, modelProvider: settings.modelProvider ?? native.modelProvider, reasoningSummary: settings.reasoningSummary ?? native.reasoningSummary, - serviceTier: settings.serviceTier ?? native.serviceTier + serviceTier: settings.serviceTier ?? native.serviceTier, + ...(settings.serviceTier === undefined && native.nativeServiceTierAbsent + ? { nativeServiceTierAbsent: true as const } : {}) }); if (JSON.stringify(recovered) !== JSON.stringify(settings)) { await writeJsonAtomic(path, { version: 1, settings: recovered }); @@ -213,6 +223,7 @@ function executionSettings(value: DeepScanExecutionSettings): DeepScanExecutionS reasoningEffort: value.reasoningEffort, reasoningSummary: value.reasoningSummary, serviceTier: value.serviceTier, + ...(value.nativeServiceTierAbsent === true ? { nativeServiceTierAbsent: true } : {}), ...(provider === undefined ? {} : { providerConfig: provider }), ...(value.parentSandbox === undefined ? {} : { parentSandbox: { filesystemDenies: [...value.parentSandbox.filesystemDenies], diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index a38eb10eec..ee1fe651aa 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -791,7 +791,7 @@ async function testIsolatedReconstructedWorkers() { payload: { id: `fixture-${name}-owner`, model_provider: config.model_provider } }, { type: "event_msg", timestamp: "2026-01-01T00:00:00Z", payload: { type: "thread_settings_applied", thread_id: `fixture-${name}-owner`, thread_settings: { - model: "native-parent-model", model_provider_id: config.model_provider, service_tier: "default", + model: "native-parent-model", model_provider_id: config.model_provider, reasoning_effort: "medium", reasoning_summary: config.model_reasoning_summary } } }, { type: "turn_context", timestamp: "2026-01-01T00:00:01Z", @@ -805,6 +805,7 @@ async function testIsolatedReconstructedWorkers() { }) + "\n"); const saved = await loadOrCaptureDeepScanExecutionSettings(fixture.root, () => captureDeepScanExecutionSettings(settings, settings.parentSandbox, { ...codexOptions.env, CODEX_CLI_PATH: executable }, { threadId: `fixture-${name}-observer`, startedAt: "2026-01-01T00:01:00Z" })); + assert.equal(saved.nativeServiceTierAbsent, name === "first" ? true : undefined); const snapshotPath = path.join(fixture.root, "artifacts", "deep_discovery", "execution-settings.json"); const snapshot = await readFile(snapshotPath, "utf8"); assert.equal(snapshot.includes("synthetic-"), false); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 9df41f4fc9..ee4fef893c 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -114,6 +114,8 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(unavailableParent.reasoningEffort, "ultra"); assert.equal(unavailableParent.modelProvider, undefined, "missing history does not establish a provider"); assert.equal(unavailableParent.reasoningSummary, undefined); + assert.equal(unavailableParent.serviceTier, undefined); + assert.equal(unavailableParent.nativeServiceTierAbsent, undefined, "missing history does not prove native absence"); const originalOwner = { threadId: "fixture-parent", turnId: "original-turn", startedAt: "2026-01-01T00:00:00Z" }; const [rebound, unboundLegacy] = await Promise.all([ captureSettings({ usageOwner: originalOwner }, { filesystemDenies: [] }, parentEnvironment, @@ -145,6 +147,7 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(applied.modelProvider, "openai", "complete native snapshot replaces the session metadata provider"); assert.equal(applied.model, "applied-model", "complete native snapshot replaces compatibility turn settings"); assert.equal(applied.reasoningEffort, "high"); + assert.equal(applied.nativeServiceTierAbsent, undefined, "explicit native standard remains an explicit selection"); const tierDir = join(root, "missing-tier"); const { serviceTier: omittedTier, ...withoutTier } = applied; assert.equal(omittedTier, "default"); @@ -155,6 +158,15 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(repairedTier.serviceTier, "default"); await writeFile(join(sessionDirectory, "applied.jsonl"), (await readFile(join(sessionDirectory, "applied.jsonl"), "utf8")) + JSON.stringify({ type: "event_msg", timestamp: "2026-01-01T00:00:04Z", payload: { + type: "thread_settings_applied", thread_id: "fixture-applied", + thread_settings: { model: "applied-model", model_provider_id: "openai", reasoning_effort: "high", service_tier: "priority" } + } }) + "\n"); + const nativeTier = await captureSettings({ usageOwner: appliedOwner }, { filesystemDenies: [] }, parentEnvironment, + { threadId: "fixture-other", startedAt: "2026-01-01T00:01:00Z" }); + assert.equal(nativeTier.serviceTier, "priority", "an effective tier selected by native remains unchanged"); + assert.equal(nativeTier.nativeServiceTierAbsent, undefined); + await writeFile(join(sessionDirectory, "applied.jsonl"), (await readFile(join(sessionDirectory, "applied.jsonl"), "utf8")) + + JSON.stringify({ type: "event_msg", timestamp: "2026-01-01T00:00:05Z", payload: { type: "thread_settings_applied", thread_id: "fixture-applied", thread_settings: { model: "applied-model", model_provider_id: "openai", reasoning_effort: "high" } } }) + "\n"); @@ -163,7 +175,8 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(nativeDefaults.model, "applied-model", "absent optional selections do not erase the required model"); assert.equal(nativeDefaults.modelProvider, "openai", "absent optional selections do not erase the required provider"); assert.equal(nativeDefaults.reasoningEffort, "high"); - assert.equal(nativeDefaults.serviceTier, undefined, "native model-default selection is not explicit standard routing"); + assert.equal(nativeDefaults.serviceTier, "default", "known native absence retains its omitted request tier"); + assert.equal(nativeDefaults.nativeServiceTierAbsent, true, "known native absence is recorded separately from explicit standard"); assert.equal(nativeDefaults.reasoningSummary, undefined, "a compatibility summary is not a recorded native default"); const incompleteDir = join(root, "incomplete"); const incomplete = { codexPath: process.execPath, codexHome: root, serviceTier: "flex" }; @@ -185,6 +198,7 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(unknown.model, "stored-model"); assert.equal(unknown.modelProvider, undefined, "missing original ownership is not current config"); assert.equal(unknown.reasoningSummary, undefined); + assert.equal(unknown.nativeServiceTierAbsent, undefined); const unsupported = JSON.stringify({ version: 99, settings }); await writeFile(savedPath, unsupported); await assert.rejects(loadSettings(join(root, "one"), async () => assert.fail()), /unsupported/); From 2b02eab2cd088e20315b04b914fb0bf238358efa Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 07:49:55 +0000 Subject: [PATCH 089/251] fix(sdk): recover committed budget completion receipts --- sdk/typescript/src/api.ts | 51 ++++++--- .../tests-ts/deep-finalization.test.ts | 105 +++++++++++++++++- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index a269224b6a..4eebe0a1d2 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1218,6 +1218,24 @@ export class CodexSecurity { this.#dependencies.prepareScanArtifactRestorer ?? prepareScanArtifactRestorer; const workbench = this.#dependencies.runWorkbench ?? runWorkbench; + const recoverCompletedScan = async ( + commandOptions: WorkbenchCommandOptions, + scanId: string, + error: unknown, + completionArgs: readonly string[], + ): Promise => { + const saved = await workbench(commandOptions, [ + "get-scan", + "--scan-id", + scanId, + ]).catch(() => null); + const savedScan = saved?.["scan"]; + const progress = isRecord(savedScan) ? savedScan["progress"] : null; + if (!isRecord(progress) || progress["status"] !== "complete") throw error; + // A lost response can follow a durable seal. Only the existing normal + // completion command can validate and return that committed receipt. + return workbench(commandOptions, completionArgs); + }; try { const checkOpen = (): void => { this.#requireOpen(); @@ -2167,20 +2185,9 @@ export class CodexSecurity { const completion = await workbench( workbenchOptions, completionArgs, - ).catch(async (error) => { - const saved = await workbench(workbenchOptions, [ - "get-scan", - "--scan-id", - scanId, - ]).catch(() => null); - const savedScan = saved?.["scan"]; - const progress = isRecord(savedScan) ? savedScan["progress"] : null; - if (!isRecord(progress) || progress["status"] !== "complete") - throw error; - // A lost response can follow a durable seal. The existing completion - // command validates that seal and returns its committed receipt. - return workbench(workbenchOptions, completionArgs); - }); + ).catch((error) => + recoverCompletedScan(workbenchOptions, scanId, error, completionArgs), + ); activeScan = null; const completedScan = completion["scan"]; if (isRecord(completedScan) && Array.isArray(completedScan["warnings"])) { @@ -2356,6 +2363,8 @@ export class CodexSecurity { options.signal?.aborted !== true ) { try { + const budgetScanId = activeScan.id; + const budgetCost = snapshot?.cost ?? failure.cost; const completionSignal = AbortSignal.any([ this.#abortController.signal, ...(options.signal === undefined ? [] : [options.signal]), @@ -2390,12 +2399,20 @@ export class CodexSecurity { const completion = await workbench(completionOptions, [ "complete-budget-exhausted-scan", "--scan-id", - activeScan.id, + budgetScanId, "--cost-json", - JSON.stringify(snapshot?.cost ?? failure.cost), + JSON.stringify(budgetCost), "--message", failure.message.slice(0, 2400), - ]); + ]).catch((error) => + recoverCompletedScan(completionOptions, budgetScanId, error, [ + "complete-scan", + "--scan-id", + budgetScanId, + "--cost-json", + JSON.stringify(budgetCost), + ]), + ); activeScan = null; runPostScan = null; const result = await collectResult( diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 66c610d5d4..4db9760aa9 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -22,7 +22,7 @@ afterEach(cleanup); const threadId = "1af317a1-c9ed-4c73-b428-cb0d160cf8e8"; const followUp = "Explain the selected finding."; -for (const outcome of [ +const outcomes = [ "failed", "completed", "restart", @@ -38,7 +38,30 @@ for (const outcome of [ "lost-completion-response-followup-canceled", "completion-before-commit-fails", "followup-canceled", -] as const) { +] as const; +type BudgetCompletionFault = "lost" | "before-commit" | "lost-and-canceled"; +const cases: { + outcome: (typeof outcomes)[number]; + budgetCompletionFault?: BudgetCompletionFault; +}[] = [ + ...outcomes.map((outcome) => ({ outcome })), + ...( + [ + "budget-during-publication", + "budget-after-deep-finish", + "budget-during-resumed-publication", + ] as const + ).map((outcome) => ({ outcome, budgetCompletionFault: "lost" as const })), + { + outcome: "budget-after-deep-finish", + budgetCompletionFault: "before-commit", + }, + { + outcome: "budget-after-deep-finish", + budgetCompletionFault: "lost-and-canceled", + }, +]; +for (const { outcome, budgetCompletionFault } of cases) { const resumedStop = outcome.includes("-resumed-"); const restart = outcome === "restart" || resumedStop; const closed = outcome.startsWith("closed-"); @@ -48,7 +71,7 @@ for (const outcome of [ const name = outcome === "followup-canceled" ? "SDK preserves a selected aggregate when its follow-up is canceled" - : `SDK handles selected aggregate: ${outcome}`; + : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}`; const runCase = async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -71,6 +94,9 @@ for (const outcome of [ let workbenchOptions: WorkbenchCommandOptions; let publicationFails = restart; let completionReceiptLost = loseCompletionResponse; + let budgetReceiptLost = + budgetCompletionFault === "lost" || + budgetCompletionFault === "lost-and-canceled"; let budgetTriggered = false; let acceptedReport = ""; const modelInputs: string[] = []; @@ -119,7 +145,28 @@ for (const outcome of [ outcome === "completion-before-commit-fails" ) throw new Error("Synthetic completion failure before commit"); + if ( + args[0] === "complete-budget-exhausted-scan" && + budgetCompletionFault === "before-commit" + ) + throw new Error( + "Synthetic budget completion failure before commit", + ); const result = await runWorkbench(options, args, input); + if ( + args[0] === "complete-budget-exhausted-scan" && + budgetReceiptLost + ) { + budgetReceiptLost = false; + if (budgetCompletionFault === "lost-and-canceled") + cancellation.abort( + "Synthetic cancellation after budget completion", + ); + throw Object.assign( + new Error("Synthetic lost budget completion response"), + { code: "ETIMEDOUT" }, + ); + } if (args[0] === "complete-scan" && completionReceiptLost) { completionReceiptLost = false; throw new Error("Synthetic lost completion response"); @@ -384,11 +431,40 @@ for (const outcome of [ if (budgeted || closed) { const running = client.run(repository, { mode: "deep", + signal: cancellation.signal, ...(budgeted ? { maxCostUsd: 0.004 } : {}), ...(resumedStop ? { resumeScanId: scanId, outputDir: scanDir } : {}), postScanPrompt: followUp, }); if (budgeted) { + if ( + budgetCompletionFault === "before-commit" || + budgetCompletionFault === "lost-and-canceled" + ) { + await expect(running).rejects.toThrow(); + expect( + commands.filter( + (command) => command === "complete-budget-exhausted-scan", + ), + ).toHaveLength(1); + expect(commands).not.toContain("complete-scan"); + expect(modelInputs).toHaveLength(1); + const saved = await runWorkbench( + { ...workbenchOptions!, signal: undefined }, + ["get-scan", "--scan-id", scanId], + ); + expect(saved["scan"]).toMatchObject({ + progress: { + status: + budgetCompletionFault === "before-commit" + ? "failed" + : "complete", + }, + findingCount: 1, + reportAvailable: true, + }); + return; + } const result = await running; expect(result.coverage.completeness).toBe("partial"); expect(JSON.stringify(result.coverage)).toContain("cost limit"); @@ -397,6 +473,29 @@ for (const outcome of [ expect(modelInputs).toHaveLength(1); expect(commands).toContain("complete-budget-exhausted-scan"); expect(commands).not.toContain("fail-scan"); + expect( + commands.filter( + (command) => command === "complete-budget-exhausted-scan", + ), + ).toHaveLength(1); + if (budgetCompletionFault === "lost") { + expect( + commands.filter((command) => command === "complete-scan"), + ).toHaveLength(1); + expect(result.findings.findings[0]?.remediation).toBe( + "Validate the resolved destination before writing.", + ); + const saved = await runWorkbench(workbenchOptions!, [ + "get-scan", + "--scan-id", + scanId, + ]); + expect(saved["scan"]).toMatchObject({ + progress: { status: "complete" }, + findingCount: 1, + reportAvailable: true, + }); + } const completed = await runWorkbench(workbenchOptions!, [ "get-deep-scan", "--scan-id", From c02b33b08bae219200c0354c00b1523f410919cc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 08:03:25 +0000 Subject: [PATCH 090/251] test: preserve Node library resolution in worker fixtures --- .../mcp-app/tests/test_deep_scan_executor.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index ee1fe651aa..6c3a92cb10 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -764,7 +764,10 @@ async function testIsolatedReconstructedWorkers() { .map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); - await copyFile(process.execPath, executable); + // Keep dynamically linked Node beside its libraries on Unix. Each scan + // still selects a distinct executable path at the spawn boundary. + if (process.platform === "win32") await copyFile(process.execPath, executable); + else await symlink(process.execPath, executable); const codexOptions = { codexPathOverride: executable, baseUrl: `https://${name}.example.invalid/v1`, @@ -817,6 +820,10 @@ async function testIsolatedReconstructedWorkers() { } childProcess.spawn = (command, args, options) => { const scan = scans.find((scan) => options?.env?.FAKE_CODEX_MARKER === scan.fixture.markerPath); + if (scan) { + const configured = scan.settings.codexOptions.codexPathOverride; + assert.ok(command === configured || command === path.toNamespacedPath(configured)); + } return originalSpawn(command, scan ? [scan.fixture.executablePath, ...args] : args, options); }; syncBuiltinESMExports(); @@ -861,7 +868,7 @@ async function testIsolatedReconstructedWorkers() { assert.equal(result.threadId, resumeThreadId ?? "fixture-thread-id"); const child = JSON.parse(await readFile(scan.fixture.markerPath, "utf8")); const preflight = JSON.parse(await readFile(scan.fixture.preflightMarkerPath, "utf8")); - assert.equal(child.executable, scan.settings.codexOptions.codexPathOverride); + assert.equal(await realpath(child.executable), await realpath(scan.settings.codexOptions.codexPathOverride)); assert.equal(child.codexCliPath, scan.settings.codexOptions.codexPathOverride); assert.equal(child.codexHome, scan.settings.codexOptions.env.CODEX_HOME); assert.equal(preflight.codexHome, child.codexHome); From 47f40294db96a61a4d8ea7dca20ae825bae7282d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 08:46:23 +0000 Subject: [PATCH 091/251] fix(sdk): preserve stop errors after completed budget results --- sdk/typescript/src/api.ts | 22 +++++++++++++---- .../tests-ts/deep-finalization.test.ts | 24 +++++++++++++++++-- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 4eebe0a1d2..4e88ab862f 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2480,13 +2480,25 @@ export class CodexSecurity { const deep = saved?.["deepScan"]; if (isRecord(deep) && isRecord(deep["finalizationInput"])) { selectedDeepFinalization = true; - await workbench(workbenchOptions, [ - "cancel-scan", + const current = await workbench(workbenchOptions, [ + "get-scan", "--scan-id", activeScan.id, - "--thread-id", - observedScanThreadId, - ]); + ]).catch(() => null); + const scan = current?.["scan"]; + if ( + isRecord(scan) && + isRecord(scan["progress"]) && + scan["progress"]["status"] === "running" + ) { + await workbench(workbenchOptions, [ + "cancel-scan", + "--scan-id", + activeScan.id, + "--thread-id", + observedScanThreadId, + ]).catch(() => undefined); + } } } // Publication failures remain resumable. A cost stop or explicit client close diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 4db9760aa9..6e6905b773 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, expect, test } from "bun:test"; import type { ThreadEvent } from "@openai/codex-sdk"; +import { ScanCostLimitExceededError } from "../src/errors.js"; import { prepareScanArtifactRestorer, runWorkbench, @@ -99,6 +100,7 @@ for (const { outcome, budgetCompletionFault } of cases) { budgetCompletionFault === "lost-and-canceled"; let budgetTriggered = false; let acceptedReport = ""; + let completedArtifacts: Buffer[] = []; const modelInputs: string[] = []; const commands: string[] = []; const usagePath = join( @@ -158,10 +160,16 @@ for (const { outcome, budgetCompletionFault } of cases) { budgetReceiptLost ) { budgetReceiptLost = false; - if (budgetCompletionFault === "lost-and-canceled") + if (budgetCompletionFault === "lost-and-canceled") { + completedArtifacts = await Promise.all( + ["report.md", "scan-manifest.json"].map((name) => + readFile(join(scanDir, name)), + ), + ); cancellation.abort( "Synthetic cancellation after budget completion", ); + } throw Object.assign( new Error("Synthetic lost budget completion response"), { code: "ETIMEDOUT" }, @@ -441,7 +449,9 @@ for (const { outcome, budgetCompletionFault } of cases) { budgetCompletionFault === "before-commit" || budgetCompletionFault === "lost-and-canceled" ) { - await expect(running).rejects.toThrow(); + await expect(running).rejects.toBeInstanceOf( + ScanCostLimitExceededError, + ); expect( commands.filter( (command) => command === "complete-budget-exhausted-scan", @@ -463,6 +473,16 @@ for (const { outcome, budgetCompletionFault } of cases) { findingCount: 1, reportAvailable: true, }); + if (budgetCompletionFault === "lost-and-canceled") { + expect(commands).not.toContain("cancel-scan"); + expect( + await Promise.all( + ["report.md", "scan-manifest.json"].map((name) => + readFile(join(scanDir, name)), + ), + ), + ).toEqual(completedArtifacts); + } return; } const result = await running; From 56a9ff952ab514cdb1d27c9e01602eee849132db Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 08:21:54 +0000 Subject: [PATCH 092/251] fix(ci): resolve native proof helper dependencies from SDK --- .../native/proof-policy-windows.mts | 9 +- sdk/typescript/tests-ts/build-plugin.test.ts | 104 +++++++++++++++++- 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/native/proof-policy-windows.mts b/plugins/codex-security/native/proof-policy-windows.mts index 5ca186a398..65235e6afe 100644 --- a/plugins/codex-security/native/proof-policy-windows.mts +++ b/plugins/codex-security/native/proof-policy-windows.mts @@ -8,10 +8,11 @@ import { nativeTarget } from "./platform.mjs"; const testDirectory = join(output, "policy-proof"); const helper = join(testDirectory, "helpers.cjs"); if (process.argv[2] === "build") { + const sdkModules = join(root, "../../../sdk/typescript/node_modules"); execFileSync( process.execPath, [ - join(root, "../../../sdk/typescript/node_modules/esbuild/bin/esbuild"), + join(sdkModules, "esbuild/bin/esbuild"), join(root, "../mcp-app/helpers-main.ts"), "--bundle", "--platform=node", @@ -20,7 +21,11 @@ if (process.argv[2] === "build") { "--define:import.meta.url=__filename", `--outfile=${helper}`, ], - { stdio: "inherit" }, + { + stdio: "inherit", + // Native CI installs the helper's dependencies only in the SDK. + env: { ...process.env, NODE_PATH: sdkModules }, + }, ); const nativeDirectory = join(testDirectory, "native", nativeTarget); mkdirSync(nativeDirectory, { recursive: true }); diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index ecd012ee11..b32160aec5 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -1,19 +1,23 @@ import { execFile } from "node:child_process"; import { chmod, + copyFile, + cp, mkdir, mkdtemp, readFile, readdir, rm, stat, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { afterEach, describe, expect, test } from "bun:test"; +import { transform } from "esbuild"; import { buildBundledPlugin } from "../scripts/build-plugin.mjs"; import { assertGeneratedPluginUntracked } from "../scripts/check-plugin-source.mjs"; @@ -69,6 +73,104 @@ afterEach(async () => { }); describe("bundled plugin build", () => { + test("bundles the native policy proof with only SDK dependencies", async () => { + const root = await temporaryDirectory(); + const plugin = join(root, "plugins", "codex-security"); + const native = join(plugin, "native"); + const sdk = join(root, "sdk", "typescript"); + const source = new URL("../../../plugins/codex-security/", import.meta.url); + await mkdir(native, { recursive: true }); + await mkdir(sdk, { recursive: true }); + await symlink( + fileURLToPath(new URL("../node_modules", import.meta.url)), + join(sdk, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + for (const name of ["schemas", "mcp-app/src"]) { + await cp(new URL(name, source), join(plugin, name), { recursive: true }); + } + await copyFile( + new URL("mcp-app/helpers-main.ts", source), + join(plugin, "mcp-app", "helpers-main.ts"), + ); + for (const name of [ + "binding", + "platform", + "windows-binding", + "windows-flags", + "windows-files", + "proof-policy-windows", + ]) { + const compiled = await transform( + await readFile(new URL(`native/${name}.mts`, source), "utf8"), + { loader: "ts", format: "esm", target: "node20" }, + ); + await writeFile(join(native, `${name}.mjs`), compiled.code); + } + const { nativeTarget } = await import( + pathToFileURL(join(native, "platform.mjs")).href + ); + const binary = process.platform === "win32" ? "windows.node" : "unix.node"; + // The build copies this artifact; this portable test does not load native code. + await writeFixture( + native, + `dist/${nativeTarget}/${binary}`, + "native fixture", + ); + await expect( + stat(join(plugin, "mcp-app", "node_modules")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + await execFileAsync( + "node", + [join(native, "proof-policy-windows.mjs"), "build"], + { + cwd: native, + env: { ...process.env, NODE_PATH: "" }, + }, + ); + const proof = join(native, "dist", nativeTarget, "policy-proof"); + expect( + await readFile( + join(proof, "native", nativeTarget, "windows.node"), + "utf8", + ), + ).toBe("native fixture"); + const helper = join(root, "helpers.cjs"); + await copyFile(join(proof, "helpers.cjs"), helper); + await rm(join(sdk, "node_modules"), { force: true }); + const result = await execFileAsync( + "node", + [ + "--eval", + ` + const assert = require("node:assert/strict"); + const helper = require(process.argv.pop()); + const input = { + scanId: "synthetic-scan", + manifest: { scan: {} }, + findings: { findings: [] }, + coverage: { + completeness: "complete", surfaces: [], explicitExclusions: [], deferred: [], + }, + }; + assert.equal(helper.parseCanonicalScanDraft(input).scanId, input.scanId); + assert.throws(() => helper.parseCanonicalScanDraft({ + ...input, coverage: { ...input.coverage, completeness: "invalid" }, + })); + console.log("Bundled parser accepted valid input and rejected invalid coverage."); + `, + helper, + ], + { cwd: root, env: { ...process.env, NODE_PATH: "" } }, + ); + expect(result.stdout).toBe( + "Bundled parser accepted valid input and rejected invalid coverage.\n", + ); + expect(result.stderr).toBe(""); + }); + test("builds the MCP runtime without invoking an npm launcher", async () => { const root = await temporaryDirectory(); const bin = join(root, "bin"); From 6e01a591a5b652848da618442aec139ad8e194ce Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 08:46:42 +0000 Subject: [PATCH 093/251] Preserve native usage coverage across null token counters --- .../scripts/workbench_scan_usage.py | 3 + .../tests/test_workbench_scan_usage.py | 82 ++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 1fb1d7e0a0..2abbf45b34 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -658,6 +658,9 @@ def _read_rollout_usage( continue if event.get("type") != "event_msg" or payload.get("type") != "token_count": continue + # Native rate-limit updates can carry no token usage. + if "info" in payload and payload["info"] is None: + continue timestamp = _timestamp(event.get("timestamp")) snapshot = _token_snapshot(payload) if timestamp is None or snapshot is None: diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index de46cd405d..81ed88337f 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -492,21 +492,29 @@ def test_completion_reports_unavailable_without_fabricating_zero(tmp_path: Path) assert "totalTokens" not in usage -@pytest.mark.parametrize("reported", [False, True], ids=["missing", "explicit-zero"]) +@pytest.mark.parametrize("reported", ["missing", "null-counter", "explicit-zero"]) def test_completion_distinguishes_missing_token_records_from_zero( - tmp_path: Path, reported: bool + tmp_path: Path, reported: str ) -> None: fixture = _start_scan(tmp_path) counted = fixture.started_at + timedelta(microseconds=1) - parent = _rollout(tmp_path, "scan-parent", [_token_event(counted, 0, 0)] if reported else []) + events = [] + if reported == "explicit-zero": + events.append(_token_event(counted, 0, 0)) + elif reported == "null-counter": + events.append( + _event(counted, "event_msg", {"type": "token_count", "info": None, "rate_limits": None}) + ) + parent = _rollout(tmp_path, "scan-parent", events) _state_graph(fixture.environment, {"scan-parent": parent}, []) usage = _complete_scan(fixture)["scan"]["usage"] - if reported: + if reported == "explicit-zero": assert usage["coverage"] == "complete" assert usage["totalTokens"] == 0 else: assert usage["coverage"] == "unavailable" assert "token_usage_unavailable" in usage["warnings"] + assert "token_record_invalid" not in usage["warnings"] assert "totalTokens" not in usage @@ -776,6 +784,72 @@ def test_shared_parent_usage_requires_original_turn_and_scan_interval( assert models == {"gpt-6-astra": _counts(10, 0, 2)} +@pytest.mark.parametrize( + "counter_info,receipt_state,expected_warning", + [ + (None, "complete", None), + ({}, "complete", "token_record_invalid"), + ({"total_token_usage": {"input_tokens": -1}}, "complete", "token_record_invalid"), + (None, "missing-response", "token_receipts_incomplete"), + (None, "incomplete-line", "rollout_record_incomplete"), + (None, "invalid-timestamp", "token_record_invalid"), + ], + ids=[ + "null-counter", + "empty-info", + "malformed-usage", + "missing-response", + "incomplete-line", + "invalid-timestamp", + ], +) +def test_completion_handles_no_usage_counter_without_hiding_incomplete_receipts( + tmp_path: Path, counter_info: Any, receipt_state: str, expected_warning: str | None +) -> None: + fixture = _start_scan(tmp_path) + counted = fixture.started_at + timedelta(microseconds=1) + tokens = dict(input_tokens=100, cached_input_tokens=20, output_tokens=10, total_tokens=110) + response = _event( + counted, + "token_usage_record", + dict( + response_id="response-one", + thread_id="scan-parent", + model="gpt-5.6-sol", + usage=tokens, + thread_token_usage=( + {**tokens, "input_tokens": 150, "total_tokens": 160} + if receipt_state == "missing-response" + else tokens + ), + ), + ) + counter = _event( + counted, + "event_msg", + {"type": "token_count", "info": counter_info, "rate_limits": None}, + ) + events = [counter, response, counter] + if receipt_state == "invalid-timestamp": + events.append( + { + **response, + "timestamp": None, + "payload": {**response["payload"], "response_id": "response-two"}, + } + ) + parent = _rollout(tmp_path, "scan-parent", events) + if receipt_state == "incomplete-line": + with parent.open("a") as stream: + stream.write('{"type":"token_usage_record"') + _state_graph(fixture.environment, {"scan-parent": parent}, []) + usage = _complete_scan(fixture)["scan"]["usage"] + assert usage["totalTokens"] == 110 + assert usage["modelUsage"] == [{"model": "gpt-5.6-sol", **_counts(100, 20, 10)}] + assert usage["coverage"] == ("partial" if expected_warning else "complete") + assert usage.get("warnings", []) == ([expected_warning] if expected_warning else []) + + @pytest.mark.parametrize("counters", [False, True]) def test_response_receipts_count_compaction_once_across_resets( tmp_path: Path, workbench_api, counters: bool From db82e9689de144b522841d6f828f70bcd85af89a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:06:52 +0000 Subject: [PATCH 094/251] test(sdk): match native Windows child executable paths --- sdk/typescript/tests-ts/api.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index d8910cf3a8..0e31f97add 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -7433,7 +7433,11 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(children[1].args).toContain("resume"); expect(children[1].args).toContain(`fixture-${name}-thread`); for (const child of children) { - expect(child.executable).toBe(fake.command.command); + expect(child.executable).toBe( + process.platform === "win32" + ? win32.toNamespacedPath(fake.command.command) + : fake.command.command, + ); expect(child.home).toBe(codexHome); expect(child.key).toBe(`synthetic-${name}-key`); expect(child.value).toBe(name); From 01fab0e8cd8d322cb5ab2e3807f3142af1177f4e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 08:50:21 +0000 Subject: [PATCH 095/251] fix(deep-scan): ignore native compatibility summaries --- .../src/deep-scan/recovery-settings.ts | 17 +++++++--- .../test_deep_scan_recovery_settings.mjs | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index e5ff33d3ec..6477e30c17 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -70,8 +70,8 @@ async function originalParentSettings( codexHome: string, parent: { threadId: string; turnId?: string | null; startedAt?: string } ): Promise> { - // Native config/read represents omitted selections as null. The existing - // parent record contains the provider and summary actually used by that turn. + // Native config/read represents omitted selections as null. Recover recorded + // selections from the original parent; some native records omit the summary. // History can be disabled or unavailable; configured selections still work. try { const log = await readScanLogs({ @@ -80,6 +80,7 @@ async function originalParentSettings( }); const settings: Partial = {}; let applied: Partial | undefined; + let summaryIsCompatibilityOnly = false; const cutoff = parent.startedAt === undefined ? Infinity : Date.parse(parent.startedAt); for (const entry of log.events) { const event = entry.event as Record; @@ -105,14 +106,20 @@ async function originalParentSettings( ...(value.service_tier === undefined ? { nativeServiceTierAbsent: true as const } : {}) }; } - if (event.type === "session_meta" && typeof context.model_provider === "string") { - settings.modelProvider = context.model_provider; + if (event.type === "session_meta") { + if (typeof context.model_provider === "string") settings.modelProvider = context.model_provider; + // Codex 0.133 replaced turn_context.summary with a compatibility default. + // Fresh threads need not have a thread_settings_applied record to replace it. + const version = typeof context.cli_version === "string" + ? /^(\d+)\.(\d+)\./u.exec(context.cli_version) : null; + summaryIsCompatibilityOnly = version !== null + && (Number(version[1]) > 0 || Number(version[2]) >= 133); } if (event.type === "turn_context") { if (parent.turnId && context.turn_id !== parent.turnId) continue; if (typeof context.model === "string") settings.model = context.model; if (typeof context.effort === "string") settings.reasoningEffort = context.effort; - if (typeof context.summary === "string") settings.reasoningSummary = context.summary; + if (!summaryIsCompatibilityOnly && typeof context.summary === "string") settings.reasoningSummary = context.summary; } } // Applied snapshots contain native selected values. Newer turn-context diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index ee4fef893c..75b5e4b828 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -129,6 +129,37 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(unboundLegacy.model, "stored-model"); assert.equal(unboundLegacy.modelProvider, undefined, "unrecorded legacy ownership cannot recover caller selections"); assert.equal(unboundLegacy.reasoningSummary, undefined); + await writeFile(join(sessionDirectory, "legacy-auto.jsonl"), [ + { type: "session_meta", payload: { id: "fixture-legacy-auto", cli_version: "0.132.0", model_provider: "openai" } }, + { type: "turn_context", payload: { turn_id: "legacy-turn", model: "legacy-model", effort: "high", summary: "auto" } } + ].map(JSON.stringify).join("\n") + "\n"); + const legacyAuto = await captureSettings({ usageOwner: { threadId: "fixture-legacy-auto", turnId: "legacy-turn" } }, + { filesystemDenies: [] }, parentEnvironment); + assert.equal(legacyAuto.reasoningSummary, "auto", "older native turn-context selections remain readable"); + for (const version of ["0.133.0", "0.154.0"]) { + const threadId = `fixture-fresh-${version}`; + await writeFile(join(sessionDirectory, `${threadId}.jsonl`), [ + { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: threadId, cli_version: version, model_provider: "openai" } }, + { type: "turn_context", timestamp: "2026-01-01T00:00:01Z", payload: { turn_id: "fresh-turn", model: "fresh-model", effort: "high", summary: "auto" } }, + { type: "event_msg", timestamp: "2026-01-01T00:02:00Z", payload: { type: "thread_settings_applied", thread_id: threadId, + thread_settings: { model: "fresh-model", model_provider_id: "openai", reasoning_summary: "detailed" } } } + ].map(JSON.stringify).join("\n") + "\n"); + const owner = { threadId, turnId: "fresh-turn", startedAt: "2026-01-01T00:01:00Z" }; + const fresh = await captureSettings({ usageOwner: owner }, { filesystemDenies: [] }, parentEnvironment); + assert.equal(fresh.model, "fresh-model"); + assert.equal(fresh.reasoningSummary, undefined, "fresh native compatibility auto is not an original selection"); + assert.equal(restoreSettings(fresh, { filesystemDenies: [] }).codexOptions.config.model_reasoning_summary, undefined); + const freshDir = join(root, threadId); + await loadSettings(freshDir, async () => fresh); + const freshPath = join(freshDir, "artifacts", "deep_discovery", "execution-settings.json"); + const freshBytes = await readFile(freshPath, "utf8"); + assert.deepEqual(await loadSettings(freshDir, async () => assert.fail(), { usageOwner: owner, createdAt: owner.startedAt }), fresh); + assert.equal(await readFile(freshPath, "utf8"), freshBytes, "unknown summary is not replaced by a compatibility field or a later selection"); + await writeFile(join(root, "config.toml"), 'model_reasoning_summary = "auto"\n'); + const explicit = await captureSettings({ usageOwner: owner }, { filesystemDenies: [] }, parentEnvironment); + assert.equal(explicit.reasoningSummary, "auto", "an explicit original config selection still takes precedence"); + await writeFile(join(root, "config.toml"), ""); + } await writeFile(join(sessionDirectory, "applied.jsonl"), [ { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: "fixture-applied", model_provider: "previous-provider" } }, { type: "event_msg", timestamp: "2026-01-01T00:00:01Z", payload: { type: "thread_settings_applied", thread_id: "fixture-applied", From 696cdd119306ed9bdf5f67388974141a08f4ff7d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:31:37 +0000 Subject: [PATCH 096/251] fix: cancel selected scans without a status read --- sdk/typescript/src/api.ts | 24 ++++------- .../tests-ts/deep-finalization.test.ts | 41 +++++++++++++++++-- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 4e88ab862f..3b110f68e8 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2480,25 +2480,15 @@ export class CodexSecurity { const deep = saved?.["deepScan"]; if (isRecord(deep) && isRecord(deep["finalizationInput"])) { selectedDeepFinalization = true; - const current = await workbench(workbenchOptions, [ - "get-scan", + // The workbench owns the running-state check and repeated cancellation. + // A lost cleanup response must preserve the original interruption. + await workbench(workbenchOptions, [ + "cancel-scan", "--scan-id", activeScan.id, - ]).catch(() => null); - const scan = current?.["scan"]; - if ( - isRecord(scan) && - isRecord(scan["progress"]) && - scan["progress"]["status"] === "running" - ) { - await workbench(workbenchOptions, [ - "cancel-scan", - "--scan-id", - activeScan.id, - "--thread-id", - observedScanThreadId, - ]).catch(() => undefined); - } + "--thread-id", + observedScanThreadId, + ]).catch(() => undefined); } } // Publication failures remain resumable. A cost stop or explicit client close diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 6e6905b773..d52947088e 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -44,6 +44,7 @@ type BudgetCompletionFault = "lost" | "before-commit" | "lost-and-canceled"; const cases: { outcome: (typeof outcomes)[number]; budgetCompletionFault?: BudgetCompletionFault; + cancellationFault?: "status-read" | "cancel-response"; }[] = [ ...outcomes.map((outcome) => ({ outcome })), ...( @@ -61,8 +62,16 @@ const cases: { outcome: "budget-after-deep-finish", budgetCompletionFault: "lost-and-canceled", }, + { + outcome: "canceled-during-publication", + cancellationFault: "status-read", + }, + { + outcome: "canceled-during-publication", + cancellationFault: "cancel-response", + }, ]; -for (const { outcome, budgetCompletionFault } of cases) { +for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { const resumedStop = outcome.includes("-resumed-"); const restart = outcome === "restart" || resumedStop; const closed = outcome.startsWith("closed-"); @@ -72,7 +81,7 @@ for (const { outcome, budgetCompletionFault } of cases) { const name = outcome === "followup-canceled" ? "SDK preserves a selected aggregate when its follow-up is canceled" - : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}`; + : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}`; const runCase = async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -138,6 +147,13 @@ for (const { outcome, budgetCompletionFault } of cases) { runWorkbench: async (options, args, input) => { workbenchOptions = options; commands.push(args[0]!); + if ( + args[0] === "get-scan" && + cancellation.signal.aborted && + cancellationFault === "status-read" + ) { + throw new Error("Synthetic lost cancellation status response"); + } if (args[0] === "write-scan-draft" && publicationFails) { publicationFails = false; throw new Error("Synthetic publication write failure"); @@ -155,6 +171,17 @@ for (const { outcome, budgetCompletionFault } of cases) { "Synthetic budget completion failure before commit", ); const result = await runWorkbench(options, args, input); + if ( + args[0] === "cancel-scan" && + cancellationFault === "cancel-response" + ) { + completedArtifacts = await Promise.all( + ["report.md", "scan-manifest.json"].map((name) => + readFile(join(scanDir, name)), + ), + ); + throw new Error("Synthetic lost cancellation response"); + } if ( args[0] === "complete-budget-exhausted-scan" && budgetReceiptLost @@ -474,7 +501,6 @@ for (const { outcome, budgetCompletionFault } of cases) { reportAvailable: true, }); if (budgetCompletionFault === "lost-and-canceled") { - expect(commands).not.toContain("cancel-scan"); expect( await Promise.all( ["report.md", "scan-manifest.json"].map((name) => @@ -600,6 +626,15 @@ for (const { outcome, budgetCompletionFault } of cases) { expect(commands).toContain("cancel-scan"); expect(commands).not.toContain("fail-scan"); expect(modelInputs.length).toBe(1); + if (cancellationFault === "cancel-response") { + expect( + await Promise.all( + ["report.md", "scan-manifest.json"].map((name) => + readFile(join(scanDir, name)), + ), + ), + ).toEqual(completedArtifacts); + } await expect( client.run(repository, { mode: "deep", From 38ba26d827099b422c6b38a79e9aa908d6690565 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:13:00 +0000 Subject: [PATCH 097/251] test: resolve shared SDK imports from MCP dependencies --- plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs | 1 + .../mcp-app/tests/test_artifact_storage_regressions.mjs | 1 + .../mcp-app/tests/test_compact_artifact_server.mjs | 1 + .../codex-security/mcp-app/tests/test_deep_scan_executor.mjs | 1 + .../mcp-app/tests/test_deep_scan_recovery_settings.mjs | 2 ++ .../mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs | 1 + .../mcp-app/tests/test_workbench_state_fallback.mjs | 1 + 7 files changed, 8 insertions(+) diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs index 0e5b33efd1..3a30099554 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_storage.mjs @@ -21,6 +21,7 @@ try { await writeFile(path.join(repository, "example.py"), "value = 1\n"); await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": "__filename" }, entryPoints: [path.join(applicationRoot, "main.ts")], external: ["fsevents"], diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_storage_regressions.mjs b/plugins/codex-security/mcp-app/tests/test_artifact_storage_regressions.mjs index 3ce7259448..53059a4596 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_storage_regressions.mjs +++ b/plugins/codex-security/mcp-app/tests/test_artifact_storage_regressions.mjs @@ -29,6 +29,7 @@ await fs.mkdir(repository); await fs.writeFile(path.join(repository, "example.py"), "value = 1\n"); await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": "__filename" }, entryPoints: [path.join(applicationRoot, "main.ts")], external: ["fsevents"], format: "cjs", loader: { ".md": "text" }, diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs index aa7937bd5f..af2138c5b7 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs @@ -1229,6 +1229,7 @@ async function testReducerWorkerToolList(bundle) { async function bundleEntrypoint(entrypoint, outfile) { await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": "__filename" diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 6c3a92cb10..369e9da478 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -10,6 +10,7 @@ import { build } from "esbuild"; const executorSource = new URL("../src/deep-scan/executor.ts", import.meta.url); const bundle = await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { "import.meta.url": JSON.stringify(executorSource.href) }, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 75b5e4b828..6a06127971 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -2,10 +2,12 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { build } from "esbuild"; const bundle = await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { "import.meta.url": JSON.stringify(new URL("../src/deep-scan/recovery-settings.ts", import.meta.url).href) }, entryPoints: [new URL("../src/deep-scan/recovery-settings.ts", import.meta.url).pathname], format: "esm", diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index dac6ff9ad3..e5e5407589 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -613,6 +613,7 @@ async function testDeepScanStdioLifecycle() { async function bundleServer(outfile) { await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { "import.meta.url": "__filename" }, entryPoints: [path.join(mcpAppRoot, "main.ts")], external: ["fsevents"], diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.mjs b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.mjs index e6cea4f54c..960a30d0da 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.mjs +++ b/plugins/codex-security/mcp-app/tests/test_workbench_state_fallback.mjs @@ -29,6 +29,7 @@ async function testWorkbenchStateFallback() { await writeFakePython(fakePythonPath); await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], define: { "import.meta.url": "__filename" }, entryPoints: [path.join(mcpAppRoot, "main.ts")], external: ["fsevents"], From c846b91b20ee9cea6cbcb761b470de8f77b0e3c3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:16:22 +0000 Subject: [PATCH 098/251] test: detach proof dependency junction with Node --- sdk/typescript/tests-ts/build-plugin.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index b32160aec5..1e70ee07fa 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -139,7 +139,14 @@ describe("bundled plugin build", () => { ).toBe("native fixture"); const helper = join(root, "helpers.cjs"); await copyFile(join(proof, "helpers.cjs"), helper); - await rm(join(sdk, "node_modules"), { force: true }); + await execFileAsync("node", [ + "--eval", + "require('node:fs').unlinkSync(process.argv[1])", + join(sdk, "node_modules"), + ]); + await expect(stat(join(sdk, "node_modules"))).rejects.toMatchObject({ + code: "ENOENT", + }); const result = await execFileAsync( "node", [ From 7d640dffbf7cb388772bcff60a0768415f33942f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:16:00 +0000 Subject: [PATCH 099/251] fix: reject unsupported stopped scan publication Apply the existing Deep workflow and selection compatibility check before the shared stopped-result publisher writes canonical output. Cover preservation and explicit recovery with real persisted state, rejected-version immutability, and supported controls. --- .../scripts/workbench_saved_results.py | 4 +- .../test_stopped_result_version_boundary.py | 112 ++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 plugins/codex-security/tests/test_stopped_result_version_boundary.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 2a6321ddbe..175e3b3045 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1295,8 +1295,10 @@ def preserve_scan_results_locked( checkpoint_heads = json.loads(scan["retained_checkpoint_heads_json"] or "{}") scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) deep_run = connection.execute( - "SELECT status FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) ).fetchone() + if deep_run is not None: + db.deep_scan.require_supported_deep_scan(deep_run) outcome = ( "canceled" if scan["canceled_at"] diff --git a/plugins/codex-security/tests/test_stopped_result_version_boundary.py b/plugins/codex-security/tests/test_stopped_result_version_boundary.py new file mode 100644 index 0000000000..50e42749b7 --- /dev/null +++ b/plugins/codex-security/tests/test_stopped_result_version_boundary.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import hashlib +import json +from argparse import Namespace + +import pytest +from test_checkpoint_publication_authority import save_disposition +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan + + +def snapshot(connection, scan_dir): + return { + "database": "\n".join(connection.iterdump()), + "files": { + path.relative_to(scan_dir).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in scan_dir.rglob("*") + if path.is_file() + }, + } + + +@pytest.mark.parametrize("operation", ["preserve", "recover"]) +@pytest.mark.parametrize("protocol", ["supported", "future-workflow", "future-selection"]) +def test_stopped_result_publication_requires_supported_protocol( + workbench_api, workbench_db, publication_scan, monkeypatch, operation, protocol +): + scan = publication_scan() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + result = add_worker(workbench_db, scan, status="canceled") + draft = save_disposition(scan, result.parent, "reported") + result.write_text(json.dumps(draft)) + + def interrupt_publication(*args, **kwargs): + raise OSError("Synthetic publication interruption") + + with monkeypatch.context() as patch: + patch.setattr( + workbench_api["saved_results"], + "_write_prepared_scan_finalization", + interrupt_publication, + ) + workbench_api["fail_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, + claim_token=None, + cost_json=None, + message="Original worker stop.", + ), + ) + row = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + assert row["status"] == "failed" + assert row["retained_source_digests_json"] + assert row["seal_manifest_digest"] is None + + with workbench_db: + if protocol == "future-workflow": + workbench_db.execute( + "UPDATE deep_scan_runs SET workflow_version = 'future/v99' WHERE scan_id = ?", + (scan.scan_id,), + ) + elif protocol == "future-selection": + workbench_db.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "finalization_input_json = ? WHERE scan_id = ?", + (json.dumps({"version": 99}), scan.scan_id), + ) + before = snapshot(workbench_db, scan.scan_dir) + error = None + try: + if operation == "preserve": + workbench_api["preserve_scan_results"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, + claim_token=None, + thread_id=None, + coordinator_generation=None, + ), + ) + else: + workbench_api["recover_scan_results"](workbench_db, Namespace(scan_id=scan.scan_id)) + except SystemExit as failure: + error = str(failure) + after = snapshot(workbench_db, scan.scan_dir) + changed_files = sorted( + path + for path in before["files"].keys() | after["files"].keys() + if before["files"].get(path) != after["files"].get(path) + ) + print( + json.dumps( + { + "operation": operation, + "protocol": protocol, + "error": error, + "database_changed": before["database"] != after["database"], + "changed_files": changed_files, + } + ) + ) + if protocol == "supported": + assert error is None + assert before != after + row = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + assert row["seal_manifest_digest"] + assert row["failure_message"] == "Original worker stop." + else: + assert error is not None and "unsupported" in error.lower() + assert after == before From 946c6e2742510ce694200dec8a7aecd338a79810 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:30:07 +0000 Subject: [PATCH 100/251] test: cover supported stopped publication workflows --- .../test_stopped_result_version_boundary.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/tests/test_stopped_result_version_boundary.py b/plugins/codex-security/tests/test_stopped_result_version_boundary.py index 50e42749b7..f31e5c0ee6 100644 --- a/plugins/codex-security/tests/test_stopped_result_version_boundary.py +++ b/plugins/codex-security/tests/test_stopped_result_version_boundary.py @@ -22,11 +22,25 @@ def snapshot(connection, scan_dir): @pytest.mark.parametrize("operation", ["preserve", "recover"]) -@pytest.mark.parametrize("protocol", ["supported", "future-workflow", "future-selection"]) +@pytest.mark.parametrize( + "protocol", + ["supported", "supported-v2", "supported-mcp-v1", "future-workflow", "future-selection"], +) def test_stopped_result_publication_requires_supported_protocol( workbench_api, workbench_db, publication_scan, monkeypatch, operation, protocol ): scan = publication_scan() + supported_workflows = { + "supported": "deep-security-scan/v1", + "supported-v2": "deep-security-scan/v2", + "supported-mcp-v1": "deep-scan-mcp/v1", + } + if protocol in supported_workflows: + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET workflow_version = ? WHERE scan_id = ?", + (supported_workflows[protocol], scan.scan_id), + ) (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) result = add_worker(workbench_db, scan, status="canceled") draft = save_disposition(scan, result.parent, "reported") @@ -101,12 +115,16 @@ def interrupt_publication(*args, **kwargs): } ) ) - if protocol == "supported": + if protocol in supported_workflows: assert error is None assert before != after row = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() assert row["seal_manifest_digest"] assert row["failure_message"] == "Original worker stop." + run = workbench_db.execute( + "SELECT workflow_version FROM deep_scan_runs WHERE scan_id = ?", (scan.scan_id,) + ).fetchone() + assert run["workflow_version"] == supported_workflows[protocol] else: assert error is not None and "unsupported" in error.lower() assert after == before From 55f98bf9b6607c743fc7d885ebc50861e5030aa5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:31:57 +0000 Subject: [PATCH 101/251] fix(deep-scan): capture original summary before owner launch --- sdk/typescript/src/api.ts | 59 +++++++++++-- sdk/typescript/src/reasoning-summary.ts | 69 ++++++++++++++++ sdk/typescript/src/runtime.ts | 2 + sdk/typescript/tests-ts/api.test.ts | 52 ++++++++++-- .../tests-ts/reasoning-summary.test.ts | 82 +++++++++++++++++++ 5 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 sdk/typescript/src/reasoning-summary.ts create mode 100644 sdk/typescript/tests-ts/reasoning-summary.test.ts diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 3b110f68e8..66097fe24c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1,6 +1,7 @@ /// import { scanPreflightCodexConfig } from "./preflight-config.js"; +import { captureOriginalReasoningSummary } from "./reasoning-summary.js"; export { scanPreflightCodexConfig } from "./preflight-config.js"; import { resumeSelectedDeepScan } from "./deep-scan-finalization.js"; import { @@ -1297,13 +1298,12 @@ export class CodexSecurity { const { runtime, runtimeHome, - effectiveConfig, - preflightConfig, modelProvider, authentication, approvalPolicy, python, } = session; + let { effectiveConfig, preflightConfig } = session; releaseCredentialHome = session.releaseCredentialHome; const deepScanConfigPath = mode === "deep" @@ -1362,6 +1362,38 @@ export class CodexSecurity { ); checkOpen(); + if (mode === "deep" && options.resumeScanId === undefined) { + const summary = await captureOriginalReasoningSummary({ + config: session.sessionConfig, + command: this.#codexCommand(), + cwd: scanDir, + environment: { + ...withoutOpenAiApiKeys( + this.#createSessionEnvironment(session, {}, options.auth), + ), + ...(session.externalProvider === null && session.apiKey !== null + ? { CODEX_API_KEY: session.apiKey } + : {}), + }, + signal, + }); + if (summary !== undefined) { + effectiveConfig = { + ...effectiveConfig, + model_reasoning_summary: summary, + }; + preflightConfig = scanPreflightCodexConfig(effectiveConfig); + session.effectiveConfig = effectiveConfig; + session.preflightConfig = preflightConfig; + session.sessionConfig = { + ...session.sessionConfig, + model_reasoning_summary: summary, + }; + if (runtime.configPath !== undefined) + await writeCodexConfig(runtime.configPath, preflightConfig); + } + } + const shellPluginRoot = runtime.plugin.pluginRoot; const canonicalShellPluginRoot = await realpath(shellPluginRoot); const pluginRelativeToHome = relative( @@ -2773,13 +2805,11 @@ export class CodexSecurity { } } - #createSessionCodex( + #createSessionEnvironment( session: PreparedSession, runtimePaths: Record, auth: ScanAuthMode = "auto", - config?: JsonObject, - configOverrides: string[] = [], - ): { codex: CodexClientLike; environment: ProcessEnvironment } { + ): ProcessEnvironment { const { runtime, python, @@ -2815,6 +2845,23 @@ export class CodexSecurity { if (session.safetyIdentifier !== undefined) { environment[SAFETY_IDENTIFIER_ENV] = session.safetyIdentifier; } + return environment; + } + + #createSessionCodex( + session: PreparedSession, + runtimePaths: Record, + auth: ScanAuthMode = "auto", + config?: JsonObject, + configOverrides: string[] = [], + ): { codex: CodexClientLike; environment: ProcessEnvironment } { + const { externalProvider, apiKey, sessionConfig } = session; + const commandAuth = hasCommandAuth(sessionConfig); + const environment = this.#createSessionEnvironment( + session, + runtimePaths, + auth, + ); const sdkCodexConfig = { ...(config ?? sessionConfig) }; // Projects and permissions already live in generated TOML files; the SDK // cannot safely encode their path and selector keys as dotted overrides. diff --git a/sdk/typescript/src/reasoning-summary.ts b/sdk/typescript/src/reasoning-summary.ts new file mode 100644 index 0000000000..a313bc00c4 --- /dev/null +++ b/sdk/typescript/src/reasoning-summary.ts @@ -0,0 +1,69 @@ +import { + inlineToml, + resolveCodexProfile, + scanModelConfiguration, + type JsonObject, +} from "./config.js"; +import { scanPreflightCodexConfig } from "./preflight-config.js"; +import { + runCodexCommand, + type CodexCommand, + type ProcessEnvironment, +} from "./runtime.js"; + +/** Capture a future owner's selected model default; never infer historical settings. */ +export async function captureOriginalReasoningSummary(options: { + config: JsonObject; + command: CodexCommand; + cwd: string; + environment: ProcessEnvironment; + signal: AbortSignal; +}): Promise { + // Keep explicit selections and invalid values with their existing native validator. + if ( + scanPreflightCodexConfig(options.config)["model_reasoning_summary"] !== + undefined || + resolveCodexProfile(options.config)["model_reasoning_summary"] !== undefined + ) + return undefined; + const { model } = scanModelConfiguration(options.config); + // The same per-session overrides protect the lookup from concurrent home edits. + const config = JSON.parse(JSON.stringify(options.config)) as JsonObject; + const args = [ + "debug", + "models", + ...Object.entries(config).flatMap(([key, value]) => [ + "--config", + `${key}=${inlineToml(value)}`, + ]), + ]; + const result = await runCodexCommand( + options.command, + args, + options.environment, + undefined, + options.signal, + options.cwd, + ); + // Older native executables and models absent from their catalog provide no + // recoverable value. Preserve omission instead of choosing another default. + if (!result.success) return undefined; + let catalog: unknown; + try { + catalog = JSON.parse(result.stdout); + } catch { + return undefined; + } + if (!isRecord(catalog) || !Array.isArray(catalog["models"])) return undefined; + const selected = catalog["models"].find( + (entry: unknown) => isRecord(entry) && entry["slug"] === model, + ); + return isRecord(selected) && + typeof selected["default_reasoning_summary"] === "string" + ? selected["default_reasoning_summary"] + : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 8d2d2a442b..f85477f8a5 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -2702,12 +2702,14 @@ export async function runCodexCommand( environment: ProcessEnvironment, input?: string | Uint8Array, signal?: AbortSignal, + cwd?: string, ): Promise { const child = spawn(executablePathForSpawn(command.command), [...args], { env: environment, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, signal, + ...(cwd === undefined ? {} : { cwd }), }); let stdout = ""; let stderr = ""; diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 0e31f97add..e3356ec4bb 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -7302,9 +7302,13 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(scanSignal?.aborted).toBe(false); }); - test.each(["standard", "deep"] as const)( - "isolates concurrent managed %s sessions at the Codex child boundary", - async (mode) => { + test.each([ + ["standard", false], + ["deep", false], + ["deep", true], + ] as const)( + "isolates concurrent managed %s sessions at the Codex child boundary (capture=%s)", + async (mode, captureSummary) => { const clients: TestClient[] = []; try { const outcomes = await Promise.allSettled( @@ -7320,12 +7324,17 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s mkdir(codexHome), mkdir(scanDir, { mode: 0o700 }), ]); + const model = `fixture-${name}-model`; + const summary = name === "first" ? "none" : "concise"; + const configPath = join(codexHome, "config-preflight.toml"); + let recipeConfig: JsonObject | undefined; await writeFile( preload, [ 'import { appendFileSync } from "node:fs";', 'let prompt = ""; for await (const chunk of process.stdin) prompt += chunk;', - `appendFileSync(${JSON.stringify(marker)}, JSON.stringify({args:process.argv, executable:process.execPath, home:process.env.CODEX_HOME, key:process.env.CODEX_API_KEY, value:process.env.FIXTURE_SCAN_VALUE, prompt}) + "\\n");`, + `appendFileSync(${JSON.stringify(marker)}, JSON.stringify({args:process.argv, executable:process.execPath, cwd:process.cwd(), home:process.env.CODEX_HOME, key:process.env.CODEX_API_KEY, value:process.env.FIXTURE_SCAN_VALUE, prompt}) + "\\n");`, + `if (process.argv.includes("models")) { console.log(JSON.stringify({models:[{slug:${JSON.stringify(model)},default_reasoning_summary:${JSON.stringify(summary)}}]})); process.exit(0); }`, `console.log(JSON.stringify({type:"thread.started",thread_id:${JSON.stringify(`fixture-${name}-thread`)}}));`, 'console.log(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"scan complete"}}));', 'console.log(JSON.stringify({type:"turn.completed",usage:null}));', @@ -7333,7 +7342,6 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s ].join("\n"), ); const fake = nodeCodex(preload); - const model = `fixture-${name}-model`; const provider = `fixture-${name}-provider`; const client = new TestClient( { @@ -7341,8 +7349,10 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s model, model_provider: provider, model_reasoning_effort: "ultra", - model_reasoning_summary: - name === "first" ? "none" : "concise", + // JavaScript callers can omit the merged default with undefined. + model_reasoning_summary: captureSummary + ? (undefined as unknown as string) + : summary, service_tier: name === "first" ? "flex" : "fast", features: { multi_agent_v2: { max_concurrent_threads_per_session: 4 }, @@ -7356,6 +7366,7 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s }, prepareRuntime: async () => ({ ...preparedRuntime(codexHome), + configPath, environment: { ...fake.environment, FIXTURE_SCAN_VALUE: name, @@ -7364,6 +7375,11 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s resolvePluginPython: async () => "/managed/python", prepareOutputDir: async () => scanDir, repositoryRevision: async () => "deadbeef", + runWorkbench: async (_options, args, input) => { + if (args[0] === "register-cli-scan") + recipeConfig = JSON.parse(input!).recipe.config; + return mockWorkbench(args, input); + }, createCodex: (options: CodexOptions) => { const codex = new Codex(options); return { @@ -7424,10 +7440,30 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s }); expect(result.threadId).toBe(`fixture-${name}-thread`); expect(result.turnResult.usage).toBeNull(); - const children = (await readFile(marker, "utf8")) + const invocations = (await readFile(marker, "utf8")) .trim() .split("\n") .map((line) => JSON.parse(line)); + const lookups = invocations.filter((child) => + child.args.includes("models"), + ); + const children = invocations.filter( + (child) => !child.args.includes("models"), + ); + expect(recipeConfig?.["model_reasoning_summary"]).toBe(summary); + expect(lookups).toHaveLength(captureSummary ? 1 : 0); + for (const lookup of lookups) { + expect(await realpath(lookup.cwd)).toBe(await realpath(scanDir)); + expect(lookup.home).toBe(codexHome); + expect(lookup.key).toBe(`synthetic-${name}-key`); + expect(lookup.value).toBe(name); + expect(lookup.args).toContain(`model=${JSON.stringify(model)}`); + } + const preflight = await readFile(configPath, "utf8"); + expect(parseToml(preflight)["model_reasoning_summary"]).toBe( + summary, + ); + expect(preflight).not.toContain(`synthetic-${name}-key`); expect(children).toHaveLength(2); expect(children[1].prompt).toBe(postScanPrompt); expect(children[1].args).toContain("resume"); diff --git a/sdk/typescript/tests-ts/reasoning-summary.test.ts b/sdk/typescript/tests-ts/reasoning-summary.test.ts new file mode 100644 index 0000000000..854ee85ead --- /dev/null +++ b/sdk/typescript/tests-ts/reasoning-summary.test.ts @@ -0,0 +1,82 @@ +import { execFileSync } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, test } from "bun:test"; +import { captureOriginalReasoningSummary } from "../src/reasoning-summary.js"; +import type { JsonObject } from "../src/config.js"; + +test.each([ + { model_reasoning_summary: "none" }, + { model_reasoning_summary: "concise" }, + { model_reasoning_summary: null }, + { model_reasoning_summary: "" }, + { + profile: "selected", + profiles: { selected: { model_reasoning_summary: "auto" } }, + }, +] as JsonObject[])( + "preserves explicit summary without a native lookup: %j", + async (config) => { + expect( + await captureOriginalReasoningSummary({ + config, + command: { command: "unused-native-executable" }, + cwd: tmpdir(), + environment: {}, + signal: new AbortController().signal, + }), + ).toBeUndefined(); + }, +); + +test.each(["unknown-model", "unsupported-command", "missing-metadata"])( + "keeps an unavailable original model default unknown: %s", + async (scenario) => { + const root = await mkdtemp(join(tmpdir(), "summary-selection-")); + try { + const cwd = join(root, "original output"); + const script = join(root, "native.mjs"); + const receipt = join(root, "receipt.json"); + await mkdir(cwd); + await writeFile( + script, + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(receipt)}, JSON.stringify({cwd:process.cwd(),args:process.argv,home:process.env.CODEX_HOME}));`, + `console.log(JSON.stringify({models:[{slug:${JSON.stringify(scenario === "unknown-model" ? "another-model" : "selected-model")}${scenario === "missing-metadata" ? "" : ',default_reasoning_summary:"none"'}}]}));`, + `process.exit(${scenario === "unsupported-command" ? 1 : 0});`, + ].join("\n"), + ); + const command = execFileSync("node", ["-p", "process.execPath"], { + encoding: "utf8", + }).trim(); + const value = await captureOriginalReasoningSummary({ + config: { model: "selected-model", model_reasoning_effort: "high" }, + command: { command }, + cwd, + environment: { + CODEX_HOME: root, + NODE_OPTIONS: `--import=${pathToFileURL(script).href}`, + }, + signal: new AbortController().signal, + }); + expect(value).toBeUndefined(); + const recorded = JSON.parse(await readFile(receipt, "utf8")); + expect(await realpath(recorded.cwd)).toBe(await realpath(cwd)); + expect(recorded.home).toBe(root); + expect(recorded.args).toContain('model="selected-model"'); + expect(recorded.args).toContain('model_reasoning_effort="high"'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); From 74111b271a7718a2314e574234fe6f2aef316c12 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:48:46 +0000 Subject: [PATCH 102/251] fix(deep-scan): preserve uncapped deny glob expansion --- .../mcp-app/src/deep-scan/recovery-settings.ts | 7 ++++++- .../mcp-app/tests/test_deep_scan_executor.mjs | 18 +++++++++++++----- .../tests/test_deep_scan_recovery_settings.mjs | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 6477e30c17..edf4c0521f 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -188,6 +188,11 @@ export function restoredDeepScanWorkerSettings( const originalSandbox = settings.parentSandbox; const depths = [originalSandbox?.globScanMaxDepth, currentParentSandbox.globScanMaxDepth] .filter((depth): depth is number => depth !== undefined); + // Native depth caps limit deny-glob expansion, not allowed traversal. Keep + // the larger finite cap, or no cap when either known policy has uncapped globs. + const uncapped = [originalSandbox, currentParentSandbox].some((sandbox) => + sandbox?.globScanMaxDepth === undefined && sandbox?.filesystemDenies.some((path) => + ["*", "?", "[", "]"].some((character) => path.includes(character)))); return { model: settings.model, reasoningEffort: settings.reasoningEffort, @@ -195,7 +200,7 @@ export function restoredDeepScanWorkerSettings( filesystemDenies: [...new Set([ ...(originalSandbox?.filesystemDenies ?? []), ...currentParentSandbox.filesystemDenies ])], - ...(depths.length === 0 ? {} : { globScanMaxDepth: Math.max(...depths) }) + ...(uncapped || depths.length === 0 ? {} : { globScanMaxDepth: Math.max(...depths) }) }, codexOptions: { codexPathOverride: settings.codexPath, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 369e9da478..1a315dc8a6 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -748,7 +748,11 @@ async function testIsolatedReconstructedWorkers() { const scans = []; try { for (const name of ["first", "second"]) { - const fixture = await fakeCodexFixture(deniedWorkerPermissionProfile); + const uncappedSandbox = { filesystemDenies: trustedParentSandboxWithDenials.filesystemDenies }; + const currentParentSandbox = name === "first" ? uncappedSandbox : trustedParentSandboxWithDenials; + const expectedProfile = structuredClone(deniedWorkerPermissionProfile); + delete expectedProfile.filesystem.glob_scan_max_depth; + const fixture = await fakeCodexFixture(expectedProfile); const codexHome = path.join(fixture.root, "home"); const configPath = path.join(fixture.root, "scan config.toml"); const promptPath = path.join(fixture.root, "prompt.md"); @@ -787,7 +791,7 @@ async function testIsolatedReconstructedWorkers() { model: `fixture-${name}-override`, reasoningEffort: "ultra", usageOwner: { threadId: `fixture-${name}-owner`, turnId: "original-turn", startedAt: "2026-01-01T00:00:00Z" }, - parentSandbox: trustedParentSandboxWithDenials + parentSandbox: name === "first" ? trustedParentSandboxWithDenials : uncappedSandbox }; await mkdir(path.join(codexHome, "sessions")); await writeFile(path.join(codexHome, "sessions", "owner.jsonl"), [ @@ -814,9 +818,9 @@ async function testIsolatedReconstructedWorkers() { const snapshot = await readFile(snapshotPath, "utf8"); assert.equal(snapshot.includes("synthetic-"), false); const runtimeEnvironment = { ...codexOptions.env }; - const restored = restoredDeepScanWorkerSettings(saved, settings.parentSandbox, () => runtimeEnvironment); + const restored = restoredDeepScanWorkerSettings(saved, currentParentSandbox, () => runtimeEnvironment); restored.codexOptions.baseUrl = codexOptions.baseUrl; - scans.push({ name, fixture, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, + scans.push({ name, fixture, currentParentSandbox, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, executor: new CodexSdkWorkerExecutor(restored) }); } childProcess.spawn = (command, args, options) => { @@ -845,7 +849,7 @@ async function testIsolatedReconstructedWorkers() { assert.fail("reconstruction must not recapture current settings"), { ...scan.settings, createdAt: "2026-01-01T00:01:00Z" }); - const restored = restoredDeepScanWorkerSettings(recorded, scan.settings.parentSandbox, () => scan.runtimeEnvironment); + const restored = restoredDeepScanWorkerSettings(recorded, scan.currentParentSandbox, () => scan.runtimeEnvironment); restored.codexOptions.baseUrl = scan.settings.codexOptions.baseUrl; scan.executor = new CodexSdkWorkerExecutor(restored); assert.equal(await readFile(scan.snapshotPath, "utf8"), scan.snapshot); @@ -888,6 +892,10 @@ async function testIsolatedReconstructedWorkers() { const baseUrl = `openai_base_url=${JSON.stringify(scan.settings.codexOptions.baseUrl)}`; assert.equal(child.argv.includes(baseUrl), true); assert.equal(preflight.argv.includes(baseUrl), true); + for (const launch of [child, preflight]) { + assert.equal(workerPermissionProfileOverride(launch.argv).includes("glob_scan_max_depth"), false, + "a resumed bounded cap must not truncate an original uncapped deny glob, in either order"); + } assertReadOnlyWorkerPolicy(child.argv); assertWorkerSubagentPolicy(child.argv, scan.name === "first" ? 0 : 2); assert.equal(workerPermissionProfileOverride(child.argv).includes('"/repo/.env"="deny"'), true); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 6a06127971..f2ad7caa90 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -28,6 +28,23 @@ try { reasoningSummary: "detailed", serviceTier: "fast" }; + const globSandbox = (depth) => ({ + filesystemDenies: ["/fixture/**/*.secret"], + ...(depth === undefined ? {} : { globScanMaxDepth: depth }) + }); + for (const [originalDepth, currentDepth, expectedDepth] of [ + [2, 5, 5], [5, 2, 5], [undefined, 2, undefined], [2, undefined, undefined] + ]) { + const restored = restoreSettings({ ...settings, parentSandbox: globSandbox(originalDepth) }, + globSandbox(currentDepth)); + assert.equal(restored.parentSandbox.globScanMaxDepth, expectedDepth, + `deny expansion must preserve both policies: ${originalDepth}, ${currentDepth}`); + } + assert.equal(restoreSettings(settings, globSandbox(2)).parentSandbox.globScanMaxDepth, 2, + "unavailable historical policy does not establish uncapped glob expansion"); + assert.equal(restoreSettings({ ...settings, parentSandbox: globSandbox(2) }, { + filesystemDenies: ["/fixture/exact-denial"] + }).parentSandbox.globScanMaxDepth, 2, "exact denials do not change glob expansion"); const first = await loadSettings(join(root, "one"), async () => ({ ...settings, apiKey: "synthetic-do-not-persist", From 051d520848de7a27f132797ca5b01cf5246389dd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:00:56 +0000 Subject: [PATCH 103/251] test: include reasoning summary in package contract --- sdk/typescript/scripts/check-package.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 6c9209972d..0553d5ac5a 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -214,6 +214,7 @@ const distFiles = new Set( "publication-events", "publication-store", "publish", + "reasoning-summary", "result", "runtime", "scan-activity", From 36c1585214b9f7f67cf74c039861e057e5dedd6c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:07:18 +0000 Subject: [PATCH 104/251] fix: resolve MCP builds with project dependencies --- .../mcp-app/scripts/build_mcp_app.mjs | 1 + plugins/codex-security/mcp-app/tsconfig.json | 4 ++ sdk/typescript/tests-ts/build-plugin.test.ts | 72 ++++++++++++++++--- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs index 6b15baa7ab..e8e636e6b5 100644 --- a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs +++ b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs @@ -41,6 +41,7 @@ export async function buildMcpApp({ output }) { loader: { ".md": "text" }, logLevel: "info", logOverride: { "empty-import-meta": "silent" }, + nodePaths: [join(root, "node_modules")], outfile: bundle, platform: "node", target: "node20" diff --git a/plugins/codex-security/mcp-app/tsconfig.json b/plugins/codex-security/mcp-app/tsconfig.json index ef0922f6bb..ceb06c5b2a 100644 --- a/plugins/codex-security/mcp-app/tsconfig.json +++ b/plugins/codex-security/mcp-app/tsconfig.json @@ -5,6 +5,10 @@ "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, + "paths": { + "@openai/codex-sdk": ["./node_modules/@openai/codex-sdk"], + "smol-toml": ["./node_modules/smol-toml"] + }, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 1e70ee07fa..4fe7b964af 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -178,8 +178,58 @@ describe("bundled plugin build", () => { expect(result.stderr).toBe(""); }); - test("builds the MCP runtime without invoking an npm launcher", async () => { + test("builds the MCP runtime with only MCP dependencies and no npm launcher", async () => { const root = await temporaryDirectory(); + const plugin = join(root, "plugins", "codex-security"); + const mcp = join(plugin, "mcp-app"); + const sdk = join(root, "sdk", "typescript"); + const source = new URL("../../../plugins/codex-security/", import.meta.url); + await mkdir(mcp, { recursive: true }); + await mkdir(sdk, { recursive: true }); + for (const name of [ + "package.json", + "tsconfig.json", + "main.ts", + "artifact-writer-main.ts", + "helpers-main.ts", + "server.ts", + "src", + "scripts", + "templates", + ]) { + await cp(new URL(`mcp-app/${name}`, source), join(mcp, name), { + recursive: true, + }); + } + for (const name of [ + "schemas", + "native/prebuilt", + "plugin-files.json", + "scripts/reserved_artifact_paths.json", + ]) { + await cp(new URL(name, source), join(plugin, name), { recursive: true }); + } + for (const name of await readdir(new URL("native/", source))) { + if (/\.(?:mjs|mts)$/.test(name)) { + await copyFile( + new URL(`native/${name}`, source), + join(plugin, "native", name), + ); + } + } + for (const name of ["src", "package.json", "tsconfig.json"]) { + await cp(new URL(`../${name}`, import.meta.url), join(sdk, name), { + recursive: true, + }); + } + await symlink( + fileURLToPath(new URL("mcp-app/node_modules", source)), + join(mcp, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + await expect(stat(join(sdk, "node_modules"))).rejects.toMatchObject({ + code: "ENOENT", + }); const bin = join(root, "bin"); const launcher = process.platform === "win32" ? "npm.cmd" : "npm"; await writeFixture( @@ -192,24 +242,24 @@ describe("bundled plugin build", () => { const destination = join(root, "mcp"); await execFileAsync( "node", - [ - fileURLToPath( - new URL( - "../../../plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs", - import.meta.url, - ), - ), - "--output", - destination, - ], + [join(mcp, "scripts", "build_mcp_app.mjs"), "--output", destination], { env: { ...process.env, + NODE_PATH: "", PATH: [bin, process.env["PATH"]].filter(Boolean).join(delimiter), }, }, ); + await execFileAsync("node", [ + "--eval", + "require('node:fs').unlinkSync(process.argv[1])", + join(mcp, "node_modules"), + ]); + await expect(stat(join(mcp, "node_modules"))).rejects.toMatchObject({ + code: "ENOENT", + }); const contract = JSON.parse( await readFile( new URL( From 6732e364c37e8b029f3b8e77805db89b59f9cf93 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:13:29 +0000 Subject: [PATCH 105/251] fix: preserve TOML module selection in MCP builds --- plugins/codex-security/mcp-app/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/tsconfig.json b/plugins/codex-security/mcp-app/tsconfig.json index ceb06c5b2a..5f455476e7 100644 --- a/plugins/codex-security/mcp-app/tsconfig.json +++ b/plugins/codex-security/mcp-app/tsconfig.json @@ -7,7 +7,7 @@ "noEmit": true, "paths": { "@openai/codex-sdk": ["./node_modules/@openai/codex-sdk"], - "smol-toml": ["./node_modules/smol-toml"] + "smol-toml": ["./node_modules/smol-toml/dist/index"] }, "resolveJsonModule": true, "skipLibCheck": true, From 302e3585b2951c0b98553a7e70e1e614a6ad3e61 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:17:57 +0000 Subject: [PATCH 106/251] fix(deep-scan): persist original settings before recovery --- plugins/codex-security/mcp-app/server.ts | 38 ++- .../src/deep-scan/recovery-settings.ts | 9 +- .../mcp-app/src/deep-scan/store.ts | 13 +- .../mcp-app/tests/test_deep_scan_executor.mjs | 46 ++- .../test_deep_scan_recovery_settings.mjs | 42 +-- .../mcp-app/tests/test_deep_scan_store.mjs | 19 ++ .../scripts/deep_scan_workbench.py | 41 ++- .../codex-security/scripts/workbench_cli.py | 12 +- .../codex-security/scripts/workbench_db.py | 4 +- .../tests/test_deep_scan_settings_creation.py | 298 ++++++++++++++++++ 10 files changed, 463 insertions(+), 59 deletions(-) create mode 100644 plugins/codex-security/tests/test_deep_scan_settings_creation.py diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 93204e9da0..e80c381f8c 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -22,7 +22,7 @@ import { DeepScanStartLock, startOrJoinDeepScanCoordinator } from "./src/deep-scan/registry.js"; -import { captureDeepScanExecutionSettings, loadOrCaptureDeepScanExecutionSettings, restoredDeepScanWorkerSettings } from "./src/deep-scan/recovery-settings.js"; +import { captureDeepScanExecutionSettings, loadDeepScanExecutionSettings, restoredDeepScanWorkerSettings } from "./src/deep-scan/recovery-settings.js"; import { CodexSdkWorkerExecutor } from "./src/deep-scan/executor.js"; import { CODEX_SANDBOX_STATE_META_CAPABILITY, @@ -728,7 +728,13 @@ export function createCodexSecurityServer(): McpServer { return toolErrorResult(deepScanInvocationFailureMessage(error)); } const preparation = await deepScanStartLock.run(async () => { + // A joining observer does not need a usable current home. A new run, + // however, must save its original settings before creation can commit. + const executionSettings = await captureDeepScanExecutionSettings( + modelSettings, parentSandbox, process.env, { threadId, startedAt: new Date().toISOString() } + ).catch(() => null); const begun = await deepScanStore.begin({ + executionSettings, scanId, targetPath, scope: hasTarget ? scope ?? "." : undefined, @@ -753,8 +759,7 @@ export function createCodexSecurityServer(): McpServer { store: deepScanStore, prepareExecutor: async (run) => new CodexSdkWorkerExecutor({ ...restoredDeepScanWorkerSettings( - await loadOrCaptureDeepScanExecutionSettings(run.scanDir, () => - captureDeepScanExecutionSettings(run, parentSandbox, process.env, { threadId, startedAt: run.createdAt }), run), + await loadDeepScanExecutionSettings(run.scanDir, run), parentSandbox ), artifactContext: { @@ -1663,11 +1668,12 @@ async function runWorkbench( args: string[], input?: string | Buffer, selectFinalization = false, + beginWithExecutionSettings = false, ): Promise { let pythonCommand: string | undefined; try { pythonCommand = await resolvePythonCommand(); - return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization); + return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization, beginWithExecutionSettings); } catch (error) { const launchError = pythonCommand ? missingPythonHelperMessage(error, pythonCommand) @@ -1687,35 +1693,36 @@ async function executeWorkbenchWithStateSelection( args: string[], input?: string | Buffer, selectFinalization = false, + beginWithExecutionSettings = false, ): Promise { if (WORKBENCH_COMMANDS_WITHOUT_DATABASE.has(args[0] ?? "")) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); } if (CONFIGURED_WORKBENCH_STATE_DIR) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); } if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, beginWithExecutionSettings); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); } return await withWorkbenchStateSelectionLock(async () => { if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, beginWithExecutionSettings); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); } try { - const result = await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + const result = await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); persistentWorkbenchStateSucceeded = true; return result; } catch (error) { if (!isUnwritableSqliteOpenError(error)) throw error; const fallbackStateDir = await pinFallbackWorkbenchStateDir(); logWorkbenchStateFallback(); - return await executeWorkbench(pythonCommand, args, fallbackStateDir, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, fallbackStateDir, input, selectFinalization, beginWithExecutionSettings); } }); } @@ -1740,6 +1747,7 @@ async function executeWorkbench( stateDir?: string, input?: string | Buffer, selectFinalization = false, + beginWithExecutionSettings = false, ): Promise { const userContextIndex = args.indexOf("--user-context"); const userContext = userContextIndex === -1 ? undefined : args[userContextIndex + 1]; @@ -1748,8 +1756,10 @@ async function executeWorkbench( workbenchArgs.splice(userContextIndex, 2, "--user-context-stdin"); } const workbenchInput = input ?? userContext; - const pythonArgs = selectFinalization - ? ["-c", "import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](select_finalization=True)", workbenchScriptPath(), ...workbenchArgs] + const internalInvocation = selectFinalization ? "select_finalization=True" + : beginWithExecutionSettings ? "begin_with_execution_settings=True" : undefined; + const pythonArgs = internalInvocation + ? ["-c", `import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](${internalInvocation})`, workbenchScriptPath(), ...workbenchArgs] : [workbenchScriptPath(), ...workbenchArgs]; const execution = execFileAsync(pythonCommand, pythonArgs, { cwd: PLUGIN_ROOT, diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index edf4c0521f..acd88be788 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -130,10 +130,9 @@ async function originalParentSettings( } } -/** Called by the acquired coordinator before it starts any worker. */ -export async function loadOrCaptureDeepScanExecutionSettings( +/** New runs save settings in their creation transaction, before any coordinator claim. */ +export async function loadDeepScanExecutionSettings( scanDir: string, - capture: () => Promise, original?: Pick ): Promise { const path = join(scanDir, "artifacts", "deep_discovery", "execution-settings.json"); @@ -146,9 +145,7 @@ export async function loadOrCaptureDeepScanExecutionSettings( settings = executionSettings(saved.settings); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - settings = executionSettings(await capture()); - await writeJsonAtomic(path, { version: 1, settings }); - return settings; + throw new Error("This Deep Scan has no recorded original execution settings; its executable and Codex home cannot be recovered."); } if (!original || (settings.model !== undefined && settings.reasoningEffort !== undefined && settings.modelProvider !== undefined && settings.reasoningSummary !== undefined diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index ba411a9b57..f089d21769 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -2,6 +2,7 @@ import { availableParallelism } from "node:os"; import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { writeJsonAtomic } from "./artifacts.js"; +import type { DeepScanExecutionSettings } from "./recovery-settings.js"; import { boundedDeepScanErrorMessage, DeepScanNonRetryableError, @@ -30,6 +31,7 @@ export type WorkbenchRunner = ( args: string[], input?: string, selectFinalization?: boolean, + beginWithExecutionSettings?: boolean, ) => Promise; const WORKFLOW_VERSION = "deep-security-scan/v2"; @@ -120,6 +122,7 @@ export class WorkbenchDeepScanStore implements DeepScanStore { reasoningEffort?: string; threadId: string; scanRoot: string; + executionSettings?: DeepScanExecutionSettings | null; }): Promise { const userContext = input.userContext; const result = await this.enqueueWrite([ @@ -129,7 +132,7 @@ export class WorkbenchDeepScanStore implements DeepScanStore { ...(input.scanId ? ["--scan-id", input.scanId] : []), ...(input.targetPath ? ["--target-path", input.targetPath] : []), ...(input.scope ? ["--scope", input.scope] : []), - ...(userContext ? ["--user-context-stdin"] : []), + ...(userContext && input.executionSettings === undefined ? ["--user-context-stdin"] : []), ...(input.handoffClaimToken ? ["--claim-token", input.handoffClaimToken] : []), ...(input.model ? ["--model", input.model] : []), ...(input.reasoningEffort ? ["--reasoning-effort", input.reasoningEffort] : []), @@ -139,7 +142,10 @@ export class WorkbenchDeepScanStore implements DeepScanStore { String(availableParallelism()), "--workflow-version", WORKFLOW_VERSION - ], false, userContext); + ], false, input.executionSettings === undefined ? userContext : JSON.stringify({ + executionSettings: input.executionSettings, + userContext + }), false, input.executionSettings !== undefined); const run = parseDeepScan(result); const startDisposition = result.startDisposition; if (startDisposition !== "created" && startDisposition !== "joined") { @@ -438,12 +444,13 @@ export class WorkbenchDeepScanStore implements DeepScanStore { retryTransientFailure = false, input?: string, selectFinalization = false, + beginWithExecutionSettings = false, ): Promise { const operation = this.writeTail.then(async () => { try { return retryTransientFailure ? await this.runIdempotentPersistence(args, input, selectFinalization) - : await this.runWorkbench(args, input, selectFinalization); + : await this.runWorkbench(args, input, selectFinalization, beginWithExecutionSettings); } catch (error) { const scanId = argumentValue(args, "--scan-id"); if (scanId && isStaleCoordinatorGenerationError(error)) { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 1a315dc8a6..0e7c99e3b0 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -16,7 +16,7 @@ const bundle = await build({ }, stdin: { // Test the environment snapshot without adding a production export. - contents: `${await readFile(executorSource, "utf8")}\nexport { snapshotWorkerEnvironment };\nexport { captureDeepScanExecutionSettings, loadOrCaptureDeepScanExecutionSettings, restoredDeepScanWorkerSettings } from "./recovery-settings.js";`, + contents: `${await readFile(executorSource, "utf8")}\nexport { snapshotWorkerEnvironment };\nexport { captureDeepScanExecutionSettings, loadDeepScanExecutionSettings, restoredDeepScanWorkerSettings } from "./recovery-settings.js";\nexport { WorkbenchDeepScanStore } from "./store.js";`, loader: "ts", resolveDir: path.dirname(fileURLToPath(executorSource)), sourcefile: fileURLToPath(executorSource) @@ -25,7 +25,7 @@ const bundle = await build({ platform: "node", write: false }); -const { CodexSdkWorkerExecutor, resolveCodexPath, snapshotWorkerEnvironment, captureDeepScanExecutionSettings, loadOrCaptureDeepScanExecutionSettings, restoredDeepScanWorkerSettings } = await import( +const { WorkbenchDeepScanStore, CodexSdkWorkerExecutor, resolveCodexPath, snapshotWorkerEnvironment, captureDeepScanExecutionSettings, loadDeepScanExecutionSettings, restoredDeepScanWorkerSettings } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); const errorsBundle = await build({ @@ -811,16 +811,47 @@ async function testIsolatedReconstructedWorkers() { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", payload: { id: `fixture-${name}-observer`, model_provider: "observer-provider" } }) + "\n"); - const saved = await loadOrCaptureDeepScanExecutionSettings(fixture.root, () => - captureDeepScanExecutionSettings(settings, settings.parentSandbox, { ...codexOptions.env, CODEX_CLI_PATH: executable }, { threadId: `fixture-${name}-observer`, startedAt: "2026-01-01T00:01:00Z" })); + const saved = await captureDeepScanExecutionSettings(settings, settings.parentSandbox, + { ...codexOptions.env, CODEX_CLI_PATH: executable }, + { threadId: `fixture-${name}-observer`, startedAt: "2026-01-01T00:01:00Z" }); assert.equal(saved.nativeServiceTierAbsent, name === "first" ? true : undefined); - const snapshotPath = path.join(fixture.root, "artifacts", "deep_discovery", "execution-settings.json"); + const targetPath = path.join(fixture.root, "target"); + await mkdir(targetPath); + const workbenchPath = fileURLToPath(new URL("../../scripts/workbench_db.py", import.meta.url)); + const runWorkbench = async (args, input, _selectFinalization, beginWithExecutionSettings) => { + const pythonArgs = beginWithExecutionSettings ? ["-c", + "import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](begin_with_execution_settings=True)", + workbenchPath, ...args] : [workbenchPath, ...args]; + const result = spawnSync(process.env.PYTHON?.trim() || "python3", pythonArgs, { + env: { ...process.env, CODEX_HOME: codexHome, + CODEX_SECURITY_STATE_DIR: path.join(fixture.root, "state") }, + input, encoding: "utf8", timeout: 30_000 + }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); + }; + const store = new WorkbenchDeepScanStore(runWorkbench); + const beginInput = { targetPath, threadId: settings.usageOwner.threadId, + model: settings.model, reasoningEffort: settings.reasoningEffort, + scanRoot: path.join(fixture.root, "scans") }; + const { run } = await store.begin({ ...beginInput, executionSettings: saved }); + const recordedScanDir = run.scanDir; + const snapshotPath = path.join(recordedScanDir, "artifacts", "deep_discovery", "execution-settings.json"); const snapshot = await readFile(snapshotPath, "utf8"); + assert.deepEqual(await loadDeepScanExecutionSettings(recordedScanDir), saved); + const claim = await store.claimCoordinator({ scanId: run.scanId, threadId: beginInput.threadId }); + assert.equal(claim.acquired, true); + const observer = await new WorkbenchDeepScanStore(runWorkbench).begin({ + ...beginInput, executionSettings: null, model: "observer-model", reasoningEffort: "low" + }); + assert.equal(observer.shouldStart, false); + assert.equal(await readFile(snapshotPath, "utf8"), snapshot); + assert.equal(observer.run.model, settings.model); assert.equal(snapshot.includes("synthetic-"), false); const runtimeEnvironment = { ...codexOptions.env }; const restored = restoredDeepScanWorkerSettings(saved, currentParentSandbox, () => runtimeEnvironment); restored.codexOptions.baseUrl = codexOptions.baseUrl; - scans.push({ name, fixture, currentParentSandbox, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, + scans.push({ name, fixture, recordedScanDir, currentParentSandbox, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, executor: new CodexSdkWorkerExecutor(restored) }); } childProcess.spawn = (command, args, options) => { @@ -845,8 +876,7 @@ async function testIsolatedReconstructedWorkers() { if (scan.name === "first") delete saved.settings.serviceTier; await writeFile(scan.snapshotPath, JSON.stringify(saved)); } - const recorded = await loadOrCaptureDeepScanExecutionSettings(scan.fixture.root, () => - assert.fail("reconstruction must not recapture current settings"), { + const recorded = await loadDeepScanExecutionSettings(scan.recordedScanDir, { ...scan.settings, createdAt: "2026-01-01T00:01:00Z" }); const restored = restoredDeepScanWorkerSettings(recorded, scan.currentParentSandbox, () => scan.runtimeEnvironment); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index f2ad7caa90..87b5e58d8b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -14,7 +14,7 @@ const bundle = await build({ platform: "node", write: false }); -const { captureDeepScanExecutionSettings: captureSettings, restoredDeepScanWorkerSettings: restoreSettings, loadOrCaptureDeepScanExecutionSettings: loadSettings } = await import( +const { captureDeepScanExecutionSettings: captureSettings, restoredDeepScanWorkerSettings: restoreSettings, loadDeepScanExecutionSettings: loadSettings } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); const root = await mkdtemp(join(tmpdir(), "deep-settings-")); @@ -45,24 +45,24 @@ try { assert.equal(restoreSettings({ ...settings, parentSandbox: globSandbox(2) }, { filesystemDenies: ["/fixture/exact-denial"] }).parentSandbox.globScanMaxDepth, 2, "exact denials do not change glob expansion"); - const first = await loadSettings(join(root, "one"), async () => ({ - ...settings, - apiKey: "synthetic-do-not-persist", - env: { CODEX_API_KEY: "synthetic-do-not-persist" } - })); - assert.deepEqual(first, settings); + const writeSnapshot = async (directory, value) => { + const path = join(directory, "artifacts", "deep_discovery", "execution-settings.json"); + await mkdir(join(directory, "artifacts", "deep_discovery"), { recursive: true }); + await writeFile(path, JSON.stringify({ version: 1, settings: value }, null, 2) + "\n"); + }; + await assert.rejects(loadSettings(join(root, "missing")), /no recorded original execution settings/); + await writeSnapshot(join(root, "one"), settings); + await writeSnapshot(join(root, "two"), { ...settings, model: "other-model" }); const savedPath = join(root, "one", "artifacts", "deep_discovery", "execution-settings.json"); const saved = await readFile(savedPath, "utf8"); - assert.equal(saved.includes("synthetic-do-not-persist"), false); const [recovered, concurrent] = await Promise.all([ - loadSettings(join(root, "one"), async () => assert.fail("recovery recaptured observer settings")), - loadSettings(join(root, "two"), async () => ({ ...settings, model: "other-model" })) + loadSettings(join(root, "one")), loadSettings(join(root, "two")) ]); assert.deepEqual(recovered, settings); assert.equal(concurrent.model, "other-model"); assert.equal(await readFile(savedPath, "utf8"), saved); recovered.model = "caller-mutation"; - assert.deepEqual(await loadSettings(join(root, "one"), async () => assert.fail()), settings); + assert.deepEqual(await loadSettings(join(root, "one")), settings); const configPath = join(root, "runtime.toml"); await writeFile(configPath, `model = "inherited-model" model_provider = "custom" @@ -169,10 +169,10 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(fresh.reasoningSummary, undefined, "fresh native compatibility auto is not an original selection"); assert.equal(restoreSettings(fresh, { filesystemDenies: [] }).codexOptions.config.model_reasoning_summary, undefined); const freshDir = join(root, threadId); - await loadSettings(freshDir, async () => fresh); + await writeSnapshot(freshDir, fresh); const freshPath = join(freshDir, "artifacts", "deep_discovery", "execution-settings.json"); const freshBytes = await readFile(freshPath, "utf8"); - assert.deepEqual(await loadSettings(freshDir, async () => assert.fail(), { usageOwner: owner, createdAt: owner.startedAt }), fresh); + assert.deepEqual(await loadSettings(freshDir, { usageOwner: owner, createdAt: owner.startedAt }), fresh); assert.equal(await readFile(freshPath, "utf8"), freshBytes, "unknown summary is not replaced by a compatibility field or a later selection"); await writeFile(join(root, "config.toml"), 'model_reasoning_summary = "auto"\n'); const explicit = await captureSettings({ usageOwner: owner }, { filesystemDenies: [] }, parentEnvironment); @@ -201,8 +201,8 @@ http_headers = { Authorization = "synthetic-secret" } const tierDir = join(root, "missing-tier"); const { serviceTier: omittedTier, ...withoutTier } = applied; assert.equal(omittedTier, "default"); - await loadSettings(tierDir, async () => withoutTier); - const repairedTier = await loadSettings(tierDir, async () => assert.fail(), { + await writeSnapshot(tierDir, withoutTier); + const repairedTier = await loadSettings(tierDir, { usageOwner: appliedOwner, createdAt: "2026-01-01T00:01:00Z" }); assert.equal(repairedTier.serviceTier, "default"); @@ -230,28 +230,28 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(nativeDefaults.reasoningSummary, undefined, "a compatibility summary is not a recorded native default"); const incompleteDir = join(root, "incomplete"); const incomplete = { codexPath: process.execPath, codexHome: root, serviceTier: "flex" }; - await loadSettings(incompleteDir, async () => incomplete); + await writeSnapshot(incompleteDir, incomplete); await writeFile(join(root, "config.toml"), 'model_provider = "observer-provider"\nmodel_reasoning_summary = "detailed"\n'); const originalRun = { model: "stored-model", reasoningEffort: "ultra", usageOwner: originalOwner, createdAt: "2026-01-01T00:01:00Z" }; - const repaired = await loadSettings(incompleteDir, async () => assert.fail("existing settings must not recapture current config"), originalRun); + const repaired = await loadSettings(incompleteDir, originalRun); assert.deepEqual(repaired, { ...incomplete, model: "stored-model", reasoningEffort: "ultra", modelProvider: "openai", reasoningSummary: "none" }); const repairedPath = join(incompleteDir, "artifacts", "deep_discovery", "execution-settings.json"); const repairedBytes = await readFile(repairedPath, "utf8"); await rm(sessionDirectory, { recursive: true }); - assert.deepEqual(await loadSettings(incompleteDir, async () => assert.fail(), originalRun), repaired); + assert.deepEqual(await loadSettings(incompleteDir, originalRun), repaired); assert.equal(await readFile(repairedPath, "utf8"), repairedBytes, "recovered selections survive unavailable history"); const unknownDir = join(root, "unknown"); - await loadSettings(unknownDir, async () => incomplete); - const unknown = await loadSettings(unknownDir, async () => assert.fail(), { ...originalRun, usageOwner: null }); + await writeSnapshot(unknownDir, incomplete); + const unknown = await loadSettings(unknownDir, { ...originalRun, usageOwner: null }); assert.equal(unknown.model, "stored-model"); assert.equal(unknown.modelProvider, undefined, "missing original ownership is not current config"); assert.equal(unknown.reasoningSummary, undefined); assert.equal(unknown.nativeServiceTierAbsent, undefined); const unsupported = JSON.stringify({ version: 99, settings }); await writeFile(savedPath, unsupported); - await assert.rejects(loadSettings(join(root, "one"), async () => assert.fail()), /unsupported/); + await assert.rejects(loadSettings(join(root, "one")), /unsupported/); assert.equal(await readFile(savedPath, "utf8"), unsupported); } finally { await rm(root, { recursive: true, force: true }); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs index 1faa302915..ceea913d1d 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs @@ -17,6 +17,7 @@ const { WorkbenchDeepScanStore, parseDeepScan } = await import( ); await testBeginProtocolAndParsing(); +await testBeginCarriesOriginalSettingsWithUserContext(); await testCanonicalCommitProtocol(); await testTerminalProtocol(); testCanonicalNullAndPartialParsing(); @@ -46,6 +47,24 @@ function testOriginalUsageOwnerParsing() { assert.equal(parseDeepScan(value).usageOwner, null, "old readers do not establish an original owner"); } +async function testBeginCarriesOriginalSettingsWithUserContext() { + const executionSettings = { codexPath: "/fixture/codex", codexHome: "/fixture/home", + model: "original-model", reasoningSummary: "concise" }; + const userContext = "Review the parser.\nKeep this second line."; + const runner = async (args, input, selectFinalization, beginWithExecutionSettings) => { + assert.equal(selectFinalization, false); + assert.equal(beginWithExecutionSettings, true); + assert.deepEqual(JSON.parse(input), { executionSettings, userContext }); + assert.equal(args.includes("--user-context-stdin"), false, + "the private structured input carries context without a second stdin consumer"); + assert.equal(args.some((arg) => arg.includes("execution-settings")), false, + "no public argument is added for the internal settings handoff"); + return stateResult(randomUUID(), { startDisposition: "created" }); + }; + await new WorkbenchDeepScanStore(runner).begin({ targetPath: "/fixture/repository", + threadId: "fixture-thread", scanRoot: "/fixture/scans", userContext, executionSettings }); +} + async function testBeginProtocolAndParsing() { const scanId = randomUUID(); const calls = []; diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 7cd2a7e731..df1d3695b2 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -670,6 +670,7 @@ def ensure_deep_scan_run( config: dict[str, int | float], workflow_version: str, timestamp: str, + args: argparse.Namespace | None = None, ) -> sqlite3.Row: existing = connection.execute( "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan["id"],) @@ -681,6 +682,39 @@ def ensure_deep_scan_run( raise SystemExit("Deep Scan orchestration requires a scan in deep mode.") if scan["status"] != "running": raise SystemExit("Only a running Deep Scan can start orchestration.") + if args is not None and hasattr(args, "execution_settings"): + # The creation transaction serializes contenders. Persist before the run + # becomes recoverable; joined callers never replace the original bytes. + scan_dir = Path(scan["scan_dir"]) + relative_path = "artifacts/deep_discovery/execution-settings.json" + if (scan_dir / relative_path).exists(): + saved = _read_scan_local_json(scan_dir, relative_path, "Deep Scan execution settings") + if saved.get("version") != 1: + raise SystemExit("This Deep Scan uses an unsupported execution settings version.") + # A managed scan directory can survive a rolled-back creation. + # Keep its saved selections and the returned scan model consistent. + connection.execute( + "UPDATE scans SET model = ?, reasoning_effort = ? WHERE id = ?", + ( + saved["settings"].get("model"), + saved["settings"].get("reasoningEffort"), + scan["id"], + ), + ) + else: + if args.execution_settings is None: + raise SystemExit("The original Deep Scan execution settings could not be captured.") + settings = dict(args.execution_settings) + # The persisted scan remains authoritative when the caller did not + # supply a model or effort (for example a managed handoff). + for key, column in (("model", "model"), ("reasoningEffort", "reasoning_effort")): + if scan[column] is not None: + settings[key] = scan[column] + write_scan_local_bytes( + scan_dir, + relative_path, + (json.dumps({"version": 1, "settings": settings}, indent=2) + "\n").encode(), + ) connection.execute( """ INSERT INTO deep_scan_runs ( @@ -873,7 +907,8 @@ def begin_deep_scan_for_scan( """, (model, reasoning_effort, scan_id), ) - ensure_deep_scan_run(connection, scan, config, workflow_version, now()) + scan = require_scan(connection, scan_id) + ensure_deep_scan_run(connection, scan, config, workflow_version, now(), args) connection.commit() except BaseException: connection.rollback() @@ -917,7 +952,7 @@ def begin_deep_scan_for_target( workflow_version = optional_text(args.workflow_version, maximum=256) if workflow_version is None: raise SystemExit("workflow-version is required.") - ensure_deep_scan_run(connection, existing, config, workflow_version, now()) + ensure_deep_scan_run(connection, existing, config, workflow_version, now(), args) connection.commit() return deep_scan_result( connection, @@ -1037,7 +1072,7 @@ def begin_deep_scan_for_target( (scan_id, timestamp, workspace_id), ) scan = require_scan(connection, scan_id) - ensure_deep_scan_run(connection, scan, config, workflow_version, timestamp) + ensure_deep_scan_run(connection, scan, config, workflow_version, timestamp, args) connection.commit() except BaseException: connection.rollback() diff --git a/plugins/codex-security/scripts/workbench_cli.py b/plugins/codex-security/scripts/workbench_cli.py index dc538fd419..a79168b871 100644 --- a/plugins/codex-security/scripts/workbench_cli.py +++ b/plugins/codex-security/scripts/workbench_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import json import sys from pathlib import Path @@ -30,7 +31,7 @@ def add_user_context(parser: argparse.ArgumentParser, *, required: bool = False) context.add_argument("--user-context-stdin", action="store_true") -def parse_args(description: str) -> argparse.Namespace: +def parse_args(description: str, *, execution_settings: bool = False) -> argparse.Namespace: parser = argparse.ArgumentParser(description=description) subparsers = parser.add_subparsers(dest="command", required=True) @@ -392,7 +393,14 @@ def parse_args(description: str) -> argparse.Namespace: parser.error("pass exactly one user-context transport") index = arguments.index("--user-context-stdin") arguments[index] = "--user-context=" + sys.stdin.buffer.read().decode("utf-8") - return parser.parse_args(arguments) + args = parser.parse_args(arguments) + if execution_settings: + # Private MCP creation input shares stdin with the user's unchanged context. + payload = json.load(sys.stdin) + args.execution_settings = payload["executionSettings"] + args.user_context = payload.get("userContext") + args.user_context_stdin = False + return args def non_negative_int(value: str) -> int: diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 2ded9cd584..7c1beb0641 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -3403,10 +3403,10 @@ def read_json_object(path: Path) -> dict[str, Any]: ) -def main(*, select_finalization: bool = False) -> None: +def main(*, select_finalization: bool = False, begin_with_execution_settings: bool = False) -> None: # Workbench callers send UTF-8 even when Windows uses a legacy code page. sys.stdin.reconfigure(encoding="utf-8") - args = parse_args(__doc__) + args = parse_args(__doc__, execution_settings=begin_with_execution_settings) deep_scan.configure( deep_scan.DeepScanDependencies( now=now, diff --git a/plugins/codex-security/tests/test_deep_scan_settings_creation.py b/plugins/codex-security/tests/test_deep_scan_settings_creation.py new file mode 100644 index 0000000000..350a33d9d3 --- /dev/null +++ b/plugins/codex-security/tests/test_deep_scan_settings_creation.py @@ -0,0 +1,298 @@ +"""Original settings survive real creation/claim process death and competing starts.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pytest +from workbench_test_support import ( + SCRIPT, + create_saved_workspace, + run_workbench, + start_delivered_scan, +) + +# Run the real workbench, pausing only at a requested transaction boundary. +# The parse hook also runs against the parent source, where the settings input +# was ignored, so its regression fails on missing durable bytes. +BOUNDARY_PROCESS = """ +import json, runpy, sys +from pathlib import Path +boundary = sys.argv.pop(1) +script = sys.argv.pop(1) +main = runpy.run_path(script)['main'] +namespace = main.__globals__ +deep = namespace['deep_scan'] +parse = namespace['parse_args'] +def parse_with_settings(*args, **kwargs): + result = parse(*args, **kwargs) + payload = json.loads(sys.stdin.readline()) + result.execution_settings = payload['executionSettings'] + result.user_context = payload.get('userContext') + result.user_context_stdin = False + return result +namespace['parse_args'] = parse_with_settings +def pause(scan): + print(json.dumps({'scanId': scan['id'], 'scanDir': scan['scan_dir']}), flush=True) + sys.stdin.readline() +write = deep.write_scan_local_bytes +def write_settings(scan_dir, relative_path, payload, **kwargs): + if boundary == 'before-write' and relative_path.endswith('/execution-settings.json'): + pause({'id': None, 'scan_dir': str(scan_dir)}) + return write(scan_dir, relative_path, payload, **kwargs) +deep.write_scan_local_bytes = write_settings +ensure = deep.ensure_deep_scan_run +def ensure_run(connection, scan, *args): + result = ensure(connection, scan, *args) + if boundary == 'before-commit': + pause(scan) + return result +deep.ensure_deep_scan_run = ensure_run +begin = deep.begin_deep_scan +def begin_run(connection, args): + result = begin(connection, args) + if boundary == 'after-commit': + pause({'id': result['deepScan']['scanId'], 'scan_dir': result['deepScan']['scanDir']}) + return result +deep.begin_deep_scan = begin_run +claim = deep.claim_deep_scan_coordinator +def claim_run(connection, args): + result = claim(connection, args) + if boundary == 'after-claim': + pause({'id': result['deepScan']['scanId'], 'scan_dir': result['deepScan']['scanDir']}) + return result +deep.claim_deep_scan_coordinator = claim_run +main() +""" + + +def settings(root: Path, name: str) -> dict[str, object]: + return { + "codexPath": str(root / name / "codex"), + "codexHome": str(root / name / "home"), + "model": f"{name}-model", + "reasoningEffort": "high", + "reasoningSummary": "concise", + "parentSandbox": {"filesystemDenies": [str(root / name / "denied")]}, + } + + +def start_process( + root: Path, + selected: dict[str, object] | None, + *args: str, + boundary: str = "none", +) -> subprocess.Popen[str]: + process = subprocess.Popen( + [sys.executable, "-c", BOUNDARY_PROCESS, boundary, str(SCRIPT), *args], + env={ + **os.environ, + "CODEX_SECURITY_STATE_DIR": str(root / "state"), + "CODEX_HOME": str(root / "current-home"), + }, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert process.stdin is not None + process.stdin.write(json.dumps({"executionSettings": selected}) + "\n") + process.stdin.flush() + return process + + +def finish(process: subprocess.Popen[str]) -> dict[str, object]: + stdout, stderr = process.communicate(timeout=30) + assert process.returncode == 0, stderr + return json.loads(stdout) + + +def begin_args(root: Path, *, name: str = "target") -> list[str]: + (root / name).mkdir(exist_ok=True) + return [ + "begin-deep-scan", + "--thread-id", + "original-thread", + "--target-path", + str(root / name), + "--scan-root", + str(root / "scans"), + ] + + +def snapshot(scan_dir: str) -> Path: + return Path(scan_dir) / "artifacts" / "deep_discovery" / "execution-settings.json" + + +@pytest.mark.parametrize( + "boundary", ["before-write", "before-commit", "after-commit", "after-claim"] +) +def test_process_death_keeps_original_settings_or_no_recoverable_run( + tmp_path: Path, boundary: str +) -> None: + original = settings(tmp_path, "original") + args = begin_args(tmp_path) + if boundary == "after-claim": + begun = finish(start_process(tmp_path, original, *args))["deepScan"] + args = [ + "claim-deep-scan-coordinator", + "--scan-id", + begun["scanId"], + "--thread-id", + "original-thread", + ] + process = start_process(tmp_path, original, *args, boundary=boundary) + try: + assert process.stdout is not None + paused = json.loads(process.stdout.readline()) + path = snapshot(paused["scanDir"]) + with sqlite3.connect(tmp_path / "state" / "workbench.sqlite3") as connection: + rows = connection.execute("SELECT scan_id FROM deep_scan_runs").fetchall() + if boundary in {"before-write", "before-commit"}: + assert rows == [], "uncommitted settings must not leave a recoverable run" + else: + assert rows == [(paused["scanId"],)] + if boundary != "before-write": + assert json.loads(path.read_bytes())["settings"] == original + finally: + process.kill() + process.communicate(timeout=30) + with sqlite3.connect(tmp_path / "state" / "workbench.sqlite3") as connection: + remaining = connection.execute("SELECT scan_id FROM deep_scan_runs").fetchall() + if boundary in {"before-write", "before-commit"}: + assert remaining == [] + return + before = path.read_bytes() + with sqlite3.connect(tmp_path / "state" / "workbench.sqlite3") as connection: + connection.execute("UPDATE deep_scan_runs SET updated_at = '2000-01-01T00:00:00Z'") + run_workbench( + tmp_path / "state", + "claim-deep-scan-coordinator", + "--scan-id", + paused["scanId"], + "--thread-id", + "original-thread", + ) + joined = finish(start_process(tmp_path, settings(tmp_path, "later"), *begin_args(tmp_path))) + assert joined["startDisposition"] == "joined" + assert path.read_bytes() == before + + +def test_competing_creation_and_observer_cannot_replace_original_settings(tmp_path: Path) -> None: + first = start_process( + tmp_path, settings(tmp_path, "original"), *begin_args(tmp_path), boundary="before-commit" + ) + second = None + try: + assert first.stdout is not None + paused = json.loads(first.stdout.readline()) + path = snapshot(paused["scanDir"]) + before = path.read_bytes() + second = start_process(tmp_path, settings(tmp_path, "later"), *begin_args(tmp_path)) + # Release the creation lock with the original process still alive. The + # contender must observe the committed run, not rewrite the settings. + created = finish(first) + joined = finish(second) + assert created["startDisposition"] == "created" + assert joined["startDisposition"] == "joined" + assert joined["deepScan"]["scanId"] == paused["scanId"] + assert path.read_bytes() == before + observer = finish(start_process(tmp_path, None, *begin_args(tmp_path))) + assert observer["startDisposition"] == "joined" + assert path.read_bytes() == before + other = finish( + start_process( + tmp_path, settings(tmp_path, "other"), *begin_args(tmp_path, name="other-target") + ) + ) + assert json.loads(snapshot(other["deepScan"]["scanDir"]).read_bytes())[ + "settings" + ] == settings(tmp_path, "other") + finally: + for process in (first, second): + if process is not None and process.poll() is None: + process.kill() + process.communicate(timeout=30) + + +def test_internal_settings_input_preserves_public_user_context_and_shape(tmp_path: Path) -> None: + context = "Review the parser.\nKeep this second line." + process = subprocess.run( + [ + sys.executable, + "-c", + ( + "import runpy, sys; script = sys.argv.pop(1); " + "runpy.run_path(script)['main'](begin_with_execution_settings=True)" + ), + str(SCRIPT), + *begin_args(tmp_path), + ], + env={**os.environ, "CODEX_SECURITY_STATE_DIR": str(tmp_path / "state")}, + input=json.dumps( + {"executionSettings": settings(tmp_path, "original"), "userContext": context} + ), + capture_output=True, + text=True, + timeout=30, + ) + assert process.returncode == 0, process.stderr + result = json.loads(process.stdout) + assert result["deepScan"]["userContext"] == context + assert "executionSettings" not in result["deepScan"] + assert json.loads(snapshot(result["deepScan"]["scanDir"]).read_bytes())["settings"] == settings( + tmp_path, "original" + ) + + +def test_managed_creation_retry_preserves_saved_settings_before_commit(tmp_path: Path) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + workspace = create_saved_workspace(state, target, thread_id="original-thread", mode="deep") + started = start_delivered_scan( + state, + "--workspace-id", + workspace["id"], + "--scan-root", + str(tmp_path / "scans"), + "--model", + "original-model", + "--reasoning-effort", + "high", + ) + scan = started["results"] + args = ["begin-deep-scan", "--scan-id", scan["scanId"], "--thread-id", "original-thread"] + original = settings(tmp_path, "original") + process = start_process(tmp_path, original, *args, boundary="before-commit") + try: + assert process.stdout is not None + paused = json.loads(process.stdout.readline()) + path = snapshot(paused["scanDir"]) + before = path.read_bytes() + finally: + process.kill() + process.communicate(timeout=30) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + assert connection.execute("SELECT COUNT(*) FROM deep_scan_runs").fetchone() == (0,) + recovered = finish( + start_process( + tmp_path, + settings(tmp_path, "later"), + *args, + "--model", + "later-model", + "--reasoning-effort", + "low", + ) + ) + assert recovered["startDisposition"] == "created" + assert path.read_bytes() == before + assert json.loads(before)["settings"] == original + assert recovered["deepScan"]["model"] == "original-model" + assert recovered["deepScan"]["reasoningEffort"] == "high" From 6fb0afe9187f225b99e8fa17053cd7db2568f5bd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:48:04 +0000 Subject: [PATCH 107/251] fix(deep-scan): preserve legacy settings recovery --- plugins/codex-security/mcp-app/server.ts | 35 ++-- .../src/deep-scan/recovery-settings.ts | 39 +++- .../mcp-app/src/deep-scan/store.ts | 8 +- .../mcp-app/tests/test_deep_scan_executor.mjs | 6 +- .../test_deep_scan_recovery_settings.mjs | 24 +++ .../mcp-app/tests/test_deep_scan_store.mjs | 18 +- .../scripts/deep_scan_workbench.py | 42 +++- .../codex-security/scripts/workbench_cli.py | 12 +- .../codex-security/scripts/workbench_db.py | 4 +- .../tests/test_deep_scan_compatibility.py | 179 +++++++++++++++++- .../tests/test_deep_scan_settings_creation.py | 2 +- 11 files changed, 325 insertions(+), 44 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index e80c381f8c..dec3b69f9a 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -22,7 +22,7 @@ import { DeepScanStartLock, startOrJoinDeepScanCoordinator } from "./src/deep-scan/registry.js"; -import { captureDeepScanExecutionSettings, loadDeepScanExecutionSettings, restoredDeepScanWorkerSettings } from "./src/deep-scan/recovery-settings.js"; +import { captureDeepScanExecutionSettings, loadDeepScanExecutionSettings, restoredDeepScanWorkerSettings, type DeepScanLegacySettingsContext } from "./src/deep-scan/recovery-settings.js"; import { CodexSdkWorkerExecutor } from "./src/deep-scan/executor.js"; import { CODEX_SANDBOX_STATE_META_CAPABILITY, @@ -759,7 +759,12 @@ export function createCodexSecurityServer(): McpServer { store: deepScanStore, prepareExecutor: async (run) => new CodexSdkWorkerExecutor({ ...restoredDeepScanWorkerSettings( - await loadDeepScanExecutionSettings(run.scanDir, run), + await loadDeepScanExecutionSettings(run.scanDir, run, async () => { + const context = await runWorkbench(["get-scan", "--scan-id", run.scanId]); + const recipe = context.recipe as Pick | undefined; + const scan = context.scan as { executionAttribution?: { owner: DeepScanRunState["usageOwner"] } }; + return { config: recipe?.config, usageOwner: scan.executionAttribution?.owner }; + }), parentSandbox ), artifactContext: { @@ -1668,12 +1673,12 @@ async function runWorkbench( args: string[], input?: string | Buffer, selectFinalization = false, - beginWithExecutionSettings = false, + withExecutionSettings = false, ): Promise { let pythonCommand: string | undefined; try { pythonCommand = await resolvePythonCommand(); - return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization, beginWithExecutionSettings); + return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization, withExecutionSettings); } catch (error) { const launchError = pythonCommand ? missingPythonHelperMessage(error, pythonCommand) @@ -1693,36 +1698,36 @@ async function executeWorkbenchWithStateSelection( args: string[], input?: string | Buffer, selectFinalization = false, - beginWithExecutionSettings = false, + withExecutionSettings = false, ): Promise { if (WORKBENCH_COMMANDS_WITHOUT_DATABASE.has(args[0] ?? "")) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); } if (CONFIGURED_WORKBENCH_STATE_DIR) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); } if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, beginWithExecutionSettings); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, withExecutionSettings); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); } return await withWorkbenchStateSelectionLock(async () => { if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, beginWithExecutionSettings); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, withExecutionSettings); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); } try { - const result = await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, beginWithExecutionSettings); + const result = await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); persistentWorkbenchStateSucceeded = true; return result; } catch (error) { if (!isUnwritableSqliteOpenError(error)) throw error; const fallbackStateDir = await pinFallbackWorkbenchStateDir(); logWorkbenchStateFallback(); - return await executeWorkbench(pythonCommand, args, fallbackStateDir, input, selectFinalization, beginWithExecutionSettings); + return await executeWorkbench(pythonCommand, args, fallbackStateDir, input, selectFinalization, withExecutionSettings); } }); } @@ -1747,7 +1752,7 @@ async function executeWorkbench( stateDir?: string, input?: string | Buffer, selectFinalization = false, - beginWithExecutionSettings = false, + withExecutionSettings = false, ): Promise { const userContextIndex = args.indexOf("--user-context"); const userContext = userContextIndex === -1 ? undefined : args[userContextIndex + 1]; @@ -1757,7 +1762,7 @@ async function executeWorkbench( } const workbenchInput = input ?? userContext; const internalInvocation = selectFinalization ? "select_finalization=True" - : beginWithExecutionSettings ? "begin_with_execution_settings=True" : undefined; + : withExecutionSettings ? "with_execution_settings=True" : undefined; const pythonArgs = internalInvocation ? ["-c", `import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](${internalInvocation})`, workbenchScriptPath(), ...workbenchArgs] : [workbenchScriptPath(), ...workbenchArgs]; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index acd88be788..4e21be2ad4 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -26,6 +26,11 @@ export interface DeepScanExecutionSettings { parentSandbox?: DeepWorkerParentSandbox; } +export interface DeepScanLegacySettingsContext { + config?: JsonObject; + usageOwner?: DeepScanRunState["usageOwner"]; +} + export async function captureDeepScanExecutionSettings( original: Pick, parentSandbox: DeepWorkerParentSandbox, @@ -133,8 +138,10 @@ async function originalParentSettings( /** New runs save settings in their creation transaction, before any coordinator claim. */ export async function loadDeepScanExecutionSettings( scanDir: string, - original?: Pick -): Promise { + original?: Pick, + readLegacyContext?: () => Promise, + environment: NodeJS.ProcessEnv = process.env +): Promise> { const path = join(scanDir, "artifacts", "deep_discovery", "execution-settings.json"); let settings: DeepScanExecutionSettings; try { @@ -145,6 +152,28 @@ export async function loadDeepScanExecutionSettings( settings = executionSettings(saved.settings); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (original?.workflowVersion === "deep-security-scan/v1" || original?.workflowVersion === "deep-scan-mcp/v1") { + // Legacy runs predate this file. Their saved recipe and recorded owner + // can recover selections, but cannot establish an original executable or + // home. Leave those unknown and retain the existing native launch behavior. + const context = await readLegacyContext?.(); + const selected = scanPreflightCodexConfig(resolveCodexProfile(context?.config ?? {})); + const owner = original.usageOwner ?? context?.usageOwner; + const home = environment.CODEX_HOME || join(homedir(), ".codex"); + const native = !owner?.threadId ? {} : await originalParentSettings(home, { + ...owner, threadId: owner.threadId, startedAt: original.createdAt ?? owner.startedAt + }); + return { + model: original.model ?? (selected.model as string | undefined) ?? native.model, + reasoningEffort: original.reasoningEffort ?? (selected.model_reasoning_effort as string | undefined) ?? native.reasoningEffort, + modelProvider: (selected.model_provider as string | undefined) ?? native.modelProvider, + reasoningSummary: (selected.model_reasoning_summary as string | undefined) ?? native.reasoningSummary, + serviceTier: (selected.service_tier as string | undefined) ?? native.serviceTier, + ...(selected.service_tier === undefined && native.nativeServiceTierAbsent + ? { nativeServiceTierAbsent: true as const } : {}), + providerConfig: selected.model_providers as JsonObject | undefined + }; + } throw new Error("This Deep Scan has no recorded original execution settings; its executable and Codex home cannot be recovered."); } if (!original || (settings.model !== undefined && settings.reasoningEffort !== undefined @@ -173,7 +202,7 @@ export async function loadDeepScanExecutionSettings( } export function restoredDeepScanWorkerSettings( - settings: DeepScanExecutionSettings, + settings: Partial, currentParentSandbox: DeepWorkerParentSandbox, environment: () => NodeJS.ProcessEnv = () => process.env ): { @@ -204,7 +233,9 @@ export function restoredDeepScanWorkerSettings( // The executor reads this property for each launch. API keys can refresh; // only the original account home and non-secret selections are bound. get env() { - return Object.fromEntries(Object.entries({ ...environment(), CODEX_CLI_PATH: settings.codexPath, CODEX_HOME: settings.codexHome }) + return Object.fromEntries(Object.entries({ ...environment(), + ...(settings.codexPath === undefined ? {} : { CODEX_CLI_PATH: settings.codexPath }), + ...(settings.codexHome === undefined ? {} : { CODEX_HOME: settings.codexHome }) }) .filter((entry): entry is [string, string] => entry[1] !== undefined)); }, config: { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index f089d21769..f27313ec01 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -31,7 +31,7 @@ export type WorkbenchRunner = ( args: string[], input?: string, selectFinalization?: boolean, - beginWithExecutionSettings?: boolean, + withExecutionSettings?: boolean, ) => Promise; const WORKFLOW_VERSION = "deep-security-scan/v2"; @@ -183,7 +183,7 @@ export class WorkbenchDeepScanStore implements DeepScanStore { input.threadId, ...this.coordinatorLeaseArgs(input.scanId), ...(input.handoffClaimToken ? ["--claim-token", input.handoffClaimToken] : []) - ]); + ], false, undefined, false, true); const run = parseDeepScan(result); const disposition = result.coordinatorDisposition; if (disposition !== "claimed" && disposition !== "adopted" && disposition !== "observing") { @@ -444,13 +444,13 @@ export class WorkbenchDeepScanStore implements DeepScanStore { retryTransientFailure = false, input?: string, selectFinalization = false, - beginWithExecutionSettings = false, + withExecutionSettings = false, ): Promise { const operation = this.writeTail.then(async () => { try { return retryTransientFailure ? await this.runIdempotentPersistence(args, input, selectFinalization) - : await this.runWorkbench(args, input, selectFinalization, beginWithExecutionSettings); + : await this.runWorkbench(args, input, selectFinalization, withExecutionSettings); } catch (error) { const scanId = argumentValue(args, "--scan-id"); if (scanId && isStaleCoordinatorGenerationError(error)) { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 0e7c99e3b0..e77bdc956b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -818,9 +818,9 @@ async function testIsolatedReconstructedWorkers() { const targetPath = path.join(fixture.root, "target"); await mkdir(targetPath); const workbenchPath = fileURLToPath(new URL("../../scripts/workbench_db.py", import.meta.url)); - const runWorkbench = async (args, input, _selectFinalization, beginWithExecutionSettings) => { - const pythonArgs = beginWithExecutionSettings ? ["-c", - "import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](begin_with_execution_settings=True)", + const runWorkbench = async (args, input, _selectFinalization, withExecutionSettings) => { + const pythonArgs = withExecutionSettings ? ["-c", + "import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](with_execution_settings=True)", workbenchPath, ...args] : [workbenchPath, ...args]; const result = spawnSync(process.env.PYTHON?.trim() || "python3", pythonArgs, { env: { ...process.env, CODEX_HOME: codexHome, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 87b5e58d8b..c10080abd7 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -136,6 +136,30 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(unavailableParent.serviceTier, undefined); assert.equal(unavailableParent.nativeServiceTierAbsent, undefined, "missing history does not prove native absence"); const originalOwner = { threadId: "fixture-parent", turnId: "original-turn", startedAt: "2026-01-01T00:00:00Z" }; + for (const workflowVersion of ["deep-security-scan/v1", "deep-scan-mcp/v1"]) { + const legacyDir = join(root, workflowVersion.replaceAll("/", "-")); + const legacy = await loadSettings(legacyDir, { + workflowVersion, model: "recorded-model", reasoningEffort: "ultra", + createdAt: "2026-01-01T00:01:00Z", usageOwner: null + }, async () => ({ config: { model_reasoning_summary: "concise", service_tier: "flex" }, + usageOwner: originalOwner }), parentEnvironment); + assert.equal(legacy.model, "recorded-model"); + assert.equal(legacy.reasoningSummary, "concise", "recorded recipe retains precedence"); + assert.equal(legacy.modelProvider, "openai", "recorded original owner supplies native selections"); + assert.equal(legacy.serviceTier, "flex"); + assert.equal(legacy.codexPath, undefined, "legacy metadata did not record an executable"); + assert.equal(legacy.codexHome, undefined, "a history lookup home is not recorded execution provenance"); + const restoredLegacy = restoreSettings(legacy, { filesystemDenies: ["/fixture/current-deny"] }, + () => ({ CODEX_HOME: "/fixture/runtime-home", CODEX_CLI_PATH: "/fixture/runtime-codex", + CODEX_API_KEY: "synthetic-live-key" })); + assert.equal(restoredLegacy.codexOptions.env.CODEX_HOME, "/fixture/runtime-home"); + assert.equal(restoredLegacy.codexOptions.env.CODEX_CLI_PATH, "/fixture/runtime-codex"); + assert.equal(restoredLegacy.codexOptions.config.model_reasoning_summary, "concise"); + await assert.rejects(readFile(join(legacyDir, "artifacts/deep_discovery/execution-settings.json")), + { code: "ENOENT" }); + } + await assert.rejects(loadSettings(join(root, "missing-v2"), { workflowVersion: "deep-security-scan/v2" }, + async () => assert.fail("missing promised v2 settings must not become legacy recovery")), /no recorded original/); const [rebound, unboundLegacy] = await Promise.all([ captureSettings({ usageOwner: originalOwner }, { filesystemDenies: [] }, parentEnvironment, { threadId: "fixture-other", startedAt: "2026-01-01T00:03:00Z" }), diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs index ceea913d1d..8026cf6002 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.mjs @@ -18,6 +18,7 @@ const { WorkbenchDeepScanStore, parseDeepScan } = await import( await testBeginProtocolAndParsing(); await testBeginCarriesOriginalSettingsWithUserContext(); +await testClaimChecksOriginalSettingsInsideTheTransaction(); await testCanonicalCommitProtocol(); await testTerminalProtocol(); testCanonicalNullAndPartialParsing(); @@ -51,9 +52,9 @@ async function testBeginCarriesOriginalSettingsWithUserContext() { const executionSettings = { codexPath: "/fixture/codex", codexHome: "/fixture/home", model: "original-model", reasoningSummary: "concise" }; const userContext = "Review the parser.\nKeep this second line."; - const runner = async (args, input, selectFinalization, beginWithExecutionSettings) => { + const runner = async (args, input, selectFinalization, withExecutionSettings) => { assert.equal(selectFinalization, false); - assert.equal(beginWithExecutionSettings, true); + assert.equal(withExecutionSettings, true); assert.deepEqual(JSON.parse(input), { executionSettings, userContext }); assert.equal(args.includes("--user-context-stdin"), false, "the private structured input carries context without a second stdin consumer"); @@ -65,6 +66,19 @@ async function testBeginCarriesOriginalSettingsWithUserContext() { threadId: "fixture-thread", scanRoot: "/fixture/scans", userContext, executionSettings }); } +async function testClaimChecksOriginalSettingsInsideTheTransaction() { + const scanId = randomUUID(); + const store = new WorkbenchDeepScanStore(async (args, input, selectFinalization, withExecutionSettings) => { + assert.equal(args[0], "claim-deep-scan-coordinator"); + assert.equal(input, undefined); + assert.equal(selectFinalization, false); + assert.equal(withExecutionSettings, true); + return { ...stateResult(scanId, { deepScan: { coordinatorGeneration: 2 } }), + coordinatorDisposition: "claimed" }; + }); + assert.equal((await store.claimCoordinator({ scanId, threadId: "fixture-thread" })).acquired, true); +} + async function testBeginProtocolAndParsing() { const scanId = randomUUID(); const calls = []; diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index df1d3695b2..740331675b 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -664,6 +664,26 @@ def effective_deep_scan_config(args: argparse.Namespace) -> dict[str, int | floa return resolve_deep_scan_config(available_parallelism) +def read_deep_scan_execution_settings(scan_dir: Path) -> dict[str, Any]: + relative_path = "artifacts/deep_discovery/execution-settings.json" + if not (scan_dir / relative_path).exists(): + raise SystemExit( + "This Deep Scan has no recorded original execution settings; " + "its executable and Codex home cannot be recovered." + ) + saved = _read_scan_local_json(scan_dir, relative_path, "Deep Scan execution settings") + if saved.get("version") != 1: + raise SystemExit("This Deep Scan uses an unsupported execution settings version.") + settings = saved.get("settings") + if not isinstance(settings, dict) or not all( + isinstance(settings.get(key), str) for key in ("codexPath", "codexHome") + ): + raise SystemExit( + "Deep Scan execution settings are missing the recorded executable or Codex home." + ) + return settings + + def ensure_deep_scan_run( connection: sqlite3.Connection, scan: sqlite3.Row, @@ -688,16 +708,14 @@ def ensure_deep_scan_run( scan_dir = Path(scan["scan_dir"]) relative_path = "artifacts/deep_discovery/execution-settings.json" if (scan_dir / relative_path).exists(): - saved = _read_scan_local_json(scan_dir, relative_path, "Deep Scan execution settings") - if saved.get("version") != 1: - raise SystemExit("This Deep Scan uses an unsupported execution settings version.") + saved = read_deep_scan_execution_settings(scan_dir) # A managed scan directory can survive a rolled-back creation. # Keep its saved selections and the returned scan model consistent. connection.execute( "UPDATE scans SET model = ?, reasoning_effort = ? WHERE id = ?", ( - saved["settings"].get("model"), - saved["settings"].get("reasoningEffort"), + saved.get("model"), + saved.get("reasoningEffort"), scan["id"], ), ) @@ -1183,10 +1201,20 @@ def claim_deep_scan_coordinator_locked( } else: adopted = run["coordinator_generation"] > 1 or run["phase"] != "setup" - if adopted: - recover_expired_coordinator(connection, run, timestamp) disposition = "adopted" if adopted else "claimed" + if ( + getattr(args, "require_execution_settings", False) + and deep_scan_finalization_input(run) is None + ): + scan_dir = Path(scan["scan_dir"]) + if ( + run["workflow_version"] == DEEP_SCAN_WORKFLOW_VERSION + or (scan_dir / "artifacts/deep_discovery/execution-settings.json").exists() + ): + read_deep_scan_execution_settings(scan_dir) + if disposition == "adopted": + recover_expired_coordinator(connection, run, timestamp) connection.execute( """ UPDATE deep_scan_runs diff --git a/plugins/codex-security/scripts/workbench_cli.py b/plugins/codex-security/scripts/workbench_cli.py index a79168b871..d21447ef3e 100644 --- a/plugins/codex-security/scripts/workbench_cli.py +++ b/plugins/codex-security/scripts/workbench_cli.py @@ -395,11 +395,13 @@ def parse_args(description: str, *, execution_settings: bool = False) -> argpars arguments[index] = "--user-context=" + sys.stdin.buffer.read().decode("utf-8") args = parser.parse_args(arguments) if execution_settings: - # Private MCP creation input shares stdin with the user's unchanged context. - payload = json.load(sys.stdin) - args.execution_settings = payload["executionSettings"] - args.user_context = payload.get("userContext") - args.user_context_stdin = False + args.require_execution_settings = True + if args.command == "begin-deep-scan": + # Private MCP creation input shares stdin with the user's unchanged context. + payload = json.load(sys.stdin) + args.execution_settings = payload["executionSettings"] + args.user_context = payload.get("userContext") + args.user_context_stdin = False return args diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 7c1beb0641..d383d3a200 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -3403,10 +3403,10 @@ def read_json_object(path: Path) -> dict[str, Any]: ) -def main(*, select_finalization: bool = False, begin_with_execution_settings: bool = False) -> None: +def main(*, select_finalization: bool = False, with_execution_settings: bool = False) -> None: # Workbench callers send UTF-8 even when Windows uses a legacy code page. sys.stdin.reconfigure(encoding="utf-8") - args = parse_args(__doc__, execution_settings=begin_with_execution_settings) + args = parse_args(__doc__, execution_settings=with_execution_settings) deep_scan.configure( deep_scan.DeepScanDependencies( now=now, diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py index 4d84e96c3e..96b01a874b 100644 --- a/plugins/codex-security/tests/test_deep_scan_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_compatibility.py @@ -3,12 +3,15 @@ from __future__ import annotations import json +import os import sqlite3 +import subprocess +import sys import uuid from pathlib import Path import pytest -from workbench_test_support import run_workbench +from workbench_test_support import SCRIPT, run_workbench def snapshot(state_dir: Path) -> str: @@ -16,6 +19,180 @@ def snapshot(state_dir: Path) -> str: return "\n".join(connection.iterdump()) +def claim_requiring_original_settings( + state: Path, scan_id: str +) -> subprocess.CompletedProcess[str]: + # Exercise the private MCP requirement against both the parent and fixed + # workbench, without adding a public CLI argument. + return subprocess.run( + [ + sys.executable, + "-c", + "\n".join( + [ + "import runpy, sys", + "script = sys.argv.pop(1)", + "main = runpy.run_path(script)['main']", + "namespace = main.__globals__", + "parse = namespace['parse_args']", + "def parse_with_requirement(*args, **kwargs):", + " result = parse(*args, **kwargs)", + " result.require_execution_settings = True", + " return result", + "namespace['parse_args'] = parse_with_requirement", + "main()", + ] + ), + str(SCRIPT), + "claim-deep-scan-coordinator", + "--scan-id", + scan_id, + "--thread-id", + "fixture-thread", + ], + env={**os.environ, "CODEX_SECURITY_STATE_DIR": str(state)}, + capture_output=True, + text=True, + timeout=30, + ) + + +@pytest.mark.parametrize( + "workflow,settings_version", + [ + ("deep-security-scan/v1", 99), + ("deep-scan-mcp/v1", 99), + ("deep-security-scan/v2", 99), + ("deep-security-scan/v2", None), + ], +) +def test_missing_or_unsupported_settings_reject_before_takeover( + tmp_path: Path, workflow: str, settings_version: int | None +) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + run = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + "--workflow-version", + workflow, + )["deepScan"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET coordinator_generation = 2, " + "phase = 'discovery', updated_at = '2000-01-01T00:00:00Z'" + ) + settings_path = Path(run["scanDir"]) / "artifacts/deep_discovery/execution-settings.json" + saved = None + if settings_version is not None: + settings_path.parent.mkdir(parents=True, exist_ok=True) + saved = json.dumps( + { + "version": settings_version, + "settings": {"codexPath": "/fixture/codex", "codexHome": "/fixture/home"}, + } + ).encode() + settings_path.write_bytes(saved) + before = snapshot(state) + result = claim_requiring_original_settings(state, run["scanId"]) + assert snapshot(state) == before, ( + "settings rejection must precede ownership and worker recovery" + ) + assert result.returncode != 0 + assert "execution settings" in result.stderr + assert (settings_path.read_bytes() if settings_path.exists() else None) == saved + + +@pytest.mark.parametrize("workflow", ["deep-security-scan/v1", "deep-scan-mcp/v1"]) +def test_legacy_takeover_does_not_require_or_create_a_new_snapshot( + tmp_path: Path, workflow: str +) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + run = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + "--workflow-version", + workflow, + )["deepScan"] + result = claim_requiring_original_settings(state, run["scanId"]) + assert result.returncode == 0, result.stderr + observed = json.loads(result.stdout)["deepScan"] + assert observed["workflowVersion"] == workflow + assert observed["config"] == run["config"] + assert not (Path(run["scanDir"]) / "artifacts/deep_discovery/execution-settings.json").exists() + + +@pytest.mark.parametrize("completion_only", [False, True]) +def test_observation_and_selected_completion_do_not_require_worker_settings( + tmp_path: Path, completion_only: bool +) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + run = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "fixture-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + )["deepScan"] + claim = run_workbench( + state, + "claim-deep-scan-coordinator", + "--scan-id", + run["scanId"], + "--thread-id", + "fixture-thread", + ) + if completion_only: + selection = { + "version": 1, + "resultPath": None, + "resultSha256": None, + "terminalReason": "capped", + "omittedWorkerIds": [], + "selectedAt": run["createdAt"], + } + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET finalization_input_json = ?, " + "updated_at = '2000-01-01T00:00:00Z'", + (json.dumps(selection),), + ) + before = snapshot(state) + result = claim_requiring_original_settings(state, run["scanId"]) + assert result.returncode == 0, result.stderr + observed = json.loads(result.stdout) + if completion_only: + assert observed["coordinatorDisposition"] == "adopted" + assert observed["deepScan"]["finalizationInput"] == selection + else: + assert observed["coordinatorDisposition"] == "observing" + assert ( + observed["deepScan"]["coordinatorGeneration"] + == claim["deepScan"]["coordinatorGeneration"] + ) + assert snapshot(state) == before + + @pytest.mark.parametrize( "version", ["deep-security-scan/v1", "deep-scan-mcp/v1", "deep-security-scan/v2"] ) diff --git a/plugins/codex-security/tests/test_deep_scan_settings_creation.py b/plugins/codex-security/tests/test_deep_scan_settings_creation.py index 350a33d9d3..4ff1a77b4b 100644 --- a/plugins/codex-security/tests/test_deep_scan_settings_creation.py +++ b/plugins/codex-security/tests/test_deep_scan_settings_creation.py @@ -228,7 +228,7 @@ def test_internal_settings_input_preserves_public_user_context_and_shape(tmp_pat "-c", ( "import runpy, sys; script = sys.argv.pop(1); " - "runpy.run_path(script)['main'](begin_with_execution_settings=True)" + "runpy.run_path(script)['main'](with_execution_settings=True)" ), str(SCRIPT), *begin_args(tmp_path), From a68a41db1109079e73d403ee1121774fb2f20e47 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 11:03:54 +0000 Subject: [PATCH 108/251] fix(deep-scan): persist provider selections without definitions --- .../src/deep-scan/recovery-settings.ts | 15 +++++--- .../mcp-app/tests/test_deep_scan_executor.mjs | 34 +++++++++++++---- .../test_deep_scan_recovery_settings.mjs | 38 +++++++++++++++++++ 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 4e21be2ad4..af8995ba8c 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -171,7 +171,8 @@ export async function loadDeepScanExecutionSettings( serviceTier: (selected.service_tier as string | undefined) ?? native.serviceTier, ...(selected.service_tier === undefined && native.nativeServiceTierAbsent ? { nativeServiceTierAbsent: true as const } : {}), - providerConfig: selected.model_providers as JsonObject | undefined + providerConfig: selected.model_provider === "amazon-bedrock" + ? selected.model_providers as JsonObject | undefined : undefined }; } throw new Error("This Deep Scan has no recorded original execution settings; its executable and Codex home cannot be recovered."); @@ -238,23 +239,25 @@ export function restoredDeepScanWorkerSettings( ...(settings.codexHome === undefined ? {} : { CODEX_HOME: settings.codexHome }) }) .filter((entry): entry is [string, string] => entry[1] !== undefined)); }, - config: { + config: scanPreflightCodexConfig({ ...(settings.model === undefined ? {} : { model: settings.model }), ...(settings.reasoningEffort === undefined ? {} : { model_reasoning_effort: settings.reasoningEffort }), ...(settings.modelProvider === undefined ? {} : { model_provider: settings.modelProvider }), ...(settings.reasoningSummary === undefined ? {} : { model_reasoning_summary: settings.reasoningSummary }), ...(settings.serviceTier === undefined ? {} : { service_tier: settings.serviceTier }), - ...(settings.providerConfig === undefined ? {} : { model_providers: settings.providerConfig as NonNullable[string] }) - } + ...(settings.providerConfig === undefined ? {} : { model_providers: settings.providerConfig }) + }) as NonNullable } }; } function executionSettings(value: DeepScanExecutionSettings): DeepScanExecutionSettings { - const provider = scanPreflightCodexConfig({ + // Catalog provider definitions are reconstructed by the existing launch + // projection. Only Bedrock's per-scan AWS selectors need persistence. + const provider = value.modelProvider === "amazon-bedrock" ? scanPreflightCodexConfig({ ...(value.modelProvider === undefined ? {} : { model_provider: value.modelProvider }), ...(value.providerConfig === undefined ? {} : { model_providers: value.providerConfig }) - }).model_providers as JsonObject | undefined; + }).model_providers as JsonObject | undefined : undefined; const settings: DeepScanExecutionSettings = { codexPath: value.codexPath, codexHome: value.codexHome, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index e77bdc956b..3f71d83840 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { parse as parseToml } from "smol-toml"; const executorSource = new URL("../src/deep-scan/executor.ts", import.meta.url); const bundle = await build({ @@ -759,15 +760,17 @@ async function testIsolatedReconstructedWorkers() { await mkdir(codexHome); const config = { model: `fixture-${name}-inherited`, - model_provider: `fixture-${name}-provider`, + model_provider: name === "first" ? "openrouter" : "amazon-bedrock", model_reasoning_effort: "medium", model_reasoning_summary: "concise", service_tier: name === "first" ? "default" : "fast" }; await writeFile(configPath, Object.entries(config).filter(([key]) => name !== "first" || !["model_provider", "model_reasoning_summary", "service_tier"].includes(key)) - .map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("")); - await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE\n"); + .map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`).join("") + + (name === "second" ? '[model_providers.amazon-bedrock.aws]\nregion = "us-west-2"\nprofile = "fixture-profile"\n' : "")); + const providerKeys = name === "first" ? ["OPENROUTER_API_KEY"] : ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"]; + await writeFile(promptPath, "CAPTURE_SYNTHETIC_OPENAI_AUTH CAPTURE_SYNTHETIC_PROVIDER_AUTH NULL_USAGE\n"); const executable = path.join(fixture.root, process.platform === "win32" ? "node.exe" : "node"); // Keep dynamically linked Node beside its libraries on Unix. Each scan // still selects a distinct executable path at the spawn boundary. @@ -783,6 +786,7 @@ async function testIsolatedReconstructedWorkers() { CODEX_SECURITY_CONFIG_PATH: configPath, CODEX_API_KEY: `synthetic-${name}-credential`, FAKE_CODEX_MARKER: fixture.markerPath, + FAKE_CODEX_PROVIDER_ENV_KEYS: JSON.stringify(providerKeys), FAKE_CODEX_SCAN_VALUE: name } }; @@ -838,6 +842,10 @@ async function testIsolatedReconstructedWorkers() { const recordedScanDir = run.scanDir; const snapshotPath = path.join(recordedScanDir, "artifacts", "deep_discovery", "execution-settings.json"); const snapshot = await readFile(snapshotPath, "utf8"); + const expectedProvider = name === "first" ? undefined + : { "amazon-bedrock": { aws: { region: "us-west-2", profile: "fixture-profile" } } }; + assert.deepEqual(JSON.parse(snapshot).settings.providerConfig, expectedProvider, + "actual workbench creation saves selections without catalog definitions"); assert.deepEqual(await loadDeepScanExecutionSettings(recordedScanDir), saved); const claim = await store.claimCoordinator({ scanId: run.scanId, threadId: beginInput.threadId }); assert.equal(claim.acquired, true); @@ -851,7 +859,7 @@ async function testIsolatedReconstructedWorkers() { const runtimeEnvironment = { ...codexOptions.env }; const restored = restoredDeepScanWorkerSettings(saved, currentParentSandbox, () => runtimeEnvironment); restored.codexOptions.baseUrl = codexOptions.baseUrl; - scans.push({ name, fixture, recordedScanDir, currentParentSandbox, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, + scans.push({ name, fixture, recordedScanDir, currentParentSandbox, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, providerKeys, expectedProvider, executor: new CodexSdkWorkerExecutor(restored) }); } childProcess.spawn = (command, args, options) => { @@ -872,7 +880,10 @@ async function testIsolatedReconstructedWorkers() { if (phase === "reconstructed") await rm(scan.configPath); if (phase === "incomplete") { const saved = JSON.parse(scan.snapshot); - for (const key of ["model", "reasoningEffort", "modelProvider", "reasoningSummary"]) delete saved.settings[key]; + for (const key of ["model", "reasoningEffort", "reasoningSummary"]) delete saved.settings[key]; + // Native history restores the first provider. The second snapshot + // retains the provider binding for its saved AWS selectors. + if (scan.name === "first") delete saved.settings.modelProvider; if (scan.name === "first") delete saved.settings.serviceTier; await writeFile(scan.snapshotPath, JSON.stringify(saved)); } @@ -887,6 +898,7 @@ async function testIsolatedReconstructedWorkers() { } for (const scan of scans) { scan.runtimeEnvironment.CODEX_API_KEY = `synthetic-${scan.name}-${phase}`; + for (const key of scan.providerKeys) scan.runtimeEnvironment[key] = `synthetic-${scan.name}-${phase}-${key}`; scan.runtimeEnvironment.FAKE_CODEX_SCAN_VALUE = `${scan.name}-${phase}`; scan.runtimeEnvironment.CODEX_HOME = path.join(scan.fixture.root, "observer-home"); scan.runtimeEnvironment.CODEX_CLI_PATH = path.join(scan.fixture.root, "observer-codex"); @@ -897,7 +909,7 @@ async function testIsolatedReconstructedWorkers() { const result = await scan.executor.run({ kind, promptPath: scan.promptPath, workingDirectory: scan.fixture.root, subagents: scan.name === "first" ? 0 : 2, - resumeThreadId, continuationPrompt: "CAPTURE_SYNTHETIC_OPENAI_AUTH NULL_USAGE continuation", + resumeThreadId, continuationPrompt: "CAPTURE_SYNTHETIC_OPENAI_AUTH CAPTURE_SYNTHETIC_PROVIDER_AUTH NULL_USAGE continuation", signal: new AbortController().signal }); assert.equal(result.threadId, resumeThreadId ?? "fixture-thread-id"); @@ -910,6 +922,8 @@ async function testIsolatedReconstructedWorkers() { assert.equal(child.scanValue, `${scan.name}-${phase}`); assert.equal(child.configPath, scan.configPath); assert.deepEqual(child.openaiAuthentication, { CODEX_API_KEY: `synthetic-${scan.name}-${phase}` }); + assert.deepEqual(child.providerAuthentication, Object.fromEntries(scan.providerKeys + .map((key) => [key, `synthetic-${scan.name}-${phase}-${key}`]))); assertFlagPair(child.argv, "--model", scan.settings.model); for (const key of ["model_provider", "model_reasoning_summary", "service_tier"]) { const override = `${key}=${JSON.stringify(scan.config[key])}`; @@ -923,6 +937,11 @@ async function testIsolatedReconstructedWorkers() { assert.equal(child.argv.includes(baseUrl), true); assert.equal(preflight.argv.includes(baseUrl), true); for (const launch of [child, preflight]) { + const provider = launch.argv.filter((argument) => /^model_providers[.=]/u.test(argument)); + assert.ok(provider.length > 0, "both preflight and worker launch receive provider configuration"); + const providers = parseToml(provider.join("\n")).model_providers; + if (scan.expectedProvider) assert.deepEqual(providers, scan.expectedProvider); + else assert.deepEqual(Object.keys(providers.openrouter).sort(), ["base_url", "env_key", "name", "wire_api"]); assert.equal(workerPermissionProfileOverride(launch.argv).includes("glob_scan_max_depth"), false, "a resumed bounded cap must not truncate an original uncapped deny glob, in either order"); } @@ -1849,7 +1868,8 @@ async function fakeCodexFixture( "for await (const chunk of process.stdin) stdin += chunk;", "const openaiAuthentication = stdin.includes('CAPTURE_SYNTHETIC_OPENAI_AUTH') ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CODEX_API_KEY: process.env.CODEX_API_KEY } : undefined;", "const bedrockAuthentication = stdin.includes('CAPTURE_SYNTHETIC_BEDROCK_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_BEDROCK_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;", - "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, codexCliPath: process.env.CODEX_CLI_PATH, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}) }));", + "const providerAuthentication = stdin.includes('CAPTURE_SYNTHETIC_PROVIDER_AUTH') ? Object.fromEntries(JSON.parse(process.env.FAKE_CODEX_PROVIDER_ENV_KEYS).map((name) => [name, process.env[name]])) : undefined;", + "writeFileSync(process.env.FAKE_CODEX_MARKER, JSON.stringify({ executable: process.execPath, argv: process.argv.slice(2), stdin, cwd: process.cwd(), codexHome: process.env.CODEX_HOME, codexCliPath: process.env.CODEX_CLI_PATH, configPath: process.env.CODEX_SECURITY_CONFIG_PATH, scanValue: process.env.FAKE_CODEX_SCAN_VALUE, deepConfigPath: process.env.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH, originator: process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE, ...(stdin.includes('COMPLETE_THEN_HANG') ? { pid: process.pid } : {}), ...(openaiAuthentication ? { openaiAuthentication } : {}), ...(bedrockAuthentication ? { bedrockAuthentication } : {}), ...(providerAuthentication ? { providerAuthentication } : {}) }));", "if (stdin.includes('COMPLETE_THEN_HANG')) process.on('SIGTERM', () => setTimeout(() => process.exit(0), 100));", "if (stdin.includes('THREAD_START_CONFIG_ERROR')) { console.error('Error: thread/start: thread/start failed: agents.max_threads cannot be set when features.multi_agent_v2 is enabled (code -32600)'); process.exit(1); }", "if (stdin.includes('CONFIG_ERROR')) { console.error('failed to load configuration: invalid value'); process.exit(2); }", diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index c10080abd7..7518d5ee9b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -83,6 +83,44 @@ http_headers = { Authorization = "synthetic-secret" } assert.equal(captured.serviceTier, "flex"); assert.equal(captured.providerConfig, undefined); assert.equal(JSON.stringify(captured).includes("synthetic-secret"), false); + for (const modelProvider of ["openrouter", "fireworks", "amazon-bedrock"]) { + await writeFile(configPath, `model_provider = ${JSON.stringify(modelProvider)} +[model_providers.${modelProvider}.aws] +region = "us-west-2" +profile = "fixture-profile" +access_key_id = "synthetic-secret" +`); + const selected = await captureSettings({}, { filesystemDenies: [] }, { + CODEX_CLI_PATH: process.execPath, CODEX_HOME: root, CODEX_SECURITY_CONFIG_PATH: configPath + }); + assert.equal(selected.modelProvider, modelProvider); + const expectedProvider = modelProvider === "amazon-bedrock" + ? { "amazon-bedrock": { aws: { region: "us-west-2", profile: "fixture-profile" } } } : undefined; + assert.deepEqual(selected.providerConfig, expectedProvider, + "saved selections exclude catalog definitions and retain Bedrock selectors"); + const providerDir = join(root, modelProvider); + await writeSnapshot(providerDir, selected); + const path = join(providerDir, "artifacts", "deep_discovery", "execution-settings.json"); + const bytes = await readFile(path, "utf8"); + assert.equal(bytes.includes("synthetic-secret"), false); + const restoredProvider = restoreSettings(await loadSettings(providerDir), { filesystemDenies: [] }) + .codexOptions.config.model_providers; + if (expectedProvider) assert.deepEqual(restoredProvider, expectedProvider); + else assert.deepEqual(Object.keys(restoredProvider[modelProvider]).sort(), + ["base_url", "env_key", "name", "wire_api"]); + assert.equal(await readFile(path, "utf8"), bytes); + // Older snapshots can contain catalog definitions. Reading them must not + // rewrite their bytes or prevent the existing launch projection. + if (!expectedProvider) { + await writeSnapshot(providerDir, { ...selected, providerConfig: restoredProvider }); + const legacyBytes = await readFile(path, "utf8"); + const legacy = await loadSettings(providerDir); + assert.equal(legacy.providerConfig, undefined); + assert.deepEqual(restoreSettings(legacy, { filesystemDenies: [] }).codexOptions.config.model_providers, + restoredProvider); + assert.equal(await readFile(path, "utf8"), legacyBytes); + } + } let credential = "synthetic-first"; const restored = restoreSettings(captured, { filesystemDenies: ["/fixture/current-deny"] }, () => ({ CODEX_API_KEY: credential, CODEX_HOME: "/fixture/observer-home", CODEX_CLI_PATH: "/fixture/observer-codex" From c7ae86a4aaad92140d5a361701735dc6b3f966f9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:58:51 +0000 Subject: [PATCH 109/251] fix: cancel selected scans without a cleanup state read --- sdk/typescript/src/api.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 66097fe24c..33d89ac26d 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2420,6 +2420,7 @@ export class CodexSecurity { ) { // Cost stops model work, but an already selected result can still // finish through the local publisher. Caller cancellation remains live. + selectedDeepFinalization = true; await resumeSelectedDeepScan({ scanId: activeScan.id, threadId: budgetRecovery.threadId, @@ -2502,16 +2503,19 @@ export class CodexSecurity { observedScanThreadId ) { const workbenchOptions = { ...activeScan.options, signal: undefined }; - const saved = await workbench(workbenchOptions, [ - "get-deep-scan", - "--scan-id", - activeScan.id, - "--thread-id", - observedScanThreadId, - ]).catch(() => null); - const deep = saved?.["deepScan"]; - if (isRecord(deep) && isRecord(deep["finalizationInput"])) { - selectedDeepFinalization = true; + if (!selectedDeepFinalization) { + const saved = await workbench(workbenchOptions, [ + "get-deep-scan", + "--scan-id", + activeScan.id, + "--thread-id", + observedScanThreadId, + ]).catch(() => null); + const deep = saved?.["deepScan"]; + selectedDeepFinalization = + isRecord(deep) && isRecord(deep["finalizationInput"]); + } + if (selectedDeepFinalization) { // The workbench owns the running-state check and repeated cancellation. // A lost cleanup response must preserve the original interruption. await workbench(workbenchOptions, [ From 20d7cae76efe760f720b60303fabc994f9e4d102 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:29:33 +0000 Subject: [PATCH 110/251] test: cover selected cancellation with a lost cleanup state response --- .../tests-ts/deep-finalization.test.ts | 85 ++++++++++++++++--- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index d52947088e..04c783c36c 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -4,7 +4,10 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, expect, test } from "bun:test"; import type { ThreadEvent } from "@openai/codex-sdk"; -import { ScanCostLimitExceededError } from "../src/errors.js"; +import { + ScanCostLimitExceededError, + ScanInterruptedError, +} from "../src/errors.js"; import { prepareScanArtifactRestorer, runWorkbench, @@ -29,6 +32,7 @@ const outcomes = [ "restart", "canceled-before-publication", "canceled-during-publication", + "canceled-during-resumed-publication", "published-before-cancellation", "closed-during-publication", "budget-during-publication", @@ -44,7 +48,7 @@ type BudgetCompletionFault = "lost" | "before-commit" | "lost-and-canceled"; const cases: { outcome: (typeof outcomes)[number]; budgetCompletionFault?: BudgetCompletionFault; - cancellationFault?: "status-read" | "cancel-response"; + cancellationFault?: "status-read" | "deep-state-read" | "cancel-response"; }[] = [ ...outcomes.map((outcome) => ({ outcome })), ...( @@ -70,6 +74,14 @@ const cases: { outcome: "canceled-during-publication", cancellationFault: "cancel-response", }, + { + outcome: "canceled-during-publication", + cancellationFault: "deep-state-read", + }, + { + outcome: "canceled-during-resumed-publication", + cancellationFault: "deep-state-read", + }, ]; for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { const resumedStop = outcome.includes("-resumed-"); @@ -100,6 +112,9 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { CODEX_SECURITY_STATE_DIR: stateDir, }; const cancellation = new AbortController(); + const parentError = new Error( + "Parent turn ended before its final completion tool call", + ); let scanId = ""; let workbenchOptions: WorkbenchCommandOptions; let publicationFails = restart; @@ -108,6 +123,9 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { budgetCompletionFault === "lost" || budgetCompletionFault === "lost-and-canceled"; let budgetTriggered = false; + let cancellationReadLost = false; + let lostCancellationDeepState: unknown; + let originalFinalizationInput: unknown; let acceptedReport = ""; let completedArtifacts: Buffer[] = []; const modelInputs: string[] = []; @@ -171,6 +189,18 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { "Synthetic budget completion failure before commit", ); const result = await runWorkbench(options, args, input); + if ( + args[0] === "get-deep-scan" && + cancellation.signal.aborted && + cancellationFault === "deep-state-read" && + !cancellationReadLost + ) { + cancellationReadLost = true; + lostCancellationDeepState = result["deepScan"]; + throw new Error( + "Synthetic lost cancellation Deep state response", + ); + } if ( args[0] === "cancel-scan" && cancellationFault === "cancel-response" @@ -255,7 +285,8 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { } if ( args[0] === "write-scan-draft" && - outcome === "canceled-during-publication" + (outcome === "canceled-during-publication" || + outcome === "canceled-during-resumed-publication") ) { cancellation.abort("Synthetic user cancellation"); } @@ -366,7 +397,7 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { ), ); // Exercise the dedicated function bridge, without extending CLI arguments. - execFileSync( + const selectionOutput = execFileSync( "python3", [ "-c", @@ -383,11 +414,15 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { join(scanDir, "scan-manifest.json"), ], { - input: JSON.stringify({ resultPath: seeded.resultPath }), + input: JSON.stringify({ + resultPath: seeded.resultPath, + }), encoding: "utf8", env: environment, }, ); + originalFinalizationInput = + JSON.parse(selectionOutput).deepScan.finalizationInput; const sessions = join( codexHome, "sessions", @@ -418,9 +453,7 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { }, }; } else { - throw new Error( - "Parent turn ended before its final completion tool call", - ); + throw parentError; } } return { events: events() }; @@ -596,9 +629,31 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { return; } if (outcome.startsWith("canceled-")) { - await expect( - client.run(repository, { mode: "deep", signal: cancellation.signal }), - ).rejects.toThrow(/interrupted/); + const error = await client + .run(repository, { + mode: "deep", + signal: cancellation.signal, + ...(resumedStop + ? { resumeScanId: scanId, outputDir: scanDir } + : {}), + postScanPrompt: followUp, + }) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(ScanInterruptedError); + expect((error as ScanInterruptedError).cause).toBe( + outcome === "canceled-before-publication" + ? parentError + : cancellation.signal.reason, + ); + if (cancellationReadLost) { + expect(lostCancellationDeepState).toMatchObject({ + status: "running", + finalizationInput: { terminalReason: "saturated" }, + }); + } + expect(originalFinalizationInput).toMatchObject({ + terminalReason: "saturated", + }); const stopped = await runWorkbench( { ...workbenchOptions!, signal: undefined }, ["get-scan", "--scan-id", scanId], @@ -621,9 +676,13 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { ); expect(deep["deepScan"]).toMatchObject({ status: "canceled", - finalizationInput: { terminalReason: "saturated" }, }); - expect(commands).toContain("cancel-scan"); + expect( + (deep["deepScan"] as Record)["finalizationInput"], + ).toEqual(originalFinalizationInput); + expect( + commands.filter((command) => command === "cancel-scan"), + ).toHaveLength(1); expect(commands).not.toContain("fail-scan"); expect(modelInputs.length).toBe(1); if (cancellationFault === "cancel-response") { From 7f21c57da3930e85aa70949b91be56e985bbcbda Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 11:19:11 +0000 Subject: [PATCH 111/251] fix(deep-scan): keep recovered settings read-only --- .../src/deep-scan/recovery-settings.ts | 9 +-- .../mcp-app/tests/test_deep_scan_executor.mjs | 4 +- .../test_deep_scan_recovery_settings.mjs | 57 +++++++++++++++++-- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index af8995ba8c..2f07eb8e99 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -6,7 +6,6 @@ import { parse as parseToml } from "smol-toml"; import { scanPreflightCodexConfig } from "../../../../../sdk/typescript/src/preflight-config.js"; import { resolveCodexProfile, type JsonObject } from "../../../../../sdk/typescript/src/config.js"; import { readScanLogs } from "../../../../../sdk/typescript/src/scan-logs.js"; -import { writeJsonAtomic } from "./artifacts.js"; import { resolveCodexPath } from "./executor.js"; import type { DeepWorkerParentSandbox } from "./parent-sandbox.js"; import type { DeepScanRunState } from "./types.js"; @@ -186,7 +185,9 @@ export async function loadDeepScanExecutionSettings( const native = !owner?.threadId ? {} : await originalParentSettings(settings.codexHome, { ...owner, threadId: owner.threadId, startedAt: original.createdAt }); - const recovered = executionSettings({ + // History reads can outlive this coordinator. Project missing selections for + // its workers without overwriting a snapshot owned by a newer coordinator. + return executionSettings({ ...settings, model: settings.model ?? original.model ?? native.model, reasoningEffort: settings.reasoningEffort ?? original.reasoningEffort ?? native.reasoningEffort, @@ -196,10 +197,6 @@ export async function loadDeepScanExecutionSettings( ...(settings.serviceTier === undefined && native.nativeServiceTierAbsent ? { nativeServiceTierAbsent: true as const } : {}) }); - if (JSON.stringify(recovered) !== JSON.stringify(settings)) { - await writeJsonAtomic(path, { version: 1, settings: recovered }); - } - return recovered; } export function restoredDeepScanWorkerSettings( diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs index 3f71d83840..e1357d5c64 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.mjs @@ -887,13 +887,15 @@ async function testIsolatedReconstructedWorkers() { if (scan.name === "first") delete saved.settings.serviceTier; await writeFile(scan.snapshotPath, JSON.stringify(saved)); } + const snapshotBeforeRead = await readFile(scan.snapshotPath, "utf8"); const recorded = await loadDeepScanExecutionSettings(scan.recordedScanDir, { ...scan.settings, createdAt: "2026-01-01T00:01:00Z" }); const restored = restoredDeepScanWorkerSettings(recorded, scan.currentParentSandbox, () => scan.runtimeEnvironment); restored.codexOptions.baseUrl = scan.settings.codexOptions.baseUrl; scan.executor = new CodexSdkWorkerExecutor(restored); - assert.equal(await readFile(scan.snapshotPath, "utf8"), scan.snapshot); + assert.equal(await readFile(scan.snapshotPath, "utf8"), snapshotBeforeRead, + "restoring original worker selections must not rewrite saved settings"); } } for (const scan of scans) { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 7518d5ee9b..a32622d21f 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; @@ -296,14 +299,60 @@ access_key_id = "synthetic-secret" await writeFile(join(root, "config.toml"), 'model_provider = "observer-provider"\nmodel_reasoning_summary = "detailed"\n'); const originalRun = { model: "stored-model", reasoningEffort: "ultra", usageOwner: originalOwner, createdAt: "2026-01-01T00:01:00Z" }; + const raceDir = join(root, "concurrent-recovery"); + await writeSnapshot(raceDir, incomplete); + const racePath = join(raceDir, "artifacts", "deep_discovery", "execution-settings.json"); + const originalCreateReadStream = fs.createReadStream; + const readingHistory = Promise.withResolvers(); + const releaseHistory = Promise.withResolvers(); + let held = false; + let pendingRead; + fs.createReadStream = (path, options) => { + const source = originalCreateReadStream(path, options); + if (path !== join(sessionDirectory, "parent.jsonl") || held) return source; + held = true; + const delayed = new PassThrough(); + source.once("error", (error) => delayed.destroy(error)); + delayed.once("close", () => source.destroy()); + void releaseHistory.promise.then(() => source.pipe(delayed)); + readingHistory.resolve(); + return delayed; + }; + syncBuiltinESMExports(); + try { + pendingRead = loadSettings(raceDir, originalRun); + await Promise.race([readingHistory.promise, pendingRead.then(() => + assert.fail("historical recovery must reach the controlled history read"))]); + const newer = { ...settings, modelProvider: "newer-provider", reasoningSummary: "concise" }; + await writeSnapshot(raceDir, newer); + const newerBytes = await readFile(racePath, "utf8"); + releaseHistory.resolve(); + const delayedProjection = await pendingRead; + assert.equal(delayedProjection.modelProvider, "openai"); + assert.equal(delayedProjection.reasoningSummary, "none"); + assert.equal(await readFile(racePath, "utf8"), newerBytes, + "a delayed historical projection must not overwrite a newer snapshot"); + assert.deepEqual(await loadSettings(raceDir), newer); + } finally { + releaseHistory.resolve(); + await pendingRead?.catch(() => {}); + fs.createReadStream = originalCreateReadStream; + syncBuiltinESMExports(); + } + const incompletePath = join(incompleteDir, "artifacts", "deep_discovery", "execution-settings.json"); + const incompleteBytes = await readFile(incompletePath, "utf8"); const repaired = await loadSettings(incompleteDir, originalRun); assert.deepEqual(repaired, { ...incomplete, model: "stored-model", reasoningEffort: "ultra", modelProvider: "openai", reasoningSummary: "none" }); - const repairedPath = join(incompleteDir, "artifacts", "deep_discovery", "execution-settings.json"); - const repairedBytes = await readFile(repairedPath, "utf8"); + assert.equal(await readFile(incompletePath, "utf8"), incompleteBytes, + "recovering historical fields is read-only"); await rm(sessionDirectory, { recursive: true }); - assert.deepEqual(await loadSettings(incompleteDir, originalRun), repaired); - assert.equal(await readFile(repairedPath, "utf8"), repairedBytes, "recovered selections survive unavailable history"); + const unavailable = await loadSettings(incompleteDir, originalRun); + assert.equal(unavailable.model, "stored-model"); + assert.equal(unavailable.reasoningEffort, "ultra"); + assert.equal(unavailable.modelProvider, undefined, "unavailable history remains unknown"); + assert.equal(unavailable.reasoningSummary, undefined); + assert.equal(await readFile(incompletePath, "utf8"), incompleteBytes); const unknownDir = join(root, "unknown"); await writeSnapshot(unknownDir, incomplete); const unknown = await loadSettings(unknownDir, { ...originalRun, usageOwner: null }); From af499a49d59b13b6925a57cd58b02266d96a067d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:03:02 +0000 Subject: [PATCH 112/251] fix: finalize owned cost-limited Deep scans from accepted evidence --- .../scripts/deep_scan_workbench.py | 117 ++++++++- .../codex-security/scripts/workbench_db.py | 201 ++++----------- .../scripts/workbench_saved_results.py | 216 ++++++++++++++++ .../test_budget_selection_publication.py | 244 ++++++++++++++++++ .../codex-security/tests/test_workbench_db.py | 38 ++- sdk/typescript/src/api.ts | 6 +- 6 files changed, 658 insertions(+), 164 deletions(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 740331675b..a1cb47617c 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -19,9 +19,17 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from deep_scan_config import resolve_deep_scan_config from filesystem_identity import serialize_filesystem_identity -from finalize_scan_contract import _read_scan_local_json, write_scan_local_bytes +from finalize_scan_contract import ( + _read_scan_local_json, + open_scan_local_file_descriptor, + write_scan_local_bytes, +) from workbench.handoff import require_current_continuation -from workbench_saved_results import _worker_checkpoint_head +from workbench_saved_results import ( + _restore_published_outputs, + _snapshot_published_outputs, + _worker_checkpoint_head, +) from workbench_scan_usage import capture_scan_usage_owner from workbench_target import ( directory_content_digest, @@ -29,7 +37,12 @@ git_revision, worktree_content_digest, ) -from workbench_validation import optional_text, require_uuid, user_context_argument +from workbench_validation import ( + optional_text, + reject_non_finite_json, + require_uuid, + user_context_argument, +) DEEP_SCAN_WORKER_KINDS = ("setup", "discovery", "dedup") DEEP_SCAN_WORKER_STATUSES = ("queued", "running", "succeeded", "failed", "canceled") @@ -2495,6 +2508,104 @@ def selected_deep_scan_finalization( return selection +def budget_unmerged_workers(connection: sqlite3.Connection, scan_id: str) -> list[sqlite3.Row]: + return connection.execute( + "SELECT workers.*, attempts.accepted_result_path, attempts.accepted_result_sha256 " + "FROM deep_scan_workers AS workers JOIN deep_scan_attempts AS attempts " + "ON attempts.worker_id = workers.id AND attempts.attempt = workers.attempt " + "WHERE workers.scan_id = ? AND workers.kind = 'discovery' " + "AND workers.status = 'succeeded' AND workers.merge_state IN ('buffered', 'merging') " + "ORDER BY workers.completion_sequence, workers.id", + (scan_id,), + ).fetchall() + + +def prepare_budget_exhausted_deep_scan( + connection: sqlite3.Connection, + scan: sqlite3.Row, + scan_dir: Path, + warning: str, + write_draft: Callable[[dict[str, Any] | None, list[sqlite3.Row]], None], +) -> None: + """Finish local budget publication without starting or promoting scan work.""" + snapshots = _snapshot_published_outputs(scan_dir) + connection.execute("BEGIN IMMEDIATE") + try: + # The completion lock and this transaction preserve any committed selector. + run, scan = require_running_deep_scan(connection, scan["id"]) + unmerged = budget_unmerged_workers(connection, scan["id"]) + if run["finalization_input_json"] is not None: + selection = json.loads(run["finalization_input_json"]) + else: + reducer = connection.execute( + "SELECT attempts.accepted_result_path FROM deep_scan_workers AS workers " + "LEFT JOIN deep_scan_attempts AS attempts ON attempts.worker_id = workers.id " + "AND attempts.attempt = workers.attempt WHERE workers.scan_id = ? " + "AND workers.kind = 'dedup' AND workers.status = 'succeeded' " + "ORDER BY workers.completed_at DESC, workers.id DESC LIMIT 1", + (scan["id"],), + ).fetchone() + if reducer is not None and not reducer["accepted_result_path"]: + raise SystemExit("Budget completion requires the committed reducer reference.") + selection = selected_deep_scan_finalization( + connection, + run, + scan, + argparse.Namespace( + finalization_result_path=reducer["accepted_result_path"] if reducer else None, + terminal_reason="capped", + ), + [row["id"] for row in unmerged], + reducer is None, + ) + accepted = None + if selection["resultPath"] is not None: + descriptor = open_scan_local_file_descriptor( + scan_dir, selection["resultPath"], "Accepted reducer" + ) + with os.fdopen(descriptor, "rb") as source: + contents = source.read() + if hashlib.sha256(contents).hexdigest() != selection["resultSha256"]: + raise SystemExit("The accepted reducer changed before budget completion.") + accepted = json.loads(contents, parse_constant=reject_non_finite_json) + if ( + not isinstance(accepted, dict) + or accepted.get("scanId") != scan["id"] + or accepted.get("complete", True) is not True + or not isinstance(accepted.get("sourceCoverage"), dict) + ): + raise SystemExit("Budget completion requires the accepted complete reducer.") + write_draft(accepted, unmerged) + timestamp = now() + cancel_active_workers(connection, scan["id"], timestamp) + connection.execute( + "UPDATE deep_scan_runs SET status = 'succeeded', phase = 'terminal', " + "terminal_reason = ?, cancel_requested = 1, error_message = ?, " + "manifest_path = ?, finalization_input_json = ?, completed_at = ?, updated_at = ? " + "WHERE scan_id = ?", + ( + selection["terminalReason"], + warning, + str(scan_dir / "scan-manifest.json"), + json.dumps(selection), + timestamp, + timestamp, + scan["id"], + ), + ) + warnings = json.loads(scan["completion_warnings_json"]) + if warning not in warnings: + connection.execute( + "UPDATE scans SET completion_warnings_json = ? WHERE id = ?", + (json.dumps([*warnings, warning]), scan["id"]), + ) + connection.commit() + except BaseException: + connection.rollback() + _restore_published_outputs(scan_dir, snapshots) + raise + + def fail_deep_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: scan_id = require_uuid(args.scan_id, "scan-id") with scan_completion_lock(scan_id): diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index d383d3a200..f5aab75afc 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -54,9 +54,7 @@ _prepare_scan_finalization, _write_prepared_scan_finalization, finalize_scan, - finding_candidate_id, open_scan_local_file_descriptor, - write_scan_local_bytes, ) from finding_preview import bounded_finding_details from workbench import handoff @@ -1176,10 +1174,15 @@ def complete_budget_exhausted_scan( ): raise SystemExit("Deep Scan has not exceeded its configured cost limit.") run = connection.execute( - "SELECT status, terminal_reason, manifest_path FROM deep_scan_runs WHERE scan_id = ?", + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,), ).fetchone() - if ( + before_selection = ( + run is not None + and run["status"] == "running" + and run["workflow_version"] == "deep-security-scan/v2" + ) + if not before_selection and ( run is None or run["status"] != "succeeded" or run["terminal_reason"] not in {"saturated", "capped"} @@ -1192,16 +1195,44 @@ def complete_budget_exhausted_scan( scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) candidates = ( [] - if run["manifest_path"] == str(scan_dir / "scan-manifest.json") + if before_selection or run["manifest_path"] == str(scan_dir / "scan-manifest.json") else budget_exhausted_candidates(scan, scan_dir) ) warning = optional_text(args.message, maximum=2400) if warning is None: + retained = ( + "saved work was retained" + if before_selection + else "completed discovery was preserved" + ) warning = ( f"Deep Scan reached its cost limit after an estimated " - f"${measured['estimatedUsd']:.6g}; completed discovery was preserved." + f"${measured['estimatedUsd']:.6g}; {retained}." + ) + if before_selection: + deep_scan.prepare_budget_exhausted_deep_scan( + connection, + scan, + scan_dir, + warning, + lambda accepted, unmerged: budget_exhausted_draft( + scan, + scan_dir, + [], + warning, + before_selection=True, + accepted_result=accepted, + unmerged_workers=unmerged, + ), + ) + else: + budget_exhausted_draft( + scan, + scan_dir, + candidates, + warning, + unmerged_workers=deep_scan.budget_unmerged_workers(connection, scan_id), ) - budget_exhausted_draft(scan, scan_dir, candidates, warning) warnings = json.loads(scan["completion_warnings_json"]) if warning not in warnings: connection.execute( @@ -1289,148 +1320,22 @@ def budget_exhausted_draft( scan_dir: Path, candidates: list[dict[str, Any]], warning: str, + *, + before_selection: bool = False, + accepted_result: dict[str, Any] | None = None, + unmerged_workers: list[sqlite3.Row] = (), ) -> None: - documents: dict[str, dict[str, Any]] = {} - for name in ("scan-manifest.json", "findings.json", "coverage.json"): - path = artifact_path(scan_dir, name, required=False) - if path is not None: - documents[name] = read_json_object(path) - if documents and len(documents) != 3: - raise SystemExit("Budget-exhausted scan contains an incomplete canonical scan draft.") - - if documents: - manifest = documents["scan-manifest.json"] - findings = documents["findings.json"] - coverage = documents["coverage.json"] - if not isinstance(manifest.get("scan"), dict) or not isinstance( - findings.get("findings"), list - ): - raise SystemExit("Budget-exhausted scan contains an invalid canonical scan draft.") - for key in ("surfaces", "explicitExclusions", "deferred"): - if not isinstance(coverage.get(key), list): - raise SystemExit("Budget-exhausted scan contains invalid canonical coverage.") - if manifest["scan"].get("sealedAt") is not None or manifest["scan"].get("artifacts"): - raise SystemExit("Budget-exhausted scan cannot replace an already sealed scan draft.") - else: - contract = scan_contract(scan) - target_contract = contract["target"] - target: dict[str, Any] = { - "kind": target_contract["allowedKinds"][0], - "targetId": target_contract["targetId"], - "displayName": target_contract["displayName"], - } - if scan["target_revision"] != "unversioned": - target["revision"] = scan["target_revision"] - if "requiredSnapshotDigest" in target_contract: - target["snapshotDigest"] = target_contract["requiredSnapshotDigest"] - manifest = { - "scan": { - "target": target, - "scope": {"limitations": [warning], "validationMode": "incomplete"}, - } - } - findings = {"findings": []} - coverage = { - "completeness": "partial", - "inventoryStrategy": ( - "scoped_path" if expected_coverage_mode(scan) == "scoped_path" else "repository" - ), - "surfaces": [], - "explicitExclusions": [], - "deferred": [], - } - - findings_by_candidate = { - candidate_id - for finding in findings["findings"] - if isinstance(finding, dict) - and isinstance(candidate_id := finding_candidate_id(finding), str) - } - existing_deferred = { - item.get("candidateId", item.get("id")) - for item in coverage["deferred"] - if isinstance(item, dict) and isinstance(item.get("candidateId", item.get("id")), str) - } - existing_surfaces = { - item.get("id") - for item in coverage["surfaces"] - if isinstance(item, dict) and isinstance(item.get("id"), str) - } - for candidate in candidates: - candidate_id = candidate["candidate_id"] - if candidate_id in findings_by_candidate or candidate_id in existing_deferred: - continue - paths = list(dict.fromkeys(location["path"] for location in candidate["locations"])) - surface_id = f"candidate-{candidate_id}" - validation = candidate.get("validation") - validation = validation.get("disposition") if isinstance(validation, dict) else None - attack = candidate.get("attack_path") - attack = attack.get("decision") if isinstance(attack, dict) else None - disposition = ( - "needs_follow_up" - if validation == "deferred" or attack == "deferred" - else "not_applicable" - if validation == "not_applicable" - else "rejected" - if validation == "suppressed" or attack == "ignore" - else "needs_follow_up" - ) - if surface_id not in existing_surfaces: - coverage["surfaces"].append( - { - "id": surface_id, - "label": candidate["summary"], - "disposition": disposition, - "notes": candidate["evidence"], - "receiptRefs": [], - } - ) - existing_surfaces.add(surface_id) - if disposition != "needs_follow_up": - continue - coverage["deferred"].append( - { - "id": candidate_id, - "candidateId": candidate_id, - "reason": ( - "Validation was deferred because the scan reached its cost limit: " - f"{candidate['summary']}. Evidence: {candidate['evidence']}" - ), - "paths": paths, - "surfaceIds": [surface_id], - } - ) - if not any( - isinstance(item, dict) - and isinstance(reason := item.get("reason"), str) - and ( - reason == "Validation was deferred because the scan reached its cost limit." - or reason.startswith( - "Validation was deferred because the scan reached its cost limit: " - ) - ) - for item in coverage["deferred"] - ): - coverage["deferred"].append( - { - "id": "scan-cost-limit", - "reason": "Validation was deferred because the scan reached its cost limit.", - } - ) - coverage["completeness"] = "partial" - for name, payload in ( - ("findings.json", findings), - ("coverage.json", coverage), - ("scan-manifest.json", manifest), - ): - try: - write_scan_local_bytes( - scan_dir, - name, - (json.dumps(payload, allow_nan=False, indent=2, sort_keys=True) + "\n").encode(), - ) - except (ContractError, OSError, TypeError, ValueError) as exc: - raise SystemExit(f"Budget-exhausted scan draft could not be saved: {exc}") from exc + saved_results.budget_exhausted_draft( + _WORKBENCH_DB_CONTEXT, + scan, + scan_dir, + candidates, + warning, + scan_contract(scan), + before_selection=before_selection, + accepted_result=accepted_result, + unmerged_workers=unmerged_workers, + ) def complete_scan_locked( diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 175e3b3045..d87d797f00 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1224,6 +1224,222 @@ def coverage_candidate(owner: str | None, item: dict[str, Any]) -> tuple[str | N return manifest, {"findings": findings}, coverage +def budget_exhausted_draft( + db: WorkbenchDbContext, + scan: sqlite3.Row, + scan_dir: Path, + candidates: list[dict[str, Any]], + warning: str, + contract: dict[str, Any], + *, + before_selection: bool = False, + accepted_result: dict[str, Any] | None = None, + unmerged_workers: list[sqlite3.Row] = (), +) -> None: + documents: dict[str, dict[str, Any]] = {} + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + path = db.artifact_path(scan_dir, name, required=False) + if path is not None: + documents[name] = db.read_json_object(path) + if documents and len(documents) != 3: + raise SystemExit("Budget-exhausted scan contains an incomplete canonical scan draft.") + + if documents: + manifest = documents["scan-manifest.json"] + findings = documents["findings.json"] + coverage = documents["coverage.json"] + if not isinstance(manifest.get("scan"), dict) or not isinstance( + findings.get("findings"), list + ): + raise SystemExit("Budget-exhausted scan contains an invalid canonical scan draft.") + for key in ("surfaces", "explicitExclusions", "deferred"): + if not isinstance(coverage.get(key), list): + raise SystemExit("Budget-exhausted scan contains invalid canonical coverage.") + if manifest["scan"].get("sealedAt") is not None or manifest["scan"].get("artifacts"): + raise SystemExit("Budget-exhausted scan cannot replace an already sealed scan draft.") + else: + target_contract = contract["target"] + target: dict[str, Any] = { + "kind": target_contract["allowedKinds"][0], + "targetId": target_contract["targetId"], + "displayName": target_contract["displayName"], + } + if scan["target_revision"] != "unversioned": + target["revision"] = scan["target_revision"] + if "requiredSnapshotDigest" in target_contract: + target["snapshotDigest"] = target_contract["requiredSnapshotDigest"] + manifest = { + "scan": { + "target": target, + "scope": {"limitations": [warning], "validationMode": "incomplete"}, + } + } + findings = {"findings": []} + coverage = { + "completeness": "partial", + "inventoryStrategy": ( + "scoped_path" if db.expected_coverage_mode(scan) == "scoped_path" else "repository" + ), + "surfaces": [], + "explicitExclusions": [], + "deferred": [], + } + + if before_selection: + # Unmerged discoveries remain in their accepted artifacts. Only the + # committed reducer may contribute findings to this partial result. + findings = {"findings": accepted_result["findings"] if accepted_result else []} + if accepted_result is not None: + coverage = accepted_result["sourceCoverage"] + if "threatModel" in accepted_result: + manifest["scan"]["threatModel"] = accepted_result["threatModel"] + + for worker in unmerged_workers: + retain_unmerged_budget_coverage(scan, scan_dir, coverage, worker) + + findings_by_candidate = { + candidate_id + for finding in findings["findings"] + if isinstance(finding, dict) + and isinstance(candidate_id := finding_candidate_id(finding), str) + } + existing_deferred = { + item.get("candidateId", item.get("id")) + for item in coverage["deferred"] + if isinstance(item, dict) and isinstance(item.get("candidateId", item.get("id")), str) + } + existing_surfaces = { + item.get("id") + for item in coverage["surfaces"] + if isinstance(item, dict) and isinstance(item.get("id"), str) + } + for candidate in candidates: + candidate_id = candidate["candidate_id"] + if candidate_id in findings_by_candidate or candidate_id in existing_deferred: + continue + paths = list(dict.fromkeys(location["path"] for location in candidate["locations"])) + surface_id = f"candidate-{candidate_id}" + validation = candidate.get("validation") + validation = validation.get("disposition") if isinstance(validation, dict) else None + attack = candidate.get("attack_path") + attack = attack.get("decision") if isinstance(attack, dict) else None + disposition = ( + "needs_follow_up" + if validation == "deferred" or attack == "deferred" + else "not_applicable" + if validation == "not_applicable" + else "rejected" + if validation == "suppressed" or attack == "ignore" + else "needs_follow_up" + ) + if surface_id not in existing_surfaces: + coverage["surfaces"].append( + { + "id": surface_id, + "label": candidate["summary"], + "disposition": disposition, + "notes": candidate["evidence"], + "receiptRefs": [], + } + ) + existing_surfaces.add(surface_id) + if disposition != "needs_follow_up": + continue + coverage["deferred"].append( + { + "id": candidate_id, + "candidateId": candidate_id, + "reason": ( + "Validation was deferred because the scan reached its cost limit: " + f"{candidate['summary']}. Evidence: {candidate['evidence']}" + ), + "paths": paths, + "surfaceIds": [surface_id], + } + ) + if not any( + isinstance(item, dict) + and isinstance(reason := item.get("reason"), str) + and ( + reason == "Validation was deferred because the scan reached its cost limit." + or reason.startswith( + "Validation was deferred because the scan reached its cost limit: " + ) + ) + for item in coverage["deferred"] + ): + coverage["deferred"].append( + { + "id": "scan-cost-limit", + "reason": "Validation was deferred because the scan reached its cost limit.", + } + ) + coverage["completeness"] = "partial" + for name, payload in ( + ("findings.json", findings), + ("coverage.json", coverage), + ("scan-manifest.json", manifest), + ): + try: + write_scan_local_bytes( + scan_dir, + name, + (json.dumps(payload, allow_nan=False, indent=2, sort_keys=True) + "\n").encode(), + ) + except (ContractError, OSError, TypeError, ValueError) as exc: + raise SystemExit(f"Budget-exhausted scan draft could not be saved: {exc}") from exc + + +def retain_unmerged_budget_coverage( + scan: sqlite3.Row, scan_dir: Path, coverage: dict[str, Any], worker: sqlite3.Row +) -> None: + """Keep each unmerged review's obligations; its findings remain evidence only.""" + relative = Path(worker["accepted_result_path"]).relative_to(scan_dir).as_posix() + draft, _ = _read_saved_result( + scan_dir, + relative, + scan["id"], + accepted_source_digests={worker["accepted_result_path"]: worker["accepted_result_sha256"]}, + ) + source = draft["coverage"] + provenance = {"workerId": worker["id"], "attempt": worker["attempt"]} + prefix = f"{worker['id']}-attempt-{worker['attempt']}" + artifact_prefix = Path(worker["artifact_dir"]).relative_to(scan_dir).as_posix() + surfaces = { + item.get("id"): f"{prefix}-surface-{index + 1}" + for index, item in enumerate(source.get("surfaces", [])) + } + for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions"): + for index, original in enumerate(source.get(field, [])): + item = copy.deepcopy(original if isinstance(original, dict) else {"question": original}) + item["provenance"] = { + **provenance, + **({"sourceId": item["id"]} if "id" in item else {}), + **({"candidateId": item["candidateId"]} if "candidateId" in item else {}), + } + item["id"] = f"{prefix}-{field}-{index + 1}" + if field == "surfaces": + item["id"] = surfaces.get(original.get("id"), item["id"]) + item["receiptRefs"] = [ + f"{artifact_prefix}/{ref}" for ref in item.get("receiptRefs", []) + ] + if field == "deferred" and "candidateId" in item: + item["candidateId"] = f"{prefix}-candidate-{index + 1}" + if "surfaceIds" in item: + item["surfaceIds"] = [surfaces.get(value, value) for value in item["surfaceIds"]] + coverage.setdefault(field, []).append(item) + coverage.setdefault("reviews", []).append( + {**provenance, "completeness": source["completeness"]} + ) + coverage["deferred"].append( + { + "id": f"{prefix}-unmerged", + "provenance": provenance, + "reason": "This accepted discovery was not merged before the scan reached its cost limit.", + } + ) + + def coverage_for_comparison(db: Any, scan: Any) -> dict[str, Any]: if scan["seal_manifest_digest"] is None: raise SystemExit("Only sealed scans can be compared.") diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index 40b7e91b58..eb06d2bf0a 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import sqlite3 from argparse import Namespace @@ -7,11 +8,254 @@ import pytest from test_accepted_publication_references import accept_reducer from test_deep_scan_publication_authority import stage_publication +from test_deep_scan_successful_publication import add_worker from test_deep_scan_successful_publication import publication_scan as publication_scan from test_publication_stop_interleavings import published_bytes, saved_selection from test_workbench_db import BUDGET_COST +def accept_unmerged(connection, scan): + result = add_worker(connection, scan) + worker_id = result.parent.name + result.write_text( + json.dumps( + { + "scanId": scan.scan_id, + "complete": True, + "findings": scan.findings, + "coverage": { + **scan.coverage, + "completeness": "partial", + "deferred": [ + { + "id": "shared-gap", + "reason": "Independent unresolved review.", + "paths": ["subdir/extract.py"], + } + ], + }, + } + ) + ) + digest = hashlib.sha256(result.read_bytes()).hexdigest() + accepted = result.parent / "accepted" / f"{digest}.json" + accepted.parent.mkdir() + accepted.write_bytes(result.read_bytes()) + with connection: + connection.execute( + "UPDATE deep_scan_workers SET merge_state = 'buffered' WHERE id = ?", (worker_id,) + ) + connection.execute( + "INSERT INTO deep_scan_attempts (scan_id, worker_id, attempt, status, started_at, " + "completed_at, accepted_result_path, accepted_result_sha256) VALUES (?, ?, 1, 'succeeded', ?, ?, ?, ?)", + (scan.scan_id, worker_id, scan.timestamp, scan.timestamp, str(accepted), digest), + ) + return accepted + + +@pytest.mark.parametrize("defect", ["changed-bytes", "incomplete-result"]) +def test_cost_before_selection_does_not_replace_invalid_accepted_evidence( + workbench_api, workbench_db, publication_scan, defect +): + scan = publication_scan() + _, accepted, _ = accept_reducer(workbench_db, scan) + document = json.loads(accepted.read_bytes()) + document["complete"] = False + accepted.write_text(json.dumps(document)) + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'discovery', " + "workflow_version = 'deep-security-scan/v2', finalization_input_json = NULL" + ) + if defect == "incomplete-result": + workbench_db.execute( + "UPDATE deep_scan_attempts SET accepted_result_sha256 = ?", + (hashlib.sha256(accepted.read_bytes()).hexdigest(),), + ) + before = published_bytes(scan) + with pytest.raises(SystemExit): + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None), + ) + assert published_bytes(scan) == before + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + assert ( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + is None + ) + + +@pytest.mark.parametrize("accepted_kind", ["none", "unmerged", "reducer"]) +@pytest.mark.parametrize("cancel_first", [False, True]) +def test_cost_before_selection_retains_only_merged_findings( + workbench_api, workbench_db, publication_scan, accepted_kind, cancel_first +): + scan = publication_scan() + accepted = None + if accepted_kind == "reducer": + _, accepted, _ = accept_reducer(workbench_db, scan) + elif accepted_kind == "unmerged": + accepted = accept_unmerged(workbench_db, scan) + active = add_worker(workbench_db, scan, status="running") + active_id = active.parent.name + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_workers SET completed_at = NULL WHERE id = ?", (active_id,) + ) + workbench_db.execute( + "INSERT INTO deep_scan_attempts (scan_id, worker_id, attempt, status, started_at) " + "VALUES (?, ?, 1, 'running', ?)", + (scan.scan_id, active_id, scan.timestamp), + ) + counters = tuple( + workbench_db.execute( + "SELECT discovery_runs_dispatched, consecutive_no_new, completion_sequence FROM deep_scan_runs" + ).fetchone() + ) + original = accepted.read_bytes() if accepted is not None else None + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'discovery', " + "workflow_version = 'deep-security-scan/v2', manifest_path = NULL, " + "terminal_reason = NULL, completed_at = NULL, max_discovery_runs = 100" + ) + args = Namespace( + scan_id=scan.scan_id, + cost_json=json.dumps(BUDGET_COST), + message="Scan reached its original cost limit.", + ) + if cancel_first: + workbench_api["cancel_scan"](workbench_db, Namespace(scan_id=scan.scan_id, thread_id=None)) + before = published_bytes(scan) + with pytest.raises(SystemExit, match="running"): + workbench_api["complete_budget_exhausted_scan"](workbench_db, args) + assert published_bytes(scan) == before + else: + workbench_api["complete_budget_exhausted_scan"](workbench_db, args) + row = workbench_db.execute("SELECT * FROM scans").fetchone() + run = workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone() + assert row["status"] == "complete" + assert run["terminal_reason"] == "capped" + assert run["error_message"] == args.message + assert args.message in json.loads(row["completion_warnings_json"]) + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert len(findings) == (1 if accepted_kind == "reducer" else 0) + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert coverage["completeness"] == "partial" + assert any(item["id"] == "scan-cost-limit" for item in coverage["deferred"]) + if accepted_kind == "reducer": + selection = json.loads(run["finalization_input_json"]) + assert selection["resultSha256"] == hashlib.sha256(original).hexdigest() + assert any(item["id"] == "accepted-follow-up" for item in coverage["deferred"]) + assert ( + tuple( + workbench_db.execute( + "SELECT discovery_runs_dispatched, consecutive_no_new, completion_sequence FROM deep_scan_runs" + ).fetchone() + ) + == counters + ) + interrupted = workbench_db.execute( + "SELECT * FROM deep_scan_attempts WHERE worker_id = ?", (active_id,) + ).fetchone() + assert interrupted["status"] == "canceled" + assert interrupted["end_reason"] == "scan_stopped" + if accepted_kind == "unmerged": + assert any( + item.get("provenance", {}).get("sourceId") == "shared-gap" + for item in coverage["deferred"] + ) + before = published_bytes(scan) + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert published_bytes(scan) == before + if accepted is not None: + assert accepted.read_bytes() == original + + +@pytest.mark.parametrize("reason", ["saturated", "capped"]) +def test_budget_keeps_selection_committed_before_transaction_and_unmerged_obligations( + workbench_api, workbench_db, publication_scan, reason +): + scan = publication_scan() + _, accepted, original_coverage = accept_reducer(workbench_db, scan) + unmerged = [accept_unmerged(workbench_db, scan) for _ in range(2)] + originals = {path: path.read_bytes() for path in [accepted, *unmerged]} + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', workflow_version = 'deep-security-scan/v2'" + ) + # The selected publication may commit before cost recovery reaches its + # transaction. Budget completion must use that exact input and cause. + committed = saved_selection(workbench_db, scan, accepted, reason=reason) + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message="Original cost stop." + ), + ) + run = workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone() + assert json.loads(run["finalization_input_json"]) == committed + assert run["terminal_reason"] == reason + assert all(path.read_bytes() == contents for path, contents in originals.items()) + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert len(findings) == 1 + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert original_coverage["deferred"][0] in coverage["deferred"] + gaps = [ + item + for item in coverage["deferred"] + if item.get("provenance", {}).get("sourceId") == "shared-gap" + ] + assert len(gaps) == 2 + assert len({item["id"] for item in gaps}) == 2 + assert {item["provenance"]["workerId"] for item in gaps} == { + path.parent.parent.name for path in unmerged + } + assert all(item["provenance"]["attempt"] == 1 for item in gaps) + + +def test_budget_accepts_complete_reducer_without_optional_complete_flag( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + _, accepted, _ = accept_reducer(workbench_db, scan) + document = json.loads(accepted.read_bytes()) + del document["complete"] + accepted.write_text(json.dumps(document)) + digest = hashlib.sha256(accepted.read_bytes()).hexdigest() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute("UPDATE deep_scan_attempts SET accepted_result_sha256 = ?", (digest,)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', workflow_version = 'deep-security-scan/v2'" + ) + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message="Original cost stop." + ), + ) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + assert hashlib.sha256(accepted.read_bytes()).hexdigest() == digest + assert len(json.loads((scan.scan_dir / "findings.json").read_text())["findings"]) == 1 + + @pytest.mark.parametrize("selected", [False, True], ids=["legacy-v1", "selected-v2"]) @pytest.mark.parametrize("reason", ["saturated", "capped"]) @pytest.mark.parametrize("cancel_first", [False, True], ids=["budget-first", "cancel-first"]) diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 697bb0e368..fd46005217 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -464,17 +464,33 @@ def test_budget_exhaustion_rejects_scan_below_configured_limit(tmp_path: Path) - assert "has not exceeded its configured cost limit" in str(rejected["stderr"]) -def test_budget_exhaustion_rejects_incomplete_discovery(tmp_path: Path) -> None: - state_dir, _, _, scan_id, _ = budget_scan_fixture(tmp_path, terminal=False) - - rejected = complete_budget_scan(state_dir, scan_id, check=False) - - assert rejected["returncode"] != 0 - assert "requires successfully completed Deep Scan discovery" in str(rejected["stderr"]) - assert ( - run_workbench(state_dir, "get-scan", "--scan-id", scan_id)["scan"]["progress"]["status"] - == "running" - ) +@pytest.mark.parametrize("workflow", ["deep-security-scan/v1", "deep-security-scan/v2"]) +def test_budget_exhaustion_during_discovery_preserves_workflow_contract( + tmp_path: Path, workflow: str +) -> None: + state_dir, _, scan_dir, scan_id, ledger = budget_scan_fixture(tmp_path, terminal=False) + original = ledger.read_bytes() + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = ? WHERE scan_id = ?", (workflow, scan_id) + ) + result = complete_budget_scan(state_dir, scan_id, check=False) + if workflow == "deep-security-scan/v1": + assert result["returncode"] != 0 + assert "requires successfully completed Deep Scan discovery" in str(result["stderr"]) + assert ( + run_workbench(state_dir, "get-scan", "--scan-id", scan_id)["scan"]["progress"]["status"] + == "running" + ) + else: + assert result["returncode"] == 0 + saved = run_workbench(state_dir, "get-scan", "--scan-id", scan_id)["scan"] + assert saved["progress"]["status"] == "complete" + assert saved["findings"] == [] + assert saved["cost"] == BUDGET_COST + assert saved["warnings"] == [BUDGET_WARNING] + assert json.loads((scan_dir / "coverage.json").read_text())["completeness"] == "partial" + assert ledger.read_bytes() == original def test_budget_exhaustion_rejects_standard_scan(tmp_path: Path) -> None: diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 33d89ac26d..42f1ae1fec 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1209,6 +1209,7 @@ export class CodexSecurity { let runPostScan: (() => ReturnType) | null = null; let selectedDeepFinalization = false; + let budgetDeepFinalization = false; let observedScanThreadId: string | undefined; let activeScan: { id: string; @@ -2429,6 +2430,7 @@ export class CodexSecurity { runWorkbench: (args) => workbench(completionOptions, args), }); } + budgetDeepFinalization = true; const completion = await workbench(completionOptions, [ "complete-budget-exhausted-scan", "--scan-id", @@ -2503,7 +2505,7 @@ export class CodexSecurity { observedScanThreadId ) { const workbenchOptions = { ...activeScan.options, signal: undefined }; - if (!selectedDeepFinalization) { + if (!selectedDeepFinalization && !budgetDeepFinalization) { const saved = await workbench(workbenchOptions, [ "get-deep-scan", "--scan-id", @@ -2515,7 +2517,7 @@ export class CodexSecurity { selectedDeepFinalization = isRecord(deep) && isRecord(deep["finalizationInput"]); } - if (selectedDeepFinalization) { + if (selectedDeepFinalization || budgetDeepFinalization) { // The workbench owns the running-state check and repeated cancellation. // A lost cleanup response must preserve the original interruption. await workbench(workbenchOptions, [ From 2b8182e5d8b113099d22b92d5db10d4a049f77e8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:21:21 +0000 Subject: [PATCH 113/251] fix: preserve budget scope and replay unmerged coverage once --- .../scripts/workbench_saved_results.py | 33 ++++- .../test_budget_selection_publication.py | 115 ++++++++++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index d87d797f00..62340fa300 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1293,6 +1293,11 @@ def budget_exhausted_draft( coverage = accepted_result["sourceCoverage"] if "threatModel" in accepted_result: manifest["scan"]["threatModel"] = accepted_result["threatModel"] + if "scope" in accepted_result: + manifest["scan"]["scope"].update(copy.deepcopy(accepted_result["scope"])) + limitations = manifest["scan"]["scope"].setdefault("limitations", []) + if warning not in limitations: + limitations.append(warning) for worker in unmerged_workers: retain_unmerged_budget_coverage(scan, scan_dir, coverage, worker) @@ -1409,6 +1414,14 @@ def retain_unmerged_budget_coverage( item.get("id"): f"{prefix}-surface-{index + 1}" for index, item in enumerate(source.get("surfaces", [])) } + + def retain(field: str, item: dict[str, Any]) -> None: + # A committed budget draft can be replayed before the scan is sealed. + # These IDs and provenance identify the same immutable accepted review. + items = coverage.setdefault(field, []) + if item not in items: + items.append(item) + for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions"): for index, original in enumerate(source.get(field, [])): item = copy.deepcopy(original if isinstance(original, dict) else {"question": original}) @@ -1427,16 +1440,24 @@ def retain_unmerged_budget_coverage( item["candidateId"] = f"{prefix}-candidate-{index + 1}" if "surfaceIds" in item: item["surfaceIds"] = [surfaces.get(value, value) for value in item["surfaceIds"]] - coverage.setdefault(field, []).append(item) - coverage.setdefault("reviews", []).append( - {**provenance, "completeness": source["completeness"]} - ) - coverage["deferred"].append( + retain(field, item) + retain("reviews", {**provenance, "completeness": source["completeness"]}) + for index, limitation in enumerate(draft.get("scope", {}).get("limitations", [])): + retain( + "deferred", + { + "id": f"{prefix}-scope-{index + 1}", + "reason": limitation, + "provenance": provenance, + }, + ) + retain( + "deferred", { "id": f"{prefix}-unmerged", "provenance": provenance, "reason": "This accepted discovery was not merged before the scan reached its cost limit.", - } + }, ) diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index eb06d2bf0a..bc8396cf68 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -256,6 +256,121 @@ def test_budget_accepts_complete_reducer_without_optional_complete_flag( assert len(json.loads((scan.scan_dir / "findings.json").read_text())["findings"]) == 1 +def test_budget_preserves_accepted_scope_and_unmerged_scope_limits( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + _, accepted, _ = accept_reducer(workbench_db, scan) + unmerged = accept_unmerged(workbench_db, scan) + scope = { + "summary": "Synthetic upload boundaries.", + "artifactsReviewed": ["subdir/extract.py"], + "runtimeStatus": "Synthetic runtime unavailable.", + "validationMode": "Static validation of the accepted findings.", + "context": "External deployment behavior remains unresolved.", + "limitations": ["The accepted review did not execute the deployment integration."], + } + unmerged_limit = "A separate unmerged review could not inspect the deployment credentials." + for path, value in [(accepted, scope), (unmerged, {"limitations": [unmerged_limit]})]: + document = json.loads(path.read_bytes()) + document["scope"] = value + path.write_text(json.dumps(document)) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_attempts SET accepted_result_sha256 = ? WHERE accepted_result_path = ?", + (hashlib.sha256(path.read_bytes()).hexdigest(), str(path)), + ) + originals = {p: p.read_bytes() for p in [accepted, unmerged]} + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', workflow_version = 'deep-security-scan/v2'" + ) + warning = "Scan stopped at its original cost limit before further validation." + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=warning), + ) + manifest = json.loads((scan.scan_dir / "scan-manifest.json").read_text()) + saved_scope = manifest["scan"]["scope"] + for key, value in scope.items(): + if key == "limitations": + assert all(item in saved_scope[key] for item in value) + else: + assert saved_scope[key] == value + assert warning in saved_scope["limitations"] + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert coverage["completeness"] == "partial" + assert any( + item["reason"] == unmerged_limit + and item["provenance"]["workerId"] == unmerged.parent.parent.name + for item in coverage["deferred"] + ) + assert all(p.read_bytes() == contents for p, contents in originals.items()) + assert len(json.loads((scan.scan_dir / "findings.json").read_text())["findings"]) == 1 + + +@pytest.mark.parametrize("has_reducer", [False, True]) +def test_budget_replay_after_draft_commit_keeps_unmerged_coverage_once( + workbench_api, workbench_db, publication_scan, tmp_path, monkeypatch, has_reducer +): + scan = publication_scan() + if has_reducer: + accept_reducer(workbench_db, scan) + unmerged = accept_unmerged(workbench_db, scan) + original = unmerged.read_bytes() + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', workflow_version = 'deep-security-scan/v2'" + ) + budget = workbench_api["complete_budget_exhausted_scan"] + original_complete = budget.__globals__["complete_scan_locked"] + + def fail_before_seal(*args, **kwargs): + raise RuntimeError("Synthetic process loss after budget draft commit.") + + monkeypatch.setitem(budget.__globals__, "complete_scan_locked", fail_before_seal) + args = Namespace( + scan_id=scan.scan_id, + cost_json=json.dumps(BUDGET_COST), + message="Original cost interruption.", + ) + path = tmp_path / "budget-gap.sqlite3" + with sqlite3.connect(path) as connection: + workbench_db.backup(connection) + connection.row_factory = sqlite3.Row + with pytest.raises(RuntimeError, match="after budget draft commit"): + budget(connection, args) + run = connection.execute("SELECT * FROM deep_scan_runs").fetchone() + assert run["status"] == "succeeded" + selection = run["finalization_input_json"] + assert connection.execute("SELECT status FROM scans").fetchone()[0] == "running" + before = json.loads((scan.scan_dir / "coverage.json").read_text()) + monkeypatch.setitem(budget.__globals__, "complete_scan_locked", original_complete) + with sqlite3.connect(path) as connection: + connection.row_factory = sqlite3.Row + budget(connection, args) + assert connection.execute("SELECT status FROM scans").fetchone()[0] == "complete" + assert ( + connection.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + == selection + ) + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + for key in ("surfaces", "explicitExclusions", "deferred", "reviews"): + assert coverage.get(key, []) == before.get(key, []) + assert len({row["id"] for row in coverage["deferred"]}) == len(coverage["deferred"]) + assert unmerged.read_bytes() == original + + @pytest.mark.parametrize("selected", [False, True], ids=["legacy-v1", "selected-v2"]) @pytest.mark.parametrize("reason", ["saturated", "capped"]) @pytest.mark.parametrize("cancel_first", [False, True], ids=["budget-first", "cancel-first"]) From a410b99d3ad9f96dd926f229595a7f7194377002 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:29:17 +0000 Subject: [PATCH 114/251] Preserve worker usage across Codex home changes --- .../scripts/workbench_scan_usage.py | 83 +++++++-- .../tests/test_workbench_scan_usage.py | 159 ++++++++++++++++++ .../tests/workbench_test_support.py | 2 +- 3 files changed, 225 insertions(+), 19 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 2abbf45b34..e0543f49eb 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -112,8 +112,34 @@ def collect_scan_usage( if not roots: return _unavailable_usage("scan_thread_unavailable") - state_database = _codex_state_database() - if state_database is None: + warnings: set[str] = set() + current_database = _codex_state_database() + worker_codex_home = None + if scan["mode"] == "deep": + # Deep orchestration imports the owner-capture helper from this module; + # its settings reader is available once completion starts. + from deep_scan_workbench import read_deep_scan_execution_settings + + try: + settings = read_deep_scan_execution_settings(Path(scan["scan_dir"])) + worker_codex_home = Path(settings["codexHome"]) + except SystemExit: + # Legacy scans may have no recorded home. Keep usage best effort. + pass + groups = [(current_database, roots)] + if worker_codex_home is not None: + worker_roots = set( + _scan_root_thread_ids(connection, scan, None, include_owner_threads=False) + ) + # Restored workers use their recorded home. A current owner or CLI + # continuation still belongs to the current process's native state. + worker_database = _codex_state_database(worker_codex_home) + if worker_database != current_database: + groups = [ + (current_database, [root for root in roots if root not in worker_roots]), + (worker_database, [root for root in roots if root in worker_roots]), + ] + if not any(database is not None for database, _ in groups): return _unavailable_usage("codex_state_unavailable") started_at = _timestamp(scan["started_at"]) @@ -121,19 +147,38 @@ def collect_scan_usage( if started_at is None: return _unavailable_usage("scan_window_unavailable") - warnings: set[str] = set() - try: - sessions, missing_thread_ids = _discover_rollout_sessions( - state_database, - roots, - warnings, - descendant_roots=set(attribution["executionThreadIds"]) if attribution else None, - ) - except (OSError, sqlite3.Error, ValueError): - return _unavailable_usage("codex_state_unavailable") + sessions: list[RolloutSession] = [] + missing_thread_ids: set[str] = set() + seen_thread_ids: set[str] = set() + for state_database, group_roots in groups: + if not group_roots: + continue + try: + if state_database is None: + raise FileNotFoundError("Codex state is unavailable") + discovered, missing = _discover_rollout_sessions( + state_database, + group_roots, + warnings, + descendant_roots=set(attribution["executionThreadIds"]) if attribution else None, + ) + except (OSError, sqlite3.Error, ValueError): + warnings.add("codex_state_unavailable") + missing_thread_ids.update(group_roots) + continue + missing_thread_ids.update(missing) + for session in discovered: + if session.thread_id not in seen_thread_ids: + sessions.append(session) + seen_thread_ids.add(session.thread_id) if not sessions: - return _unavailable_usage("scan_thread_unavailable", warnings=warnings) + return _unavailable_usage( + "codex_state_unavailable" + if "codex_state_unavailable" in warnings + else "scan_thread_unavailable", + warnings=warnings, + ) total = _empty_token_usage() observed_thread_count = 0 @@ -349,15 +394,17 @@ def scan_execution_fields(connection: sqlite3.Connection, scan: sqlite3.Row) -> } -def _codex_state_database() -> Path | None: - configured_database = os.environ.get("CODEX_STATE_DB", "").strip() +def _codex_state_database(worker_codex_home: Path | None = None) -> Path | None: + configured_home = os.environ.get("CODEX_HOME", "").strip() + current_home = Path(configured_home).expanduser() if configured_home else Path.home() / ".codex" + codex_home = worker_codex_home if worker_codex_home is not None else current_home + same_home = worker_codex_home is None or codex_home.resolve() == current_home.resolve() + configured_database = os.environ.get("CODEX_STATE_DB", "").strip() if same_home else "" if configured_database: path = Path(configured_database).expanduser() return path.resolve() if path.is_file() and os.access(path, os.R_OK) else None - configured_home = os.environ.get("CODEX_HOME", "").strip() - codex_home = Path(configured_home).expanduser() if configured_home else Path.home() / ".codex" - configured_sqlite_home = os.environ.get("CODEX_SQLITE_HOME", "").strip() + configured_sqlite_home = os.environ.get("CODEX_SQLITE_HOME", "").strip() if same_home else "" search_roots = [ *([Path(configured_sqlite_home).expanduser()] if configured_sqlite_home else []), codex_home, diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 81ed88337f..734e79585f 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +import os import runpy import sqlite3 +import subprocess import sys import tempfile import uuid +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path @@ -13,6 +16,7 @@ import pytest from workbench_test_support import ( + SCRIPT, create_saved_workspace, initialize_git_repository, mark_deep_coordinator_succeeded, @@ -641,6 +645,161 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N } +@pytest.mark.parametrize("worker_home", ["recorded", "current", "unavailable"]) +def test_completion_keeps_owner_and_workers_in_their_recorded_homes( + tmp_path: Path, worker_home: str +) -> None: + current_home = tmp_path / "current-home" + environment = { + "CODEX_HOME": str(current_home), + "CODEX_SQLITE_HOME": str(current_home / "sqlite"), + "CODEX_STATE_DB": str(current_home / "sqlite" / "state_5.sqlite"), + } + owners = { + f"owner-{index}": _rollout( + tmp_path, + f"owner-{index}", + [_event(datetime.now().astimezone(), "turn_context", {"turn_id": "original"})], + ) + for index in (1, 2) + } + _state_graph(environment, owners, []) + + def complete(index: int) -> dict[str, Any]: + root = tmp_path / f"scan-{index}" + target = root / "target" + target.mkdir(parents=True) + selected_home = current_home if worker_home == "current" else root / "original-home" + process = subprocess.run( + [ + sys.executable, + "-c", + ( + "import runpy, sys; script = sys.argv.pop(1); " + "runpy.run_path(script)['main'](with_execution_settings=True)" + ), + str(SCRIPT), + "begin-deep-scan", + "--thread-id", + f"owner-{index}", + "--target-path", + str(target), + "--scan-root", + str(root / "scans"), + ], + env={**os.environ, **environment, "CODEX_SECURITY_STATE_DIR": str(root / "state")}, + input=json.dumps( + { + "executionSettings": { + "codexHome": str(selected_home), + "codexPath": sys.executable, + } + } + ), + capture_output=True, + text=True, + timeout=30, + ) + assert process.returncode == 0, process.stderr + deep = json.loads(process.stdout)["deepScan"] + scan_id, scan_dir = deep["scanId"], Path(deep["scanDir"]) + snapshot = scan_dir / "artifacts/deep_discovery/execution-settings.json" + original_bytes = snapshot.read_bytes() + fixture = ScanFixture( + root / "state", + target, + scan_id, + scan_dir, + datetime.fromisoformat(deep["createdAt"]), + environment, + "deep", + ) + counted = fixture.started_at + timedelta(microseconds=1) + with owners[f"owner-{index}"].open("a") as stream: + stream.write(json.dumps(_token_event(counted, index * 10, 2)) + "\n") + stream.write(json.dumps(_event(counted, "turn_context", {"turn_id": "later"})) + "\n") + stream.write(json.dumps(_token_event(counted, 9000, 900)) + "\n") + worker_threads = {} + for kind in ("discovery", "second-discovery"): + thread_id = f"{kind}-{index}" + artifact = scan_dir / "artifacts" / thread_id + artifact.mkdir() + prompt = artifact / "prompt.md" + prompt.write_text("Review the synthetic target.\n") + run_workbench( + fixture.state_dir, + "upsert-deep-scan-worker", + "--scan-id", + scan_id, + "--worker-id", + str(uuid.uuid4()), + "--kind", + "discovery", + "--status", + "running", + "--prompt-path", + str(prompt), + "--artifact-dir", + str(artifact), + "--sdk-thread-id", + thread_id, + environment=environment, + ) + worker_threads[thread_id] = _rollout( + root, + thread_id, + [ + _token_event(counted, index * 20, 3), + _event(counted, "turn_context", {"turn_id": "resumed"}), + _token_event(counted, index * 30, 5), + ], + ) + child_id = f"child-{index}" + worker_threads[child_id] = _rollout( + root, + child_id, + [_token_event(counted, index * 7, 1)], + parent_thread_id=f"discovery-{index}", + ) + if worker_home == "current": + with sqlite3.connect(environment["CODEX_STATE_DB"]) as connection: + connection.executemany( + "INSERT INTO threads VALUES (?, ?)", + [(key, str(path)) for key, path in worker_threads.items()], + ) + connection.execute( + "INSERT INTO thread_spawn_edges VALUES (?, ?)", (f"discovery-{index}", child_id) + ) + elif worker_home == "recorded": + _state_graph( + {"CODEX_SQLITE_HOME": str(selected_home)}, + worker_threads, + [(f"discovery-{index}", child_id)], + ) + result = _complete_scan(fixture)["scan"]["usage"] + assert snapshot.read_bytes() == original_bytes + return result + + # Both completions share current home B; each scan retains its own worker home A. + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(complete, (1, 2))) + for index, usage in enumerate(results, 1): + if worker_home == "unavailable": + assert usage["coverage"] == "partial" + assert usage["inputTokens"] == index * 10 + assert usage["outputTokens"] == 2 + assert usage["threadCount"] == 1 + assert usage["missingThreadCount"] == 2 + else: + assert usage == { + "coverage": "complete", + "source": "codex_rollout", + **_counts(index * 77, 0, 13), + "threadCount": 4, + "modelUsage": [{"model": None, **_counts(index * 77, 0, 13)}], + } + + def test_completion_preserves_explicit_legacy_cost(tmp_path: Path) -> None: fixture = _start_scan(tmp_path) counted = fixture.started_at + timedelta(microseconds=1) diff --git a/plugins/codex-security/tests/workbench_test_support.py b/plugins/codex-security/tests/workbench_test_support.py index dae12b77e7..4a9017c1f2 100644 --- a/plugins/codex-security/tests/workbench_test_support.py +++ b/plugins/codex-security/tests/workbench_test_support.py @@ -212,7 +212,7 @@ def create_saved_git_workspace(state_dir: Path, target: Path) -> dict[str, objec def mark_deep_coordinator_succeeded(state_dir: Path, scan_id: str, scan_dir: Path) -> Path: manifest = scan_dir / "artifacts" / "deep_discovery" / "coordinator-manifest.json" - manifest.parent.mkdir(parents=True) + manifest.parent.mkdir(parents=True, exist_ok=True) manifest.write_text('{"status":"succeeded"}\n') with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: connection.execute( From f1a7c63c2d8f399e2a45a77a31e5ccc05a62ae84 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:28:37 +0000 Subject: [PATCH 115/251] Include recorded worker homes in scan usage --- sdk/typescript/src/cost.ts | 60 +++++++++---- sdk/typescript/tests-ts/cost.test.ts | 130 +++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 16 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 28c13a9abc..c9696f92d4 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -1,4 +1,4 @@ -import { open, readdir } from "node:fs/promises"; +import { open, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { estimateScanCost, @@ -256,24 +256,52 @@ export class ScanCostTracker { this.#attribution = attribution; } const unreadable: Array<{ session: SessionUsage; error: unknown }> = []; - for await (const path of sessionFiles( - join(this.#options.codexHome, "sessions"), - )) { - let session = this.#sessions.get(path); - if (session === undefined) { - session = createSessionUsage(); - this.#sessions.set(path, session); - } + const homes = new Set([this.#options.codexHome]); + if (this.#options.scanDirectory !== undefined) { try { - await readSessionUsage( - path, - session, - this.#options.repository, - this.#attribution, + const saved: unknown = JSON.parse( + await readFile( + join( + this.#options.scanDirectory, + "artifacts", + "deep_discovery", + "execution-settings.json", + ), + "utf8", + ), ); + if ( + isRecord(saved) && + saved["version"] === 1 && + isRecord(saved["settings"]) + ) { + const home = saved["settings"]["codexHome"]; + if (typeof home === "string" && home !== "") homes.add(home); + } } catch (error) { - if (session.threadId === null) throw error; - unreadable.push({ session, error }); + if (!isMissingFile(error)) throw error; + } + } + // Recovery restores workers to their recorded home; the SDK parent can + // continue in the current home. Apply the same scan membership to both. + for (const home of homes) { + for await (const path of sessionFiles(join(home, "sessions"))) { + let session = this.#sessions.get(path); + if (session === undefined) { + session = createSessionUsage(); + this.#sessions.set(path, session); + } + try { + await readSessionUsage( + path, + session, + this.#options.repository, + this.#attribution, + ); + } catch (error) { + if (session.threadId === null) throw error; + unreadable.push({ session, error }); + } } } diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 077eb4983b..b07d238994 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, realpath, + readFile, rm, writeFile, } from "node:fs/promises"; @@ -2259,3 +2260,132 @@ describe("live scan cost tracking", () => { }, ); }); + +describe("recorded Deep worker homes", () => { + test("keeps resumed worker usage and current parent usage isolated per scan", async () => { + const currentHome = await codexHome(); + const firstHome = await codexHome(); + const secondHome = await codexHome(); + const at = "2026-09-01T00:00:02Z"; + const trackers: ScanCostTracker[] = []; + const fixture = async (home: string, id: string, count: number) => { + const path = await writeSession(home, id, {}); + await appendFile( + path, + [ + { + type: "turn_context", + timestamp: at, + payload: { turn_id: "scan-turn", model: "gpt-5.6-sol" }, + }, + { + type: "token_usage_record", + timestamp: at, + payload: { + thread_id: id, + turn_id: "scan-turn", + response_id: `${id}-response`, + model: "gpt-5.6-sol", + usage: { input_tokens: count, output_tokens: 0 }, + }, + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + ); + return path; + }; + try { + const cases = [ + { id: "one", home: firstHome, parent: 10, discovery: 20, reducer: 30 }, + { id: "two", home: secondHome, parent: 11, discovery: 21, reducer: 31 }, + ]; + for (const row of cases) { + const scanDirectory = join(currentHome, "scans", row.id); + const settingsDirectory = join( + scanDirectory, + "artifacts", + "deep_discovery", + ); + await mkdir(settingsDirectory, { recursive: true }); + await writeFile( + join(settingsDirectory, "execution-settings.json"), + JSON.stringify({ + version: 1, + settings: { codexHome: row.home }, + }), + ); + await fixture(currentHome, `${row.id}-parent`, row.parent); + const discovery = await fixture( + row.home, + `${row.id}-discovery`, + row.discovery, + ); + await fixture(row.home, `${row.id}-reducer`, row.reducer); + await fixture(row.home, `${row.id}-unrelated`, 10_000); + // Repeated receipt identity after reconnect must remain one charge. + const duplicate = (await readFile(discovery, "utf8")) + .trim() + .split("\n") + .at(-1)!; + await appendFile(discovery, duplicate + "\n"); + const attribution = { + formatVersion: 1 as const, + executionThreadIds: [`${row.id}-discovery`, `${row.id}-reducer`], + owner: { + threadId: `${row.id}-parent`, + turnId: "scan-turn", + startedAt: at, + }, + startedAt: at, + completedAt: null, + }; + const tracker = new ScanCostTracker({ + codexHome: currentHome, + scanDirectory, + model: "gpt-5.6-sol", + }); + tracker.setAttributionReader(async () => attribution); + tracker.start(`${row.id}-parent`); + trackers.push(tracker); + } + const initial = await Promise.all( + trackers.map((tracker) => tracker.refresh()), + ); + expect( + initial.map((snapshot) => tokenUsage(snapshot.usage)?.input_tokens), + ).toEqual([60, 63]); + expect(initial.map((snapshot) => snapshot.cost?.inputTokens)).toEqual([ + 60, 63, + ]); + const firstDirectory = join(currentHome, "scans", "one"); + const rebuilt = new ScanCostTracker({ + codexHome: currentHome, + scanDirectory: firstDirectory, + model: "gpt-5.6-sol", + }); + rebuilt.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: [ + "one-discovery", + "one-reducer", + "one-missing-attempt", + ], + owner: { threadId: "one-parent", turnId: "scan-turn", startedAt: at }, + startedAt: at, + completedAt: null, + })); + rebuilt.start("one-parent"); + trackers.push(rebuilt); + expect((await rebuilt.refresh()).usage).toMatchObject({ + input_tokens: 60, + coverage: "partial", + }); + await fixture(firstHome, "one-missing-attempt", 7); + expect((await rebuilt.refresh()).cost?.inputTokens).toBe(67); + expect((await trackers[1]!.refresh()).cost?.inputTokens).toBe(63); + } finally { + await Promise.all(trackers.map((tracker) => tracker.stop())); + } + }); +}); From ac064f1a8b88491f491942ed6f37a665bbe74a2d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:39:18 +0000 Subject: [PATCH 116/251] fix: cancel owned deep scans across resume and selection boundaries --- sdk/typescript/src/api.ts | 48 +++----- .../tests-ts/deep-finalization.test.ts | 111 ++++++++++++------ 2 files changed, 92 insertions(+), 67 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 42f1ae1fec..7bd3c104cb 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1209,7 +1209,6 @@ export class CodexSecurity { let runPostScan: (() => ReturnType) | null = null; let selectedDeepFinalization = false; - let budgetDeepFinalization = false; let observedScanThreadId: string | undefined; let activeScan: { id: string; @@ -1961,6 +1960,7 @@ export class CodexSecurity { ); } thread = codex.resumeThread(resumeThreadId, threadOptions); + observedScanThreadId = resumeThreadId; tracker.start(resumeThreadId); if (budgetRecovery !== null) budgetRecovery.threadId = resumeThreadId; await tracker.refresh().catch(reportTrackingError); @@ -1984,8 +1984,6 @@ export class CodexSecurity { if (postScanPrompt?.trim()) { runPostScan = () => thread.runStreamed(postScanPrompt, { signal }); } - observedScanThreadId = - typeof resumeThreadId === "string" ? resumeThreadId : undefined; const recoverSelectedCompletion = async () => { const threadId = observedScanThreadId ?? thread.id; if (mode !== "deep" || !threadId || signal.aborted) return null; @@ -2430,7 +2428,6 @@ export class CodexSecurity { runWorkbench: (args) => workbench(completionOptions, args), }); } - budgetDeepFinalization = true; const completion = await workbench(completionOptions, [ "complete-budget-exhausted-scan", "--scan-id", @@ -2499,40 +2496,27 @@ export class CodexSecurity { return result; } catch {} } - if ( - activeScan?.mode === "deep" && - options.signal?.aborted && - observedScanThreadId - ) { + const cancellationThreadId = + activeScan?.mode === "deep" && options.signal?.aborted + ? observedScanThreadId + : undefined; + if (activeScan !== null && cancellationThreadId !== undefined) { const workbenchOptions = { ...activeScan.options, signal: undefined }; - if (!selectedDeepFinalization && !budgetDeepFinalization) { - const saved = await workbench(workbenchOptions, [ - "get-deep-scan", - "--scan-id", - activeScan.id, - "--thread-id", - observedScanThreadId, - ]).catch(() => null); - const deep = saved?.["deepScan"]; - selectedDeepFinalization = - isRecord(deep) && isRecord(deep["finalizationInput"]); - } - if (selectedDeepFinalization || budgetDeepFinalization) { - // The workbench owns the running-state check and repeated cancellation. - // A lost cleanup response must preserve the original interruption. - await workbench(workbenchOptions, [ - "cancel-scan", - "--scan-id", - activeScan.id, - "--thread-id", - observedScanThreadId, - ]).catch(() => undefined); - } + // The workbench owns the running-state check and repeated cancellation. + // Selection may have committed before the SDK received its response. + await workbench(workbenchOptions, [ + "cancel-scan", + "--scan-id", + activeScan.id, + "--thread-id", + cancellationThreadId, + ]).catch(() => undefined); } // Publication failures remain resumable. A cost stop or explicit client close // still uses the existing failure path to retain partial results and stop work. if ( activeScan !== null && + cancellationThreadId === undefined && ((options.resumeScanId === undefined && !selectedDeepFinalization) || (selectedDeepFinalization && !options.signal?.aborted && diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 04c783c36c..f6156686fe 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -48,6 +48,7 @@ type BudgetCompletionFault = "lost" | "before-commit" | "lost-and-canceled"; const cases: { outcome: (typeof outcomes)[number]; budgetCompletionFault?: BudgetCompletionFault; + initialResumeUsage?: boolean; cancellationFault?: "status-read" | "deep-state-read" | "cancel-response"; }[] = [ ...outcomes.map((outcome) => ({ outcome })), @@ -82,8 +83,21 @@ const cases: { outcome: "canceled-during-resumed-publication", cancellationFault: "deep-state-read", }, + { + outcome: "canceled-before-publication", + cancellationFault: "deep-state-read", + }, + { + outcome: "canceled-during-resumed-publication", + initialResumeUsage: true, + }, ]; -for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { +for (const { + outcome, + budgetCompletionFault, + cancellationFault, + initialResumeUsage, +} of cases) { const resumedStop = outcome.includes("-resumed-"); const restart = outcome === "restart" || resumedStop; const closed = outcome.startsWith("closed-"); @@ -93,7 +107,7 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { const name = outcome === "followup-canceled" ? "SDK preserves a selected aggregate when its follow-up is canceled" - : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}`; + : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${initialResumeUsage ? " (initial resume cost)" : ""}`; const runCase = async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -126,6 +140,9 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { let cancellationReadLost = false; let lostCancellationDeepState: unknown; let originalFinalizationInput: unknown; + let selectedPath = ""; + let selectedBytes: Buffer; + let originalResumeSignal: AbortSignal | undefined; let acceptedReport = ""; let completedArtifacts: Buffer[] = []; const modelInputs: string[] = []; @@ -138,6 +155,34 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { "01", `rollout-${threadId}.jsonl`, ); + const recordBudgetUsage = () => + appendFile( + usagePath, + JSON.stringify({ + timestamp: new Date().toISOString(), + type: "turn_context", + payload: { + turn_id: "synthetic-scan-turn", + model: "gpt-5.6-sol", + }, + }) + + "\n" + + JSON.stringify({ + timestamp: new Date().toISOString(), + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }, + }, + }, + }) + + "\n", + ); let closePromise: Promise | undefined; const makeClient = () => new TestClient( @@ -164,6 +209,8 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { prepareOutputDir: async () => scanDir, runWorkbench: async (options, args, input) => { workbenchOptions = options; + if (args[0] === "get-cli-scan-resume") + originalResumeSignal = options.signal; commands.push(args[0]!); if ( args[0] === "get-scan" && @@ -245,33 +292,7 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { outcome === "budget-after-deep-finish")) ) { budgetTriggered = true; - await appendFile( - usagePath, - JSON.stringify({ - timestamp: new Date().toISOString(), - type: "turn_context", - payload: { - turn_id: "synthetic-scan-turn", - model: "gpt-5.6-sol", - }, - }) + - "\n" + - JSON.stringify({ - timestamp: new Date().toISOString(), - type: "event_msg", - payload: { - type: "token_count", - info: { - total_token_usage: { - input_tokens: 1_250, - cached_input_tokens: 200, - output_tokens: 30, - }, - }, - }, - }) + - "\n", - ); + await recordBudgetUsage(); await new Promise((resolve) => { if (options.signal?.aborted) resolve(); else @@ -288,6 +309,12 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { (outcome === "canceled-during-publication" || outcome === "canceled-during-resumed-publication") ) { + if (initialResumeUsage) { + expect(originalResumeSignal?.reason).toBeInstanceOf( + ScanCostLimitExceededError, + ); + expect(options.signal?.aborted).toBe(false); + } cancellation.abort("Synthetic user cancellation"); } if ( @@ -423,6 +450,12 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { ); originalFinalizationInput = JSON.parse(selectionOutput).deepScan.finalizationInput; + selectedPath = join( + scanDir, + (originalFinalizationInput as { resultPath: string }) + .resultPath, + ); + selectedBytes = await readFile(selectedPath); const sessions = join( codexHome, "sessions", @@ -495,6 +528,7 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { expect(commands).not.toContain("fail-scan"); await client.close(); client = makeClient(); + if (initialResumeUsage) await recordBudgetUsage(); } if (budgeted || closed) { const running = client.run(repository, { @@ -637,14 +671,21 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { ? { resumeScanId: scanId, outputDir: scanDir } : {}), postScanPrompt: followUp, + ...(initialResumeUsage ? { maxCostUsd: 0.004 } : {}), }) .catch((error: unknown) => error); - expect(error).toBeInstanceOf(ScanInterruptedError); - expect((error as ScanInterruptedError).cause).toBe( - outcome === "canceled-before-publication" - ? parentError - : cancellation.signal.reason, - ); + if (initialResumeUsage) { + expect(error).toBeInstanceOf(ScanCostLimitExceededError); + expect(error).toBe(originalResumeSignal?.reason); + } else { + expect(error).toBeInstanceOf(ScanInterruptedError); + expect((error as ScanInterruptedError).cause).toBe( + outcome === "canceled-before-publication" + ? parentError + : cancellation.signal.reason, + ); + } + expect(await readFile(selectedPath)).toEqual(selectedBytes!); if (cancellationReadLost) { expect(lostCancellationDeepState).toMatchObject({ status: "running", From 8cd90732f6b208bbdc27fff825bff39b4908946d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 13:04:27 +0000 Subject: [PATCH 117/251] Include inherited SQLite state in worker usage --- .../codex-security/scripts/workbench_scan_usage.py | 14 ++++++++------ .../tests/test_workbench_scan_usage.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index e0543f49eb..52667684b8 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -131,14 +131,11 @@ def collect_scan_usage( worker_roots = set( _scan_root_thread_ids(connection, scan, None, include_owner_threads=False) ) - # Restored workers use their recorded home. A current owner or CLI - # continuation still belongs to the current process's native state. + # Workers retain their Codex home, but inherit an explicit current + # SQLite home. Their earlier and resumed sessions can be in either index. worker_database = _codex_state_database(worker_codex_home) if worker_database != current_database: - groups = [ - (current_database, [root for root in roots if root not in worker_roots]), - (worker_database, [root for root in roots if root in worker_roots]), - ] + groups.append((worker_database, [root for root in roots if root in worker_roots])) if not any(database is not None for database, _ in groups): return _unavailable_usage("codex_state_unavailable") @@ -172,6 +169,11 @@ def collect_scan_usage( sessions.append(session) seen_thread_ids.add(session.thread_id) + # Absence from one known index is not missing usage when another has it. + missing_thread_ids.difference_update(seen_thread_ids) + if not missing_thread_ids: + warnings.difference_update({"scan_root_unavailable", "codex_state_unavailable"}) + if not sessions: return _unavailable_usage( "codex_state_unavailable" diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 734e79585f..95102ac8d6 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -645,7 +645,7 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N } -@pytest.mark.parametrize("worker_home", ["recorded", "current", "unavailable"]) +@pytest.mark.parametrize("worker_home", ["recorded", "current", "inherited-sqlite", "unavailable"]) def test_completion_keeps_owner_and_workers_in_their_recorded_homes( tmp_path: Path, worker_home: str ) -> None: @@ -761,7 +761,7 @@ def complete(index: int) -> dict[str, Any]: [_token_event(counted, index * 7, 1)], parent_thread_id=f"discovery-{index}", ) - if worker_home == "current": + if worker_home in {"current", "inherited-sqlite"}: with sqlite3.connect(environment["CODEX_STATE_DB"]) as connection: connection.executemany( "INSERT INTO threads VALUES (?, ?)", @@ -770,6 +770,14 @@ def complete(index: int) -> dict[str, Any]: connection.execute( "INSERT INTO thread_spawn_edges VALUES (?, ?)", (f"discovery-{index}", child_id) ) + if worker_home == "inherited-sqlite": + # Earlier launches used A; the resumed process forwards its + # explicit SQLite home C even while workers keep Codex home A. + _state_graph( + {"CODEX_SQLITE_HOME": str(selected_home)}, + {f"discovery-{index}": worker_threads[f"discovery-{index}"]}, + [], + ) elif worker_home == "recorded": _state_graph( {"CODEX_SQLITE_HOME": str(selected_home)}, From 335a7e9b2affa694fc456a210201f25fc0ca48e3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:52:04 +0000 Subject: [PATCH 118/251] Read aliased scan session directories once --- sdk/typescript/src/cost.ts | 14 ++++- sdk/typescript/tests-ts/cost.test.ts | 94 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index c9696f92d4..405455733f 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -1,4 +1,4 @@ -import { open, readdir, readFile } from "node:fs/promises"; +import { open, readdir, readFile, realpath } from "node:fs/promises"; import { join } from "node:path"; import { estimateScanCost, @@ -284,8 +284,18 @@ export class ScanCostTracker { } // Recovery restores workers to their recorded home; the SDK parent can // continue in the current home. Apply the same scan membership to both. + const directories = new Set(); for (const home of homes) { - for await (const path of sessionFiles(join(home, "sessions"))) { + let directory: string; + try { + directory = await realpath(join(home, "sessions")); + } catch (error) { + if (isMissingFile(error)) continue; + throw error; + } + if (directories.has(directory)) continue; + directories.add(directory); + for await (const path of sessionFiles(directory)) { let session = this.#sessions.get(path); if (session === undefined) { session = createSessionUsage(); diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index b07d238994..5789ecf62d 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -1,11 +1,13 @@ import { spawnSync } from "node:child_process"; import { appendFile, + cp, mkdir, mkdtemp, realpath, readFile, rm, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -2388,4 +2390,96 @@ describe("recorded Deep worker homes", () => { await Promise.all(trackers.map((tracker) => tracker.stop())); } }); + + test("reads a recorded directory alias only once", async () => { + const home = await codexHome(); + const alias = join(await codexHome(), "recorded-home"); + await symlink( + home, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + const scanDirectory = join(home, "scan"); + const directory = join(scanDirectory, "artifacts", "deep_discovery"); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "execution-settings.json"), + JSON.stringify({ version: 1, settings: { codexHome: alias } }), + ); + await writeSession(home, "worker", { input_tokens: 100, output_tokens: 0 }); + const events: ScanSessionEvent[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + scanDirectory, + model: "gpt-5.6-sol", + onSessionEvent: (event) => events.push(event), + }); + tracker.start("worker"); + try { + expect((await tracker.stop()).cost?.inputTokens).toBe(100); + expect(events).toHaveLength(2); + } finally { + await tracker.stop(); + } + }); + + test("charges copied response records across homes once with their actual models", async () => { + const home = await codexHome(); + const recordedHome = await codexHome(); + const scanDirectory = join(home, "scan"); + const directory = join(scanDirectory, "artifacts", "deep_discovery"); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "execution-settings.json"), + JSON.stringify({ version: 1, settings: { codexHome: recordedHome } }), + ); + const path = await writeSession(home, "worker", {}); + for (const [id, model, input, output] of [ + ["response-one", "gpt-5.6-sol", 100, 10], + ["response-two", "gpt-6-astra", 50, 5], + ] as const) { + await appendFile( + path, + JSON.stringify({ + type: "token_usage_record", + payload: { + thread_id: "worker", + turn_id: "turn", + response_id: id, + model, + usage: { input_tokens: input, output_tokens: output }, + }, + }) + "\n", + ); + } + await mkdir(join(recordedHome, "sessions")); + await cp(path, join(recordedHome, "sessions", "copied-worker.jsonl")); + const tracker = new ScanCostTracker({ + codexHome: home, + scanDirectory, + model: "gpt-5.6-sol", + }); + tracker.start("worker"); + try { + const snapshot = await tracker.stop(); + expect(snapshot.usage).toMatchObject({ + input_tokens: 150, + output_tokens: 15, + total_tokens: 165, + }); + expect( + Object.fromEntries( + snapshot.cost!.modelCosts!.map((part) => [ + part.model, + [part.inputTokens, part.outputTokens], + ]), + ), + ).toEqual({ + "gpt-5.6-sol": [100, 10], + "gpt-6-astra": [50, 5], + }); + } finally { + await tracker.stop(); + } + }); }); From b04068baa0d627ccac9f7f6a66dcd087ea232395 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 13:11:10 +0000 Subject: [PATCH 119/251] Keep model usage with the most complete session copy --- sdk/typescript/src/cost.ts | 14 ++- sdk/typescript/tests-ts/cost.test.ts | 146 ++++++++++++++++----------- 2 files changed, 100 insertions(+), 60 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 405455733f..4db9da82b7 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -377,6 +377,7 @@ export class ScanCostTracker { if (!usages.has(threadId)) usages.set(threadId, null); } } + const usageSessions = new Map(); for (const [path, tracked] of this.#sessions) { const threadId = tracked.threadId; if (threadId === null || !included.has(threadId)) continue; @@ -419,6 +420,15 @@ export class ScanCostTracker { } this.#reportWorkerProgress(session); } + // A copied prefix must not supply model usage for a more complete log. + const previous = usageSessions.get(threadId); + if ( + previous === undefined || + (session.usage?.total_tokens ?? -1) > + (previous.usage?.total_tokens ?? -1) + ) { + usageSessions.set(threadId, session); + } if ( session.counterUsage && session.counterUsage.total_tokens > @@ -465,9 +475,7 @@ export class ScanCostTracker { let observedModel = false; for (const [threadId, value] of usages) { if (value === null) continue; - const session = [...this.#sessions.values()].find( - (item) => item.threadId === threadId, - ); + const session = usageSessions.get(threadId); for (const [model, tokens] of session?.modelUsage ?? []) { if (model !== null) observedModel = true; modelUsage.set( diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 5789ecf62d..04eafcce5e 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -2423,63 +2423,95 @@ describe("recorded Deep worker homes", () => { } }); - test("charges copied response records across homes once with their actual models", async () => { - const home = await codexHome(); - const recordedHome = await codexHome(); - const scanDirectory = join(home, "scan"); - const directory = join(scanDirectory, "artifacts", "deep_discovery"); - await mkdir(directory, { recursive: true }); - await writeFile( - join(directory, "execution-settings.json"), - JSON.stringify({ version: 1, settings: { codexHome: recordedHome } }), - ); - const path = await writeSession(home, "worker", {}); - for (const [id, model, input, output] of [ - ["response-one", "gpt-5.6-sol", 100, 10], - ["response-two", "gpt-6-astra", 50, 5], - ] as const) { - await appendFile( - path, - JSON.stringify({ - type: "token_usage_record", - payload: { - thread_id: "worker", - turn_id: "turn", - response_id: id, - model, - usage: { input_tokens: input, output_tokens: output }, - }, - }) + "\n", + test.each([ + ["identical", false], + ["identical", true], + ["prefix-first", false], + ["prefix-first", true], + ["prefix-last", false], + ["prefix-last", true], + ] as const)( + "prices copied response records (%s, attribution: %s)", + async (copy, attributed) => { + const home = await codexHome(); + const recordedHome = await codexHome(); + const scanDirectory = join(home, "scan"); + const directory = join(scanDirectory, "artifacts", "deep_discovery"); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "execution-settings.json"), + JSON.stringify({ version: 1, settings: { codexHome: recordedHome } }), ); - } - await mkdir(join(recordedHome, "sessions")); - await cp(path, join(recordedHome, "sessions", "copied-worker.jsonl")); - const tracker = new ScanCostTracker({ - codexHome: home, - scanDirectory, - model: "gpt-5.6-sol", - }); - tracker.start("worker"); - try { - const snapshot = await tracker.stop(); - expect(snapshot.usage).toMatchObject({ - input_tokens: 150, - output_tokens: 15, - total_tokens: 165, - }); - expect( - Object.fromEntries( - snapshot.cost!.modelCosts!.map((part) => [ - part.model, - [part.inputTokens, part.outputTokens], - ]), - ), - ).toEqual({ - "gpt-5.6-sol": [100, 10], - "gpt-6-astra": [50, 5], + const path = await writeSession(home, "worker", {}); + for (const [id, model, input, output] of [ + ["response-one", "gpt-5.6-sol", 100, 10], + ["response-two", "gpt-6-astra", 50, 5], + ] as const) { + await appendFile( + path, + JSON.stringify({ + type: "token_usage_record", + timestamp: "2026-09-01T00:00:02Z", + payload: { + thread_id: "worker", + turn_id: "turn", + response_id: id, + model, + usage: { input_tokens: input, output_tokens: output }, + }, + }) + "\n", + ); + } + await mkdir(join(recordedHome, "sessions")); + const copiedPath = join(recordedHome, "sessions", "copied-worker.jsonl"); + await cp(path, copiedPath); + const prefix = + (await readFile(path, "utf8")) + .trimEnd() + .split("\n") + .slice(0, -1) + .join("\n") + "\n"; + if (copy === "prefix-first") await writeFile(path, prefix); + if (copy === "prefix-last") await writeFile(copiedPath, prefix); + const tracker = new ScanCostTracker({ + codexHome: home, + scanDirectory, + model: "gpt-5.6-sol", }); - } finally { - await tracker.stop(); - } - }); + if (attributed) + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: ["worker"], + owner: { + threadId: null, + turnId: null, + startedAt: "2026-09-01T00:00:00Z", + }, + startedAt: "2026-09-01T00:00:00Z", + completedAt: null, + })); + tracker.start("worker"); + try { + const snapshot = await tracker.stop(); + expect(snapshot.usage).toMatchObject({ + input_tokens: 150, + output_tokens: 15, + total_tokens: 165, + }); + expect( + Object.fromEntries( + snapshot.cost!.modelCosts!.map((part) => [ + part.model, + [part.inputTokens, part.outputTokens], + ]), + ), + ).toEqual({ + "gpt-5.6-sol": [100, 10], + "gpt-6-astra": [50, 5], + }); + } finally { + await tracker.stop(); + } + }, + ); }); From 7de2361c0ec4a83bc88da2023905263c95fd193b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 13:19:40 +0000 Subject: [PATCH 120/251] fix: recover interrupted budget finalization locally --- .../scripts/workbench_saved_results.py | 20 +- .../scripts/workbench_scan_history.py | 10 +- .../test_budget_selection_publication.py | 242 ++++++++++++++++++ 3 files changed, 268 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 62340fa300..ba62a0d258 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1241,8 +1241,15 @@ def budget_exhausted_draft( path = db.artifact_path(scan_dir, name, required=False) if path is not None: documents[name] = db.read_json_object(path) + interrupted_documents = {} if documents and len(documents) != 3: - raise SystemExit("Budget-exhausted scan contains an incomplete canonical scan draft.") + if not before_selection or set(documents) not in ( + {"findings.json"}, + {"findings.json", "coverage.json"}, + ): + raise SystemExit("Budget-exhausted scan contains an incomplete canonical scan draft.") + interrupted_documents = documents + documents = {} if documents: manifest = documents["scan-manifest.json"] @@ -1380,11 +1387,20 @@ def budget_exhausted_draft( } ) coverage["completeness"] = "partial" - for name, payload in ( + outputs = ( ("findings.json", findings), ("coverage.json", coverage), ("scan-manifest.json", manifest), + ) + # Process death can leave a prefix of these derived writes before SQLite + # commits. Recover only bytes whose documents match the accepted inputs. + if any( + interrupted_documents[name] != payload + for name, payload in outputs + if name in interrupted_documents ): + raise SystemExit("Budget-exhausted scan contains an incomplete canonical scan draft.") + for name, payload in outputs: try: write_scan_local_bytes( scan_dir, diff --git a/plugins/codex-security/scripts/workbench_scan_history.py b/plugins/codex-security/scripts/workbench_scan_history.py index fc145bc045..e814adda9f 100644 --- a/plugins/codex-security/scripts/workbench_scan_history.py +++ b/plugins/codex-security/scripts/workbench_scan_history.py @@ -75,10 +75,16 @@ def cli_scan_resume( ): raise SystemExit("Resume requires the original owning CLI session.") run = connection.execute( - "SELECT status, cancel_requested FROM deep_scan_runs WHERE scan_id = ?", (scan["id"],) + "SELECT status, cancel_requested, finalization_input_json FROM deep_scan_runs " + "WHERE scan_id = ?", + (scan["id"],), ).fetchone() if run is not None and ( - run["status"] not in {"running", "succeeded"} or run["cancel_requested"] + run["status"] not in {"running", "succeeded"} + or ( + run["cancel_requested"] + and not (run["status"] == "succeeded" and run["finalization_input_json"] is not None) + ) ): raise SystemExit("This Deep Scan has stopped and cannot resume.") try: diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index bc8396cf68..d7956f6db3 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -3,7 +3,10 @@ import hashlib import json import sqlite3 +import subprocess +import sys from argparse import Namespace +from pathlib import Path import pytest from test_accepted_publication_references import accept_reducer @@ -433,3 +436,242 @@ def test_budget_completion_and_cancel_keep_the_committed_outcome( with pytest.raises(SystemExit, match="stopped"): workbench_api["write_scan_draft"](connection, staged) assert published_bytes(scan) == frozen + + +@pytest.mark.parametrize("cut", ["after-findings", "after-coverage", "after-draft-commit"]) +@pytest.mark.parametrize("has_reducer", [False, True]) +def test_budget_process_death_replays_exact_evidence( + workbench_api, workbench_db, publication_scan, tmp_path, cut, has_reducer +): + scan = publication_scan() + thread_id = "a23e657b-c14c-4da7-bd20-baa9e7579390" + workbench_api["set_scan_thread"]( + workbench_db, Namespace(scan_id=scan.scan_id, thread_id=thread_id) + ) + accepted = None + if has_reducer: + _, accepted, _ = accept_reducer(workbench_db, scan) + unmerged = accept_unmerged(workbench_db, scan) + originals = {str(p): p.read_bytes() for p in [unmerged, *([accepted] if accepted else [])]} + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status='running', workflow_version='deep-security-scan/v2'" + ) + database = tmp_path / "budget-process-cut.sqlite3" + with sqlite3.connect(database) as db: + workbench_db.backup(db) + budget = workbench_api["complete_budget_exhausted_scan"] + args = Namespace( + scan_id=scan.scan_id, + cost_json=json.dumps(BUDGET_COST), + message="Original synthetic cost stop.", + ) + marker = tmp_path / "crash-marker.json" + child_source = """ +from argparse import Namespace +from pathlib import Path +import json +import os +import runpy +import sqlite3 +import sys + +script, database, scan_id, cost_json, warning, cut, marker = sys.argv[1:] +api = runpy.run_path(str(script), run_name='fault_review_budget_child') +budget = api['complete_budget_exhausted_scan'] +deep = budget.__globals__['deep_scan'] +deep.configure(deep.DeepScanDependencies(**{ + name: api['preserve_stopped_results_after_transition' if name == 'preserve_stopped_results' else name] + for name in deep.DeepScanDependencies.__dataclass_fields__ +})) + + +def terminate(stage): + Path(marker).write_text(json.dumps({'stage': stage, 'pid': os.getpid()})) + os._exit(86) + + +if cut == 'after-draft-commit': + budget.__globals__['complete_scan_locked'] = lambda *a, **kw: terminate(cut) +else: + saved = budget.__globals__['saved_results'] + original = saved.write_scan_local_bytes + + def write_then_die(scan_dir, name, contents): + result = original(scan_dir, name, contents) + if name == ('findings.json' if cut == 'after-findings' else 'coverage.json'): + terminate(cut) + return result + + saved.write_scan_local_bytes = write_then_die + +with sqlite3.connect(database) as connection: + connection.row_factory = sqlite3.Row + connection.execute('PRAGMA foreign_keys=ON') + budget(connection, Namespace(scan_id=scan_id, cost_json=cost_json, message=warning)) +os._exit(87) +""" + child = subprocess.run( + [ + sys.executable, + "-c", + child_source, + workbench_api["__file__"], + str(database), + scan.scan_id, + args.cost_json, + args.message, + cut, + str(marker), + ], + capture_output=True, + text=True, + ) + assert child.returncode == 86, (child.returncode, child.stdout, child.stderr) + assert json.loads(marker.read_text())["stage"] == cut + before = { + name: (scan.scan_dir / name).exists() + for name in ("findings.json", "coverage.json", "scan-manifest.json") + } + with sqlite3.connect(database) as db: + db.row_factory = sqlite3.Row + db.execute("PRAGMA foreign_keys=ON") + run = dict(db.execute("SELECT * FROM deep_scan_runs").fetchone()) + snapshot = { + "cut": cut, + "hasReducer": has_reducer, + "database": str(database), + "scanDir": str(scan.scan_dir), + "beforeFiles": before, + "beforeStatus": run["status"], + "beforeSelection": run["finalization_input_json"], + } + saved_scan = workbench_api["require_scan"](db, scan.scan_id) + resumed = workbench_api["scan_history"].cli_scan_resume( + db, + saved_scan, + workbench_api["require_workspace"](db, saved_scan["workspace_id"]), + **{ + name: workbench_api[source] + for name, source in { + "parse_scan_recipe": "parse_scan_recipe", + "scan_contract": "scan_contract", + "require_scan_directory": "require_canonical_scan_directory", + "artifact_path": "artifact_path", + "read_json_object": "read_json_object", + "workbench_completion_binding": "workbench_completion_binding", + }.items() + }, + ) + assert resumed["scanId"] == scan.scan_id + assert resumed["threadId"] == thread_id + try: + budget(db, args) + except BaseException as error: + snapshot["replayError"] = str(error) + (tmp_path / "crash-result.json").write_text(json.dumps(snapshot, indent=2)) + raise + snapshot["afterStatus"] = db.execute("SELECT status FROM scans").fetchone()[0] + snapshot["afterSelection"] = db.execute( + "SELECT finalization_input_json FROM deep_scan_runs" + ).fetchone()[0] + (tmp_path / "crash-result.json").write_text(json.dumps(snapshot, indent=2)) + assert snapshot["afterStatus"] == "complete" + if run["finalization_input_json"] is not None: + assert snapshot["afterSelection"] == run["finalization_input_json"] + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert len(findings) == int(has_reducer) + assert coverage["completeness"] == "partial" + assert len({x["id"] for x in coverage["deferred"]}) == len(coverage["deferred"]) + assert all(Path(path).read_bytes() == data for path, data in originals.items()) + + +@pytest.mark.parametrize("files", ["findings", "coverage", "manifest", "findings-and-coverage"]) +def test_budget_rejects_unrelated_incomplete_drafts( + workbench_api, workbench_db, publication_scan, files +): + scan = publication_scan() + accept_unmerged(workbench_db, scan) + keep = { + "findings": {"findings.json"}, + "coverage": {"coverage.json"}, + "manifest": {"scan-manifest.json"}, + "findings-and-coverage": {"findings.json", "coverage.json"}, + }[files] + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + if name not in keep: + (scan.scan_dir / name).unlink() + before = {name: (scan.scan_dir / name).read_bytes() for name in keep} + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', workflow_version = 'deep-security-scan/v2'" + ) + with pytest.raises(SystemExit, match="incomplete canonical scan draft"): + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, + cost_json=json.dumps(BUDGET_COST), + message="Original cost stop.", + ), + ) + assert {name: (scan.scan_dir / name).read_bytes() for name in keep} == before + assert all( + not (scan.scan_dir / name).exists() + for name in ("scan-manifest.json", "findings.json", "coverage.json") + if name not in keep + ) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + + +@pytest.mark.parametrize( + "state", ["parent-canceled", "stopping", "failed", "canceled", "unselected"] +) +def test_budget_resume_keeps_explicit_stop_and_unselected_guards( + workbench_api, workbench_db, publication_scan, state +): + scan = publication_scan() + workbench_api["set_scan_thread"]( + workbench_db, + Namespace(scan_id=scan.scan_id, thread_id="a23e657b-c14c-4da7-bd20-baa9e7579390"), + ) + with workbench_db: + if state == "parent-canceled": + workbench_db.execute("UPDATE scans SET canceled_at = ?", (scan.timestamp,)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = ?, cancel_requested = 1, finalization_input_json = NULL", + ( + "running" + if state == "stopping" + else state + if state in {"failed", "canceled"} + else "succeeded", + ), + ) + saved_scan = workbench_api["require_scan"](workbench_db, scan.scan_id) + with pytest.raises(SystemExit, match="cannot resume"): + workbench_api["scan_history"].cli_scan_resume( + workbench_db, + saved_scan, + workbench_api["require_workspace"](workbench_db, saved_scan["workspace_id"]), + **{ + name: workbench_api[source] + for name, source in { + "parse_scan_recipe": "parse_scan_recipe", + "scan_contract": "scan_contract", + "require_scan_directory": "require_canonical_scan_directory", + "artifact_path": "artifact_path", + "read_json_object": "read_json_object", + "workbench_completion_binding": "workbench_completion_binding", + }.items() + }, + ) From dc6016128f9f3fdabc0b7535b213ef00284a6fc3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 14:15:15 +0000 Subject: [PATCH 121/251] Cancel registered Deep scans before thread initialization --- sdk/typescript/src/api.ts | 15 ++-- .../tests-ts/deep-finalization.test.ts | 88 +++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 7bd3c104cb..ed0777bf6c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2496,11 +2496,9 @@ export class CodexSecurity { return result; } catch {} } - const cancellationThreadId = - activeScan?.mode === "deep" && options.signal?.aborted - ? observedScanThreadId - : undefined; - if (activeScan !== null && cancellationThreadId !== undefined) { + const callerCanceledDeepScan = + activeScan?.mode === "deep" && options.signal?.aborted; + if (activeScan !== null && callerCanceledDeepScan) { const workbenchOptions = { ...activeScan.options, signal: undefined }; // The workbench owns the running-state check and repeated cancellation. // Selection may have committed before the SDK received its response. @@ -2508,15 +2506,16 @@ export class CodexSecurity { "cancel-scan", "--scan-id", activeScan.id, - "--thread-id", - cancellationThreadId, + ...(observedScanThreadId === undefined + ? [] + : ["--thread-id", observedScanThreadId]), ]).catch(() => undefined); } // Publication failures remain resumable. A cost stop or explicit client close // still uses the existing failure path to retain partial results and stop work. if ( activeScan !== null && - cancellationThreadId === undefined && + !callerCanceledDeepScan && ((options.resumeScanId === undefined && !selectedDeepFinalization) || (selectedDeepFinalization && !options.signal?.aborted && diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index f6156686fe..a4b6987429 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -26,6 +26,94 @@ afterEach(cleanup); const threadId = "1af317a1-c9ed-4c73-b428-cb0d160cf8e8"; const followUp = "Explain the selected finding."; +for (const boundary of ["registration", "stream-start"] as const) { + test(`SDK cancels registered Deep Scan before first thread event: ${boundary}`, async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const scanDir = join(root, "scan"); + const codexHome = join(root, "codex-home"); + await Promise.all([ + mkdir(repository), + mkdir(scanDir, { mode: 0o700 }), + mkdir(codexHome), + ]); + await writeFile(join(repository, "source.py"), "# Synthetic source\n"); + const environment = { + ...process.env, + CODEX_HOME: codexHome, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }; + const cancellation = new AbortController(); + const reason = new Error( + "Synthetic cancellation before first thread event", + ); + const commands: string[][] = []; + let scanId = ""; + let savedOptions: WorkbenchCommandOptions; + let startedTurns = 0; + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment, + }), + resolvePluginPython: async () => "python3", + prepareOutputDir: async () => scanDir, + runWorkbench: async (options, args, input) => { + savedOptions = options; + commands.push([...args]); + const result = await runWorkbench(options, args, input); + if (args[0] === "register-cli-scan") { + scanId = result["scanId"] as string; + if (boundary === "registration") cancellation.abort(reason); + } + return result; + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + runStreamed: async () => { + startedTurns++; + cancellation.abort(reason); + throw reason; + }, + }), + }), + }, + ); + try { + const error = await client + .run(repository, { + mode: "deep", + signal: cancellation.signal, + postScanPrompt: followUp, + }) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(ScanInterruptedError); + expect((error as ScanInterruptedError).cause).toBe(reason); + const stopped = await runWorkbench( + { ...savedOptions!, signal: undefined }, + ["get-scan", "--scan-id", scanId], + ); + expect(stopped["scan"]).toMatchObject({ + progress: { status: "canceled" }, + }); + expect(commands.filter((args) => args[0] === "cancel-scan")).toEqual([ + ["cancel-scan", "--scan-id", scanId], + ]); + expect(commands.some((args) => args[0] === "fail-scan")).toBe(false); + expect(commands.some((args) => args[0] === "set-scan-thread")).toBe( + false, + ); + expect(startedTurns).toBe(boundary === "registration" ? 0 : 1); + } finally { + await client.close(); + } + }, 30_000); +} + const outcomes = [ "failed", "completed", From 6632cc3ed9fe5a369007748327270ec487607003 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 14:15:15 +0000 Subject: [PATCH 122/251] Replay sealed budget results through parent completion --- .../scripts/workbench_saved_results.py | 18 ++++- .../test_budget_selection_publication.py | 79 ++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index ba62a0d258..070d437552 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1263,7 +1263,23 @@ def budget_exhausted_draft( if not isinstance(coverage.get(key), list): raise SystemExit("Budget-exhausted scan contains invalid canonical coverage.") if manifest["scan"].get("sealedAt") is not None or manifest["scan"].get("artifacts"): - raise SystemExit("Budget-exhausted scan cannot replace an already sealed scan draft.") + if before_selection: + raise SystemExit( + "Budget-exhausted scan cannot replace an already sealed scan draft." + ) + # The seal can reach disk before parent completion commits. Validate + # it without changing bytes; the existing finalizer commits replay. + try: + _prepare_scan_finalization( + scan_dir, + expected_coverage_mode=db.expected_coverage_mode(scan), + completion_binding=db.workbench_completion_binding( + scan, scan["started_at"], manifest + ), + ) + except ContractError as exc: + raise SystemExit(str(exc)) from exc + return else: target_contract = contract["target"] target: dict[str, Any] = { diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index d7956f6db3..7137059ad7 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -438,7 +438,9 @@ def test_budget_completion_and_cancel_keep_the_committed_outcome( assert published_bytes(scan) == frozen -@pytest.mark.parametrize("cut", ["after-findings", "after-coverage", "after-draft-commit"]) +@pytest.mark.parametrize( + "cut", ["after-findings", "after-coverage", "after-draft-commit", "after-seal"] +) @pytest.mark.parametrize("has_reducer", [False, True]) def test_budget_process_death_replays_exact_evidence( workbench_api, workbench_db, publication_scan, tmp_path, cut, has_reducer @@ -498,6 +500,14 @@ def terminate(stage): if cut == 'after-draft-commit': budget.__globals__['complete_scan_locked'] = lambda *a, **kw: terminate(cut) +elif cut == 'after-seal': + original = budget.__globals__['_write_prepared_scan_finalization'] + + def seal_then_die(prepared): + original(prepared) + terminate(cut) + + budget.__globals__['_write_prepared_scan_finalization'] = seal_then_die else: saved = budget.__globals__['saved_results'] original = saved.write_scan_local_bytes @@ -538,6 +548,9 @@ def write_then_die(scan_dir, name, contents): name: (scan.scan_dir / name).exists() for name in ("findings.json", "coverage.json", "scan-manifest.json") } + sealed_bytes = published_bytes(scan) if cut == "after-seal" else None + if sealed_bytes is not None: + assert json.loads((scan.scan_dir / "scan-manifest.json").read_text())["scan"]["sealedAt"] with sqlite3.connect(database) as db: db.row_factory = sqlite3.Row db.execute("PRAGMA foreign_keys=ON") @@ -582,6 +595,8 @@ def write_then_die(scan_dir, name, contents): ).fetchone()[0] (tmp_path / "crash-result.json").write_text(json.dumps(snapshot, indent=2)) assert snapshot["afterStatus"] == "complete" + if sealed_bytes is not None: + assert published_bytes(scan) == sealed_bytes if run["finalization_input_json"] is not None: assert snapshot["afterSelection"] == run["finalization_input_json"] findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] @@ -633,6 +648,68 @@ def test_budget_rejects_unrelated_incomplete_drafts( assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" +@pytest.mark.parametrize( + "state", + ["changed-findings", "wrong-scan", "running-discovery", "canceled", "other-owner", "complete"], +) +def test_budget_sealed_replay_keeps_integrity_and_ownership_guards( + workbench_api, workbench_db, publication_scan, monkeypatch, state +): + scan = publication_scan() + accept_reducer(workbench_db, scan) + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', workflow_version = 'deep-security-scan/v2'" + ) + budget = workbench_api["complete_budget_exhausted_scan"] + complete = budget.__globals__["complete_scan_locked"] + args = Namespace( + scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message="Original cost stop." + ) + with monkeypatch.context() as patch: + patch.setitem( + budget.__globals__, + "complete_scan_locked", + lambda *a: complete(*a, prepare_only=True), + ) + budget(workbench_db, args) + manifest = scan.scan_dir / "scan-manifest.json" + assert json.loads(manifest.read_text())["scan"]["sealedAt"] + if state in {"changed-findings", "wrong-scan"}: + path = scan.scan_dir / ( + "findings.json" if state == "changed-findings" else "scan-manifest.json" + ) + document = json.loads(path.read_text()) + if state == "changed-findings": + document["findings"] = [] + else: + document["scan"]["id"] = "69078890-d24c-4416-a6fa-c286825bef88" + path.write_text(json.dumps(document)) + elif state == "running-discovery": + with workbench_db: + workbench_db.execute("UPDATE deep_scan_runs SET status = 'running'") + elif state == "canceled": + workbench_api["cancel_scan"](workbench_db, Namespace(scan_id=scan.scan_id, thread_id=None)) + elif state == "other-owner": + with workbench_db: + workbench_db.execute( + "UPDATE scans SET handoff_claim_token = 'a3292ae4-9b47-430f-8ed6-73ff73db575c'" + ) + else: + complete(workbench_db, scan.scan_id, None, args.cost_json) + before_files = published_bytes(scan) + before_scan = dict(workbench_db.execute("SELECT * FROM scans").fetchone()) + before_run = dict(workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone()) + with pytest.raises(SystemExit): + budget(workbench_db, args) + assert published_bytes(scan) == before_files + assert dict(workbench_db.execute("SELECT * FROM scans").fetchone()) == before_scan + assert dict(workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone()) == before_run + + @pytest.mark.parametrize( "state", ["parent-canceled", "stopping", "failed", "canceled", "unselected"] ) From fc3180c26112133cdca580b573e194e9afb9c5c9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 14:13:57 +0000 Subject: [PATCH 123/251] Preserve distinct unmerged coverage surfaces at cost completion --- .../scripts/workbench_saved_results.py | 2 +- .../tests/test_budget_coverage_provenance.py | 145 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 plugins/codex-security/tests/test_budget_coverage_provenance.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 070d437552..5d69234a1d 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1464,7 +1464,7 @@ def retain(field: str, item: dict[str, Any]) -> None: } item["id"] = f"{prefix}-{field}-{index + 1}" if field == "surfaces": - item["id"] = surfaces.get(original.get("id"), item["id"]) + item["id"] = f"{prefix}-surface-{index + 1}" item["receiptRefs"] = [ f"{artifact_prefix}/{ref}" for ref in item.get("receiptRefs", []) ] diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py new file mode 100644 index 0000000000..479c45bbcf --- /dev/null +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import hashlib +import json +from argparse import Namespace + +import pytest +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_publication_stop_interleavings import published_bytes +from test_workbench_db import BUDGET_COST + + +@pytest.mark.parametrize("explicit_ids", [True, False], ids=["named-surfaces", "omitted-ids"]) +def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( + workbench_api, workbench_db, publication_scan, explicit_ids +): + scan = publication_scan() + worker_id = add_worker(workbench_db, scan).parent.name + output = scan.scan_dir / "artifacts/deep_discovery/workers/discovery-0001/output" + output.mkdir(parents=True) + surfaces = [ + { + "label": "Filesystem boundary", + "disposition": "needs_follow_up", + "notes": "Filesystem race remains untested.", + }, + { + "label": "Template boundary", + "disposition": "needs_follow_up", + "notes": "Template caller policy remains untested.", + }, + ] + receipts = {} + for index, surface in enumerate(surfaces, start=1): + if explicit_ids: + surface["id"] = f"source-surface-{index}" + relative = f"artifacts/review-{index}.md" + surface["receiptRefs"] = [relative] + receipt = output / relative + receipt.parent.mkdir(exist_ok=True) + receipts[receipt] = f"Independent review receipt {index}.\n".encode() + receipt.write_bytes(receipts[receipt]) + deferred = {"reason": "Both independent validation tasks remain unfinished."} + if explicit_ids: + deferred["surfaceIds"] = [surface["id"] for surface in surfaces] + contents = json.dumps( + { + "scanId": scan.scan_id, + "complete": True, + "findings": scan.findings, + "coverage": { + **scan.coverage, + "completeness": "partial", + "surfaces": surfaces, + "deferred": [deferred], + }, + } + ).encode() + digest = hashlib.sha256(contents).hexdigest() + accepted = output / "checkpoints" / f"{digest}.json" + accepted.parent.mkdir() + accepted.write_bytes(contents) + # The mutable result may advance after acceptance. Publication must read the + # accepted checkpoint, while receipt paths remain relative to its owner. + result = output / "result.json" + result.write_text("A later, unaccepted result.") + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_workers SET artifact_dir = ?, result_manifest_path = ?, " + "prompt_path = ?, merge_state = 'buffered' WHERE id = ?", + (str(output), str(result), str(output.parent / "prompt.md"), worker_id), + ) + workbench_db.execute( + "INSERT INTO deep_scan_attempts (scan_id, worker_id, attempt, status, started_at, " + "completed_at, accepted_result_path, accepted_result_sha256) " + "VALUES (?, ?, 1, 'succeeded', ?, ?, ?, ?)", + (scan.scan_id, worker_id, scan.timestamp, scan.timestamp, str(accepted), digest), + ) + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'discovery', " + "workflow_version = 'deep-security-scan/v2', manifest_path = NULL, " + "terminal_reason = NULL, completed_at = NULL, max_discovery_runs = 100" + ) + counter_query = ( + "SELECT discovery_runs_dispatched, completion_sequence, consecutive_no_new " + "FROM deep_scan_runs" + ) + counters = tuple(workbench_db.execute(counter_query).fetchone()) + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, + cost_json=json.dumps(BUDGET_COST), + message="Scan reached its original cost limit.", + ), + ) + + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert coverage["completeness"] == "partial" + actual = coverage["surfaces"] + assert [(item["label"], item["notes"]) for item in actual] == [ + (item["label"], item["notes"]) for item in surfaces + ] + assert len({item["id"] for item in actual}) == len(surfaces) + for original, retained in zip(surfaces, actual, strict=True): + assert retained["provenance"] == { + "workerId": worker_id, + "attempt": 1, + **({"sourceId": original["id"]} if explicit_ids else {}), + } + assert retained["receiptRefs"] == [ + (output / ref).relative_to(scan.scan_dir).as_posix() for ref in original["receiptRefs"] + ] + for path, receipt_contents in receipts.items(): + assert path.read_bytes() == receipt_contents + obligation = next(item for item in coverage["deferred"] if item["reason"] == deferred["reason"]) + if explicit_ids: + assert obligation["surfaceIds"] == [item["id"] for item in actual] + assert {"workerId": worker_id, "attempt": 1, "completeness": "partial"} in coverage["reviews"] + report = (scan.scan_dir / "report.md").read_text() + assert all(surface["notes"] in report for surface in surfaces) + assert deferred["reason"] in report + assert json.loads((scan.scan_dir / "findings.json").read_text())["findings"] == [] + assert tuple(workbench_db.execute(counter_query).fetchone()) == counters + attempt = workbench_db.execute( + "SELECT accepted_result_path, accepted_result_sha256 FROM deep_scan_attempts " + "WHERE worker_id = ?", + (worker_id,), + ).fetchone() + assert tuple(attempt) == (str(accepted), digest) + assert accepted.read_bytes() == contents + before = published_bytes(scan) + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert published_bytes(scan) == before + assert accepted.read_bytes() == contents From 210dcae92f7386c139937e3f717ab722f959f8ee Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 14:15:52 +0000 Subject: [PATCH 124/251] Keep complete usage from copied worker rollouts --- .../scripts/workbench_scan_usage.py | 62 +++++++++++++-- .../tests/test_workbench_scan_usage.py | 76 +++++++++++++++++-- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 52667684b8..5a4da11c78 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -144,7 +144,7 @@ def collect_scan_usage( if started_at is None: return _unavailable_usage("scan_window_unavailable") - sessions: list[RolloutSession] = [] + sessions: dict[str, list[RolloutSession]] = {} missing_thread_ids: set[str] = set() seen_thread_ids: set[str] = set() for state_database, group_roots in groups: @@ -165,9 +165,10 @@ def collect_scan_usage( continue missing_thread_ids.update(missing) for session in discovered: - if session.thread_id not in seen_thread_ids: - sessions.append(session) - seen_thread_ids.add(session.thread_id) + copies = sessions.setdefault(session.thread_id, []) + if session not in copies: + copies.append(session) + seen_thread_ids.add(session.thread_id) # Absence from one known index is not missing usage when another has it. missing_thread_ids.difference_update(seen_thread_ids) @@ -187,7 +188,8 @@ def collect_scan_usage( accepted_thread_ids: set[str] = set() excluded_thread_ids: set[str] = set() model_usage: dict[str | None, dict[str, int]] = {} - for session in sessions: + for copies in sessions.values(): + session = copies[0] owner_turn_id = None if ( attribution @@ -211,8 +213,8 @@ def collect_scan_usage( warnings.add("thread_lineage_incomplete") continue try: - session_usage, session_warnings = _read_rollout_usage( - session, + session_usage, session_warnings = _read_rollout_copies_usage( + copies, started_at=started_at, completed_at=stopped_at, owner_turn_id=owner_turn_id, @@ -256,6 +258,52 @@ def collect_scan_usage( return result +def _read_rollout_copies_usage( + copies: list[RolloutSession], + *, + started_at: datetime, + completed_at: datetime | None, + owner_turn_id: str | None, + model_usage: dict[str | None, dict[str, int]], +) -> tuple[dict[str, int], set[str]]: + readings = [] + for session in copies: + local_models: dict[str | None, dict[str, int]] = {} + try: + usage, warnings = _read_rollout_usage( + session, + started_at=started_at, + completed_at=completed_at, + owner_turn_id=owner_turn_id, + model_usage=local_models, + ) + except (OSError, UnicodeError, ValueError): + continue + readings.append((usage, warnings, local_models)) + if not readings: + raise ValueError("No readable rollout copy.") + attributable = [ + reading + for reading in readings + if not reading[1].intersection( + { + "thread_identity_mismatch", + "thread_ownership_unavailable", + "thread_outside_scan_window", + "token_usage_unavailable", + } + ) + ] + # Restored indexes can reference a prefix and its complete continuation. + # Keep totals and model attribution from the same copy, counting it once. + usage, warnings, selected_models = max( + attributable or readings, key=lambda reading: reading[0]["totalTokens"] + ) + for model, tokens in selected_models.items(): + _add_token_usage(model_usage.setdefault(model, _empty_token_usage()), tokens) + return usage, warnings + + def _scan_root_thread_ids( connection: sqlite3.Connection, scan: sqlite3.Row, diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 95102ac8d6..60fc4805e1 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -645,11 +645,29 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N } -@pytest.mark.parametrize("worker_home", ["recorded", "current", "inherited-sqlite", "unavailable"]) +@pytest.mark.parametrize( + "worker_home", + [ + "recorded", + "current", + "inherited-sqlite", + "current-prefix", + "recorded-prefix", + "current-unreadable", + "current-mismatched", + "unavailable", + ], +) def test_completion_keeps_owner_and_workers_in_their_recorded_homes( tmp_path: Path, worker_home: str ) -> None: current_home = tmp_path / "current-home" + copied_rollout = worker_home in { + "current-prefix", + "recorded-prefix", + "current-unreadable", + "current-mismatched", + } environment = { "CODEX_HOME": str(current_home), "CODEX_SQLITE_HOME": str(current_home / "sqlite"), @@ -749,8 +767,24 @@ def complete(index: int) -> dict[str, Any]: root, thread_id, [ + *( + [_event(counted, "turn_context", {"model": "model-alpha"})] + if copied_rollout and kind == "discovery" + else [] + ), _token_event(counted, index * 20, 3), - _event(counted, "turn_context", {"turn_id": "resumed"}), + _event( + counted, + "turn_context", + { + "turn_id": "resumed", + **( + {"model": "model-beta"} + if copied_rollout and kind == "discovery" + else {} + ), + }, + ), _token_event(counted, index * 30, 5), ], ) @@ -778,10 +812,34 @@ def complete(index: int) -> dict[str, Any]: {f"discovery-{index}": worker_threads[f"discovery-{index}"]}, [], ) - elif worker_home == "recorded": + elif worker_home in { + "recorded", + "current-prefix", + "recorded-prefix", + "current-unreadable", + "current-mismatched", + }: + recorded_threads = dict(worker_threads) + if worker_home != "recorded": + thread_id = f"discovery-{index}" + full = worker_threads[thread_id] + copied = full.with_name(f"copied-{thread_id}.jsonl") + copied.write_bytes(b"\n".join(full.read_bytes().splitlines()[:3]) + b"\n") + if worker_home == "current-unreadable": + copied.write_text("invalid session metadata\n") + elif worker_home == "current-mismatched": + copied.write_text(full.read_text().replace(thread_id, "unrelated-thread")) + current_copy = copied + if worker_home == "recorded-prefix": + recorded_threads[thread_id] = copied + current_copy = full + with sqlite3.connect(environment["CODEX_STATE_DB"]) as connection: + connection.execute( + "INSERT INTO threads VALUES (?, ?)", (thread_id, str(current_copy)) + ) _state_graph( {"CODEX_SQLITE_HOME": str(selected_home)}, - worker_threads, + recorded_threads, [(f"discovery-{index}", child_id)], ) result = _complete_scan(fixture)["scan"]["usage"] @@ -804,7 +862,15 @@ def complete(index: int) -> dict[str, Any]: "source": "codex_rollout", **_counts(index * 77, 0, 13), "threadCount": 4, - "modelUsage": [{"model": None, **_counts(index * 77, 0, 13)}], + "modelUsage": ( + [ + {"model": None, **_counts(index * 47, 0, 8)}, + {"model": "model-alpha", **_counts(index * 20, 0, 3)}, + {"model": "model-beta", **_counts(index * 10, 0, 2)}, + ] + if copied_rollout + else [{"model": None, **_counts(index * 77, 0, 13)}] + ), } From 01f57e1af5957fe142402a59417ba9082a1a2ff8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 14:25:21 +0000 Subject: [PATCH 125/251] Preserve event occurrences and expose known cost internally --- sdk/typescript/src/cost-model.ts | 44 +++++ sdk/typescript/src/cost.ts | 54 +++++-- sdk/typescript/tests-ts/cost.test.ts | 230 ++++++++++++++++++++++++++- 3 files changed, 316 insertions(+), 12 deletions(-) diff --git a/sdk/typescript/src/cost-model.ts b/sdk/typescript/src/cost-model.ts index fe23706829..c6781bfb50 100644 --- a/sdk/typescript/src/cost-model.ts +++ b/sdk/typescript/src/cost-model.ts @@ -147,6 +147,50 @@ export function estimateScanCost( : cost; } +// Internal budget enforcement only. The public estimate remains unavailable +// when some attributed usage has no price. +export function estimateScanCostLowerBound( + model: string | undefined, + usage: unknown, +): ScanCost | null { + if (!isRecord(usage) || !Array.isArray(usage["modelUsage"])) return null; + const total = tokenUsage(usage); + if (total === null) return null; + const keys = [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "reasoning_output_tokens", + ] as const; + const observed = Object.fromEntries(keys.map((key) => [key, 0])); + const priced = Object.fromEntries(keys.map((key) => [key, 0])); + const parts: Record[] = []; + let cacheWritesReported = true; + for (const part of usage["modelUsage"]) { + const normalized = tokenUsage(part); + if (!isRecord(part) || normalized === null) return null; + for (const key of keys) observed[key]! += normalized[key]; + if ( + typeof part["model"] !== "string" || + estimateModelCost(part["model"], part) === null + ) + continue; + parts.push(part); + for (const key of keys) priced[key]! += normalized[key]; + if (normalized.cache_write_input_tokens_reported === false) + cacheWritesReported = false; + } + // A malformed partition is not evidence of an enforceable lower bound. + if (keys.some((key) => observed[key] !== total[key])) return null; + return estimateScanCost(model, { + ...priced, + cache_write_input_tokens_reported: cacheWritesReported, + modelUsage: parts, + coverage: "partial", + }); +} + function estimateModelCost( model: string | undefined, usage: unknown, diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 4db9da82b7..860e705bb6 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { open, readdir, readFile, realpath } from "node:fs/promises"; import { join } from "node:path"; import { estimateScanCost, + estimateScanCostLowerBound, tokenUsage, type ScanCost, type ScanTokenUsage, @@ -69,7 +71,8 @@ interface SessionUsage { prose: Set; reasoning: SessionReasoning | null; reasoningCount: number; - events?: Record[]; + eventIndex: number; + events?: { index: number; event: Record }[]; } interface ScanCostTrackerOptions { @@ -80,6 +83,8 @@ interface ScanCostTrackerOptions { maxCostUsd?: number; expectedFilesTotal?: number; onCost?: (cost: Readonly) => void; + // Only reported when the full public estimate is unavailable. + onCostLowerBound?: (cost: Readonly) => void; onActivity?: (activity: ScanActivity) => void; onProgress?: (progress: ScanProgress) => void; onSessionEvent?: (event: ScanSessionEvent) => void; @@ -125,6 +130,7 @@ function createSessionUsage(): SessionUsage { prose: new Set(), reasoning: null, reasoningCount: 0, + eventIndex: 0, }; } @@ -135,11 +141,13 @@ export class ScanCostTracker { readonly #workers = new Map(); readonly #workerProgress = new Map(); readonly #reportedProgress = new Set(); + readonly #reportedSessionEvents = new Map>(); #threadId: string | null = null; #timer: NodeJS.Timeout | null = null; #pending: Promise = Promise.resolve(); #snapshot: ScanCostSnapshot = { usage: null, cost: null }; #lastCost: string | null = null; + #lastCostLowerBound: string | null = null; #highestFilesCompleted = 0; #expectedFilesTotal: number | undefined; #attribution: ScanExecutionAttribution | null = null; @@ -181,6 +189,7 @@ export class ScanCostTracker { if ( this.#options.maxCostUsd === undefined && this.#options.onCost === undefined && + this.#options.onCostLowerBound === undefined && this.#options.onActivity === undefined && this.#options.onProgress === undefined && this.#options.onSessionEvent === undefined @@ -236,7 +245,7 @@ export class ScanCostTracker { return this.#snapshot; const cost = estimateScanCost(this.#options.model, fallbackUsage); this.#snapshot = { usage: fallbackUsage ?? null, cost }; - this.#reportCost(cost); + this.#reportCost(cost, fallbackUsage); return this.#snapshot; } @@ -402,7 +411,20 @@ export class ScanCostTracker { worker = this.#workers.get(threadId) ?? this.#workers.size + 1; this.#workers.set(threadId, worker); } - for (const event of session.events?.splice(0) ?? []) { + for (const { index, event } of session.events?.splice(0) ?? []) { + let reported = this.#reportedSessionEvents.get(threadId); + if (reported === undefined) { + reported = new Set(); + this.#reportedSessionEvents.set(threadId, reported); + } + // A physical copy keeps each event's position, including repeated + // identical events. Positions count unfiltered records so attribution + // changes can replay the same log without changing occurrence identity. + const identity = `${index}:${createHash("sha256") + .update(JSON.stringify(event)) + .digest("hex")}`; + if (reported.has(identity)) continue; + reported.add(identity); this.#options.onSessionEvent?.({ threadId, parentThreadId: session.parentThreadId, @@ -513,7 +535,7 @@ export class ScanCostTracker { : reconciled; const cost = estimateScanCost(this.#options.model, measured); this.#snapshot = { usage: measured, cost }; - this.#reportCost(cost); + this.#reportCost(cost, measured); } #reportWorkerProgress(session: SessionUsage): void { @@ -556,8 +578,17 @@ export class ScanCostTracker { } } - #reportCost(cost: ScanCost | null): void { - if (cost === null) return; + #reportCost(cost: ScanCost | null, usage: unknown): void { + if (cost === null) { + if (this.#options.onCostLowerBound === undefined) return; + const lowerBound = estimateScanCostLowerBound(this.#options.model, usage); + if (lowerBound === null) return; + const signature = JSON.stringify(lowerBound); + if (signature === this.#lastCostLowerBound) return; + this.#lastCostLowerBound = signature; + this.#options.onCostLowerBound(lowerBound); + return; + } const signature = JSON.stringify(cost); if (signature === this.#lastCost) return; this.#lastCost = signature; @@ -685,10 +716,11 @@ function readSessionEvent( } if (!isRecord(event) || !isRecord(event["payload"])) return; const payload = event["payload"]; + const index = session.eventIndex++; if (event["type"] === "session_meta") { if (session.threadId !== null) { session.replaying = payload["id"] !== session.threadId; - if (!session.replaying) session.events?.push(event); + if (!session.replaying) session.events?.push({ index, event }); return; } if (typeof payload["id"] === "string") { @@ -700,7 +732,7 @@ function readSessionEvent( if (typeof payload["model"] === "string") session.model = payload["model"]; session.startedAt = sessionStartedAt(payload["timestamp"]); session.parentThreadId = sessionParentThreadId(payload); - session.events?.push(event); + session.events?.push({ index, event }); return; } if (session.replaying) { @@ -721,7 +753,7 @@ function readSessionEvent( : turnOrder !== null && turnOrder >= threadOrder; if (owned) { session.replaying = false; - session.events?.push(event); + session.events?.push({ index, event }); } } return; @@ -785,7 +817,7 @@ function readSessionEvent( model, addTokenUsage(session.modelUsage.get(model) ?? null, usage), ); - session.events?.push(event); + session.events?.push({ index, event }); return; } const attributable = @@ -796,7 +828,7 @@ function readSessionEvent( session.currentTurnId, event["timestamp"], ); - if (attributable) session.events?.push(event); + if (attributable) session.events?.push({ index, event }); if ( !attributable && !(event["type"] === "event_msg" && payload["type"] === "token_count") diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 04eafcce5e..cfc1160aa6 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -18,9 +18,14 @@ import { estimateScanCost, ScanCostTracker, type ScanSessionEvent, + type ScanCost, } from "../src/cost.js"; import type { ScanActivity } from "../src/scan-activity.js"; -import { formatTokenUsage, tokenUsage } from "../src/cost-model.js"; +import { + estimateScanCostLowerBound, + formatTokenUsage, + tokenUsage, +} from "../src/cost-model.js"; import { readScanLogs } from "../src/scan-logs.js"; import { sessionParentThreadId } from "../src/scan-sessions.js"; import type { ScanProgress } from "../src/worker-progress.js"; @@ -2264,6 +2269,229 @@ describe("live scan cost tracking", () => { }); describe("recorded Deep worker homes", () => { + test("only enforces a priced subtotal from a valid attributed usage partition", () => { + const known = { + model: "gpt-5.6-sol", + input_tokens: 1_000, + output_tokens: 0, + }; + const unknown = { model: null, input_tokens: 100, output_tokens: 0 }; + const usage = { + input_tokens: 1_100, + output_tokens: 0, + modelUsage: [known, unknown], + }; + expect(estimateScanCostLowerBound("gpt-5.6-sol", usage)?.estimatedUsd).toBe( + 0.004, + ); + expect(estimateScanCost("gpt-5.6-sol", usage)).toBeNull(); + for (const invalid of [ + { ...usage, input_tokens: 999 }, + { ...usage, modelUsage: [known, known, unknown] }, + { ...usage, modelUsage: [known, { ...unknown, input_tokens: -1 }] }, + { ...usage, modelUsage: [{ ...known, model: null }, unknown] }, + ]) + expect(estimateScanCostLowerBound("gpt-5.6-sol", invalid)).toBeNull(); + }); + + test.each([null, "synthetic-unpriced-model"])( + "reports an internal priced lower bound with model %p without inventing a total", + async (unknownModel) => { + const home = await codexHome(); + const at = "2026-09-01T00:00:02Z"; + const known = await writeSession(home, "owner", {}); + await appendFile( + known, + JSON.stringify({ + type: "token_usage_record", + timestamp: at, + payload: { + thread_id: "owner", + turn_id: "turn", + response_id: "known-response", + model: "gpt-5.6-sol", + usage: { input_tokens: 1_000, output_tokens: 0 }, + }, + }) + "\n", + ); + const unknown = await writeSession(home, "worker", {}); + await appendFile( + unknown, + JSON.stringify({ + type: "turn_context", + timestamp: at, + payload: { + turn_id: "worker-turn", + ...(unknownModel === null ? {} : { model: unknownModel }), + }, + }) + + "\n" + + JSON.stringify({ + type: "event_msg", + timestamp: at, + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 100, output_tokens: 0 }, + }, + }, + }) + + "\n", + ); + const lowerBounds: Readonly[] = []; + const publicCosts: Readonly[] = []; + const options = { + codexHome: home, + model: "gpt-5.6-sol", + maxCostUsd: 0.003, + onCost: (cost: Readonly) => publicCosts.push(cost), + onCostLowerBound: (cost: Readonly) => lowerBounds.push(cost), + }; + const tracker = new ScanCostTracker(options); + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: ["worker"], + owner: { threadId: "owner", turnId: "turn", startedAt: at }, + startedAt: at, + completedAt: null, + })); + tracker.start("owner"); + try { + const snapshot = await tracker.refresh(); + expect(tokenUsage(snapshot.usage)?.input_tokens).toBe(1_100); + expect(snapshot.cost).toBeNull(); + expect(publicCosts).toEqual([]); + expect(lowerBounds).toHaveLength(1); + expect(lowerBounds[0]).toMatchObject({ + inputTokens: 1_000, + estimatedUsd: 0.004, + coverage: "partial", + }); + expect(lowerBounds[0]!.estimatedUsd).toBeGreaterThan( + options.maxCostUsd, + ); + await tracker.refresh(); + expect(lowerBounds).toHaveLength(1); + } finally { + await tracker.stop(); + } + }, + ); + + test.each(["identical", "prefix-first", "prefix-last"] as const)( + "forwards each event occurrence once from copied logs: %s", + async (copy) => { + const home = await codexHome(); + const recordedHome = await codexHome(); + const scanDirectory = join(home, "scan"); + const settings = join(scanDirectory, "artifacts", "deep_discovery"); + await mkdir(settings, { recursive: true }); + await writeFile( + join(settings, "execution-settings.json"), + JSON.stringify({ + version: 1, + settings: { codexHome: recordedHome }, + }), + ); + await mkdir(join(home, "sessions")); + await mkdir(join(recordedHome, "sessions")); + const first = join(home, "sessions", "worker.jsonl"); + const second = join(recordedHome, "sessions", "worker-copy.jsonl"); + const repeated = { + timestamp: "2026-09-01T00:00:02Z", + type: "event_msg", + payload: { type: "agent_message", message: "Reviewing source." }, + }; + const expected = [ + { + timestamp: "2026-09-01T00:00:00Z", + type: "session_meta", + payload: { id: "worker", model: "gpt-5.6-sol" }, + }, + repeated, + repeated, + { + timestamp: "2026-09-01T00:00:03Z", + type: "token_usage_record", + payload: { + thread_id: "worker", + turn_id: "turn", + response_id: "response", + model: "gpt-5.6-sol", + usage: { input_tokens: 100, output_tokens: 0 }, + }, + }, + ]; + const contents = expected.map((event) => JSON.stringify(event) + "\n"); + await writeFile( + first, + contents.slice(0, copy === "prefix-first" ? 2 : 4).join(""), + ); + await writeFile( + second, + contents.slice(0, copy === "prefix-last" ? 2 : 4).join(""), + ); + const events: ScanSessionEvent[] = []; + const options = { + codexHome: home, + scanDirectory, + model: "gpt-5.6-sol", + onSessionEvent: (event: ScanSessionEvent) => events.push(event), + }; + const tracker = new ScanCostTracker(options); + tracker.start("worker"); + try { + expect((await tracker.refresh()).cost?.inputTokens).toBe(100); + expect(events.map((event) => event.event)).toEqual(expected); + await tracker.refresh(); + expect(events).toHaveLength(expected.length); + // Both logs catch up, then a genuine repeated occurrence is copied later. + await writeFile(first, contents.join("")); + await writeFile(second, contents.join("")); + await appendFile(first, JSON.stringify(repeated) + "\n"); + await tracker.refresh(); + expect(events.map((event) => event.event)).toEqual([ + ...expected, + repeated, + ]); + await appendFile(second, JSON.stringify(repeated) + "\n"); + await tracker.stop(); + expect(events.map((event) => event.event)).toEqual([ + ...expected, + repeated, + ]); + // Re-reading after the owner interval becomes available filters early + // events, but must not renumber the surviving source occurrences. + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: ["worker"], + owner: { + threadId: "worker", + turnId: "turn", + startedAt: "2026-09-01T00:00:03Z", + }, + startedAt: "2026-09-01T00:00:03Z", + completedAt: "2026-09-01T00:00:04Z", + })); + await tracker.refresh(); + expect(events.map((event) => event.event)).toEqual([ + ...expected, + repeated, + ]); + events.length = 0; + const reconstructed = new ScanCostTracker(options); + reconstructed.start("worker"); + await reconstructed.stop(); + expect(events.map((event) => event.event)).toEqual([ + ...expected, + repeated, + ]); + } finally { + await tracker.stop(); + } + }, + ); + test("keeps resumed worker usage and current parent usage isolated per scan", async () => { const currentHome = await codexHome(); const firstHome = await codexHome(); From 3f1560d787298439a4d69e8268b5b959e7680967 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 15:00:16 +0000 Subject: [PATCH 126/251] Enforce known cost bounds without publishing unknown totals --- .../codex-security/scripts/workbench_db.py | 8 +- .../scripts/workbench_validation.py | 7 +- .../test_budget_selection_publication.py | 52 ++++- sdk/typescript/src/api.ts | 52 ++--- sdk/typescript/tests-ts/api-policy.test.ts | 87 +++++++++ sdk/typescript/tests-ts/api.test.ts | 177 ++++++++++++++++++ .../tests-ts/deep-finalization.test.ts | 60 +++++- 7 files changed, 412 insertions(+), 31 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index f5aab75afc..ca49138c8d 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1153,7 +1153,7 @@ def complete_budget_exhausted_scan( connection: sqlite3.Connection, args: argparse.Namespace ) -> dict[str, Any]: scan_id = require_uuid(args.scan_id, "scan-id") - cost_json = parse_scan_cost(args.cost_json) + cost_json = parse_scan_cost(args.cost_json, allow_lower_bound=True) if cost_json is None: raise SystemExit("Budget-exhausted scan completion requires the measured scan cost.") with scan_completion_lock(scan_id): @@ -1164,7 +1164,8 @@ def complete_budget_exhausted_scan( if not isinstance(recipe, dict) or recipe.get("mode") != "deep": raise SystemExit("Budget-exhausted scan completion requires a Deep Scan launch recipe.") cost = json.loads(cost_json) - measured = cost.get("cost", cost) + lower_bound = set(cost) == {"lowerBound"} + measured = cost["lowerBound"] if lower_bound else cost.get("cost", cost) limit = recipe.get("maxCostUsd") if ( not isinstance(limit, (int, float)) @@ -1240,7 +1241,8 @@ def complete_budget_exhausted_scan( (json.dumps([*warnings, warning]), scan_id), ) connection.commit() - return complete_scan_locked(connection, scan_id, None, cost_json) + # A priced subtotal proves the stop but is not the saved total estimate. + return complete_scan_locked(connection, scan_id, None, None if lower_bound else cost_json) def budget_exhausted_candidates(scan: sqlite3.Row, scan_dir: Path) -> list[dict[str, Any]]: diff --git a/plugins/codex-security/scripts/workbench_validation.py b/plugins/codex-security/scripts/workbench_validation.py index f4f68f5f42..a511fcc517 100644 --- a/plugins/codex-security/scripts/workbench_validation.py +++ b/plugins/codex-security/scripts/workbench_validation.py @@ -168,7 +168,7 @@ def _valid_measured_scan_usage(usage: object) -> bool: return True -def parse_scan_cost(value: str | None) -> str | None: +def parse_scan_cost(value: str | None, *, allow_lower_bound: bool = False) -> str | None: if value is None: return None if len(value.encode("utf-8")) > 8192: @@ -177,7 +177,10 @@ def parse_scan_cost(value: str | None) -> str | None: cost = json.loads(value, parse_constant=reject_nonstandard_json_number) except (TypeError, UnicodeError, ValueError) as exc: raise SystemExit("Scan cost must be a valid JSON object.") from exc - if isinstance(cost, dict) and "usage" in cost: + if allow_lower_bound and isinstance(cost, dict) and set(cost) == {"lowerBound"}: + if not _valid_legacy_scan_cost(cost["lowerBound"]): + raise SystemExit("Scan cost lower bound must be a valid measured cost.") + elif isinstance(cost, dict) and "usage" in cost: if ( not set(cost).issubset({"usage", "cost"}) or not _valid_measured_scan_usage(cost["usage"]) diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index 7137059ad7..39da80b941 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -94,8 +94,9 @@ def test_cost_before_selection_does_not_replace_invalid_accepted_evidence( @pytest.mark.parametrize("accepted_kind", ["none", "unmerged", "reducer"]) @pytest.mark.parametrize("cancel_first", [False, True]) +@pytest.mark.parametrize("lower_bound", [False, True]) def test_cost_before_selection_retains_only_merged_findings( - workbench_api, workbench_db, publication_scan, accepted_kind, cancel_first + workbench_api, workbench_db, publication_scan, accepted_kind, cancel_first, lower_bound ): scan = publication_scan() accepted = None @@ -133,7 +134,7 @@ def test_cost_before_selection_retains_only_merged_findings( ) args = Namespace( scan_id=scan.scan_id, - cost_json=json.dumps(BUDGET_COST), + cost_json=json.dumps({"lowerBound": BUDGET_COST} if lower_bound else BUDGET_COST), message="Scan reached its original cost limit.", ) if cancel_first: @@ -147,6 +148,12 @@ def test_cost_before_selection_retains_only_merged_findings( row = workbench_db.execute("SELECT * FROM scans").fetchone() run = workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone() assert row["status"] == "complete" + stored = json.loads(row["cost_json"]) + if lower_bound: + assert "cost" not in stored and "estimatedUsd" not in stored + assert stored["usage"]["coverage"] == "unavailable" + else: + assert stored == BUDGET_COST assert run["terminal_reason"] == "capped" assert run["error_message"] == args.message assert args.message in json.loads(row["completion_warnings_json"]) @@ -186,6 +193,47 @@ def test_cost_before_selection_retains_only_merged_findings( assert accepted.read_bytes() == original +@pytest.mark.parametrize( + "bound", [None, {**BUDGET_COST, "estimatedUsd": -1}, {**BUDGET_COST, "estimatedUsd": 0.005}] +) +def test_lower_bound_budget_rejects_invalid_or_unexceeded_cost( + workbench_api, workbench_db, publication_scan, bound +): + scan = publication_scan() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + before = published_bytes(scan) + with pytest.raises(SystemExit): + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, cost_json=json.dumps({"lowerBound": bound}), message=None + ), + ) + assert published_bytes(scan) == before + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + + +def test_lower_bound_is_not_an_ordinary_completion_cost( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + before = published_bytes(scan) + with pytest.raises(SystemExit): + workbench_api["complete_scan"]( + workbench_db, + Namespace( + scan_id=scan.scan_id, + claim_token=None, + cost_json=json.dumps({"lowerBound": BUDGET_COST}), + ), + ) + assert published_bytes(scan) == before + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + + @pytest.mark.parametrize("reason", ["saturated", "capped"]) def test_budget_keeps_selection_committed_before_transaction_and_unmerged_obligations( workbench_api, workbench_db, publication_scan, reason diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index ed0777bf6c..e838a8980a 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1005,15 +1005,7 @@ export class CodexSecurity { `permissions.${POLICY_PERMISSION_PROFILE}.filesystem=${inlineToml(policyFilesystemPermissions(inputs.gitMetadataPaths))}`, ], ); - const reportCost = (current: Readonly): void => { - const total = addScanCosts(accumulatedCost, current); - if (completeCost) - notifyObserver( - "onCost", - options.onCost, - options.onObserverError, - total, - ); + const enforceCostLimit = (total: Readonly): void => { if ( options.maxCostUsd !== undefined && total.estimatedUsd > options.maxCostUsd @@ -1025,6 +1017,17 @@ export class CodexSecurity { ); } }; + const reportCost = (current: Readonly): void => { + const total = addScanCosts(accumulatedCost, current); + if (completeCost) + notifyObserver( + "onCost", + options.onCost, + options.onObserverError, + total, + ); + enforceCostLimit(total); + }; const outputSchema = securityPolicyStageOutputSchema(); const run = async ( stage: SecurityPolicyStage, @@ -1048,6 +1051,10 @@ export class CodexSecurity { options.onCost === undefined && options.maxCostUsd === undefined ? undefined : reportCost, + onCostLowerBound: + options.maxCostUsd === undefined + ? undefined + : (cost) => enforceCostLimit(addScanCosts(accumulatedCost, cost)), onError: (error) => { if (options.maxCostUsd !== undefined) budgetController.abort(error); else @@ -1484,6 +1491,14 @@ export class CodexSecurity { `Could not track scan activity: ${errorMessage(error)}`, ); }; + const enforceCostLimit = (cost: Readonly): boolean => { + if (maxCostUsd === undefined || cost.estimatedUsd <= maxCostUsd) + return false; + costAbortController.abort( + new ScanCostLimitExceededError(maxCostUsd, cost, scanDir), + ); + return true; + }; const tracker = new ScanCostTracker({ codexHome: runtime.codexHome, model, @@ -1524,15 +1539,7 @@ export class CodexSecurity { cost, maxCostUsd, ); - if ( - maxCostUsd !== undefined && - cost.estimatedUsd > maxCostUsd - ) { - costAbortController.abort( - new ScanCostLimitExceededError(maxCostUsd, cost, scanDir), - ); - return; - } + if (enforceCostLimit(cost)) return; const request = options.onBudgetApproaching; if ( request === undefined || @@ -1597,6 +1604,8 @@ export class CodexSecurity { } }); }, + onCostLowerBound: + options.maxCostUsd === undefined ? undefined : enforceCostLimit, onError: reportTrackingError, }); costTracker = tracker; @@ -2395,7 +2404,7 @@ export class CodexSecurity { ) { try { const budgetScanId = activeScan.id; - const budgetCost = snapshot?.cost ?? failure.cost; + const budgetCost = snapshot?.cost ?? { lowerBound: failure.cost }; const completionSignal = AbortSignal.any([ this.#abortController.signal, ...(options.signal === undefined ? [] : [options.signal]), @@ -2441,8 +2450,9 @@ export class CodexSecurity { "complete-scan", "--scan-id", budgetScanId, - "--cost-json", - JSON.stringify(budgetCost), + ...(snapshot?.cost + ? ["--cost-json", JSON.stringify(snapshot.cost)] + : []), ]), ); activeScan = null; diff --git a/sdk/typescript/tests-ts/api-policy.test.ts b/sdk/typescript/tests-ts/api-policy.test.ts index 08d057f01f..899ffc59e0 100644 --- a/sdk/typescript/tests-ts/api-policy.test.ts +++ b/sdk/typescript/tests-ts/api-policy.test.ts @@ -1532,6 +1532,93 @@ describe("CodexSecurity policy API", () => { await f.security.close(); }); + test.each(["architecture", "threat_model"] as const)( + "enforces priced policy usage despite an unpriced remainder in %s", + async (crossingStage) => { + const costs: number[] = []; + const f = await setup({ + config: { codexOverrides: { model: "gpt-5.6-sol" } }, + stream: async function* (stage, signal) { + if (stage !== crossingStage) { + yield* events(stage); + return; + } + const directory = join(f.root, "codex-home", "sessions"); + await mkdir(directory, { recursive: true }); + const thread = `policy-${stage}`; + const input = stage === "architecture" ? 1_200 : 1_000; + for (const [id, model, parent, inputTokens] of [ + [thread, "gpt-5.6-sol", undefined, input], + ["unpriced-policy-worker", "synthetic-unpriced-model", thread, 100], + ] as const) { + await writeFile( + join(directory, `${id}.jsonl`), + [ + JSON.stringify({ + type: "session_meta", + payload: { + id, + ...(parent === undefined + ? {} + : { parent_thread_id: parent }), + }, + }), + JSON.stringify({ type: "turn_context", payload: { model } }), + JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: inputTokens, + output_tokens: 0, + }, + }, + }, + }), + "", + ].join("\n"), + ); + } + yield { type: "thread.started", thread_id: thread }; + await new Promise((resolve) => { + if (signal.aborted) resolve(); + else + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + throw signal.reason; + }, + }); + const keepAlive = setTimeout(() => {}, 10_000); + try { + await expect( + f.security.generatePolicy(f.repository, { + outputDir: f.outputDir, + maxCostUsd: 0.0045, + signal: AbortSignal.timeout(5_000), + onCost: (cost) => costs.push(cost.estimatedUsd), + }), + ).rejects.toThrow("exceeded its $0.0045 cost limit"); + expect(f.threads).toHaveLength( + crossingStage === "architecture" ? 1 : 2, + ); + if (crossingStage === "architecture") expect(costs).toEqual([]); + else { + expect(costs.length).toBeGreaterThan(0); + for (const cost of costs) expect(cost).toBeCloseTo(0.0006, 12); + } + if (crossingStage === "threat_model") { + expect( + await readFile(join(f.outputDir, "project-spec.md"), "utf8"), + ).toContain("src/service.ts:1"); + } + } finally { + clearTimeout(keepAlive); + await f.security.close(); + } + }, + ); + test("enforces one cost budget across stages and preserves completed evidence", async () => { const f = await setup(); await expect( diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index e3356ec4bb..1e7d35f56a 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -4767,6 +4767,183 @@ describe("CodexSecurity orchestration", () => { }, ); + test.each([ + ["standard", false], + ["deep", false], + ["standard", true], + ["deep", true], + ] as const)( + "enforces priced usage with an unpriced remainder (%s, raised limit: %s)", + async (mode, raised) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await Promise.all([ + mkdir(repository), + mkdir(codexHome), + mkdir(scanDir, { mode: 0o700 }), + ]); + const commands: Array = []; + const costs: number[] = []; + let turns = 0; + let releaseIncrease!: () => void; + const increased = new Promise((resolve) => { + releaseIncrease = resolve; + }); + const knownUsage = { + input_tokens: raised ? 2_500 : 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }; + const expectedCost = estimateScanCost("gpt-5.6-sol", knownUsage)!; + const writePricedUsage = async (usage: Record) => { + const path = await writeUsageSession(codexHome, "scan-thread", usage); + const lines = (await readFile(path, "utf8")).split("\n"); + lines.splice( + 1, + 0, + JSON.stringify({ + type: "turn_context", + payload: { model: "gpt-5.6-sol" }, + }), + ); + await writeFile(path, lines.join("\n")); + }; + const writeUnpricedUsage = async () => { + const path = await writeUsageSession( + codexHome, + "unpriced-worker", + { input_tokens: 100, output_tokens: 10 }, + "scan-thread", + ); + const lines = (await readFile(path, "utf8")).split("\n"); + lines.splice( + 1, + 0, + JSON.stringify({ + type: "turn_context", + payload: { model: "synthetic-unpriced-model" }, + }), + ); + await writeFile(path, lines.join("\n")); + }; + const client = new TestClient( + {}, + { + environment: {}, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async (_options, args, input) => { + commands.push(args); + if (args[0] === "get-scan") + return { scan: { id: "scan_example_001" } }; + if (args[0] === "complete-budget-exhausted-scan") + throw new Error("Synthetic canonical output is not ready"); + return mockWorkbench(args, input); + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed( + _input: string, + options: { signal: AbortSignal }, + ) { + turns++; + async function* events(): AsyncGenerator { + if (raised) { + await writePricedUsage({ + input_tokens: 800, + output_tokens: 0, + }); + } else { + await writePricedUsage(knownUsage); + await writeUnpricedUsage(); + } + yield { type: "thread.started", thread_id: "scan-thread" }; + if (raised) { + await increased; + await writeUnpricedUsage(); + const path = join( + codexHome, + "sessions", + "2026", + "07", + "26", + "rollout-scan-thread.jsonl", + ); + await appendFile( + path, + JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: knownUsage }, + }, + }) + "\n", + ); + } + await new Promise((resolve) => { + if (options.signal.aborted) resolve(); + else + options.signal.addEventListener( + "abort", + () => resolve(), + { once: true }, + ); + }); + throw new DOMException("aborted", "AbortError"); + } + return { events: events() }; + }, + }), + }), + }, + ); + const keepAlive = setTimeout(() => {}, 10_000); + try { + const failure = await client + .run(repository, { + mode, + maxCostUsd: 0.004, + signal: AbortSignal.timeout(5_000), + postScanPrompt: "No model work after the budget stop.", + ...(raised ? { onBudgetApproaching: () => 0.008 } : {}), + onCost: (cost, limit) => { + costs.push(cost.estimatedUsd); + if (limit === 0.008) releaseIncrease(); + }, + }) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ScanCostLimitExceededError); + expect(failure).toMatchObject({ + maxCostUsd: raised ? 0.008 : 0.004, + cost: { + estimatedUsd: expectedCost.estimatedUsd, + inputTokens: knownUsage.input_tokens, + coverage: "partial", + }, + }); + expect(costs.every((cost) => raised && cost === 0.0032)).toBe(true); + expect(turns).toBe(1); + expect( + commands.some((args) => args[0] === "complete-budget-exhausted-scan"), + ).toBe(mode === "deep"); + if (raised) + expect( + commands + .filter((args) => args[0] === "set-scan-cost-limit") + .map((args) => args.at(-1)), + ).toEqual(["0.008"]); + } finally { + clearTimeout(keepAlive); + 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"); diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index a4b6987429..dcc0c75882 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -137,9 +137,20 @@ const cases: { outcome: (typeof outcomes)[number]; budgetCompletionFault?: BudgetCompletionFault; initialResumeUsage?: boolean; + unpricedUsage?: boolean; cancellationFault?: "status-read" | "deep-state-read" | "cancel-response"; }[] = [ ...outcomes.map((outcome) => ({ outcome })), + ...( + [ + "budget-during-publication", + "budget-after-deep-finish", + "budget-during-resumed-publication", + ] as const + ).flatMap((outcome) => [ + { outcome, unpricedUsage: true }, + { outcome, unpricedUsage: true, budgetCompletionFault: "lost" as const }, + ]), ...( [ "budget-during-publication", @@ -185,6 +196,7 @@ for (const { budgetCompletionFault, cancellationFault, initialResumeUsage, + unpricedUsage, } of cases) { const resumedStop = outcome.includes("-resumed-"); const restart = outcome === "restart" || resumedStop; @@ -195,7 +207,7 @@ for (const { const name = outcome === "followup-canceled" ? "SDK preserves a selected aggregate when its follow-up is canceled" - : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${initialResumeUsage ? " (initial resume cost)" : ""}`; + : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${initialResumeUsage ? " (initial resume cost)" : ""}${unpricedUsage ? " (unpriced remainder)" : ""}`; const runCase = async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -235,6 +247,7 @@ for (const { let completedArtifacts: Buffer[] = []; const modelInputs: string[] = []; const commands: string[] = []; + const reportedCosts: number[] = []; const usagePath = join( codexHome, "sessions", @@ -269,7 +282,34 @@ for (const { }, }, }) + - "\n", + "\n" + + (unpricedUsage + ? [ + JSON.stringify({ + timestamp: new Date().toISOString(), + type: "turn_context", + payload: { + turn_id: "synthetic-scan-turn", + model: "synthetic-unpriced-model", + }, + }), + JSON.stringify({ + timestamp: new Date().toISOString(), + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 1_350, + cached_input_tokens: 200, + output_tokens: 30, + }, + }, + }, + }), + "", + ].join("\n") + : ""), ); let closePromise: Promise | undefined; const makeClient = () => @@ -624,6 +664,9 @@ for (const { signal: cancellation.signal, ...(budgeted ? { maxCostUsd: 0.004 } : {}), ...(resumedStop ? { resumeScanId: scanId, outputDir: scanDir } : {}), + ...(unpricedUsage + ? { onCost: (cost) => reportedCosts.push(cost.estimatedUsd) } + : {}), postScanPrompt: followUp, }); if (budgeted) { @@ -669,7 +712,18 @@ for (const { const result = await running; expect(result.coverage.completeness).toBe("partial"); expect(JSON.stringify(result.coverage)).toContain("cost limit"); - expect(result.cost?.estimatedUsd).toBeGreaterThan(0.004); + if (unpricedUsage) { + expect(result.cost).toBeNull(); + expect(reportedCosts).toEqual([]); + const saved = await runWorkbench(workbenchOptions!, [ + "get-scan", + "--scan-id", + scanId, + ]); + expect( + (saved["scan"] as { cost?: unknown }).cost ?? null, + ).toBeNull(); + } else expect(result.cost?.estimatedUsd).toBeGreaterThan(0.004); expect(result.threadId).toBe(threadId); expect(modelInputs).toHaveLength(1); expect(commands).toContain("complete-budget-exhausted-scan"); From 63a8c166cf787f95d47f309ba25458759c43a9e7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 14:22:33 +0000 Subject: [PATCH 127/251] Recover worker usage from recorded session files --- .../scripts/workbench_scan_usage.py | 57 ++++++++++++++++++- .../tests/test_workbench_scan_usage.py | 18 ++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 5a4da11c78..766fd1b959 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -9,6 +9,7 @@ import sqlite3 import sys import uuid +from collections import deque from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -127,6 +128,7 @@ def collect_scan_usage( # Legacy scans may have no recorded home. Keep usage best effort. pass groups = [(current_database, roots)] + worker_roots: set[str] = set() if worker_codex_home is not None: worker_roots = set( _scan_root_thread_ids(connection, scan, None, include_owner_threads=False) @@ -136,7 +138,7 @@ def collect_scan_usage( worker_database = _codex_state_database(worker_codex_home) if worker_database != current_database: groups.append((worker_database, [root for root in roots if root in worker_roots])) - if not any(database is not None for database, _ in groups): + if not any(database is not None for database, _ in groups) and not worker_roots: return _unavailable_usage("codex_state_unavailable") started_at = _timestamp(scan["started_at"]) @@ -170,6 +172,15 @@ def collect_scan_usage( copies.append(session) seen_thread_ids.add(session.thread_id) + if worker_codex_home is not None and worker_roots: + # An external SQLite location can change on recovery. Native rollouts + # still live in the recorded worker home; they retain their lineage. + for session in _discover_recorded_worker_sessions(worker_codex_home, worker_roots): + copies = sessions.setdefault(session.thread_id, []) + if session not in copies: + copies.append(session) + seen_thread_ids.add(session.thread_id) + # Absence from one known index is not missing usage when another has it. missing_thread_ids.difference_update(seen_thread_ids) if not missing_thread_ids: @@ -586,6 +597,50 @@ def _discover_rollout_sessions( database.close() +def _discover_recorded_worker_sessions(codex_home: Path, roots: set[str]) -> list[RolloutSession]: + recorded: dict[str, list[RolloutSession]] = {} + children: dict[str, set[str]] = {} + for candidate in sorted((codex_home / "sessions").rglob("*.jsonl")): + path = _rollout_path(str(candidate)) + if path is None: + continue + try: + with path.open("rb") as stream: + metadata = json.loads(stream.readline()) + except (OSError, UnicodeError, ValueError): + continue + if not isinstance(metadata, dict) or metadata.get("type") != "session_meta": + continue + payload = metadata.get("payload") + if not isinstance(payload, dict): + continue + thread_id = payload.get("id") or payload.get("session_id") + if not isinstance(thread_id, str): + continue + parent_id = _session_parent_thread_id(payload) + recorded.setdefault(thread_id, []).append(RolloutSession(thread_id, parent_id, path)) + if parent_id is not None: + children.setdefault(parent_id, set()).add(thread_id) + + sessions: list[RolloutSession] = [] + included = set(roots) + pending = deque(sorted(roots)) + while pending: + thread_id = pending.popleft() + for session in recorded.get(thread_id, []): + sessions.append( + RolloutSession( + thread_id, + None if thread_id in roots else session.parent_thread_id, + session.path, + ) + ) + for child_id in sorted(children.get(thread_id, set()) - included): + included.add(child_id) + pending.append(child_id) + return sessions + + def _require_state_columns( connection: sqlite3.Connection, table: str, diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 60fc4805e1..dbf34128d9 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -655,6 +655,8 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N "recorded-prefix", "current-unreadable", "current-mismatched", + "external-sqlite", + "external-shared-home", "unavailable", ], ) @@ -688,6 +690,8 @@ def complete(index: int) -> dict[str, Any]: target = root / "target" target.mkdir(parents=True) selected_home = current_home if worker_home == "current" else root / "original-home" + if worker_home == "external-shared-home": + selected_home = tmp_path / "shared-original-home" process = subprocess.run( [ sys.executable, @@ -842,6 +846,20 @@ def complete(index: int) -> dict[str, Any]: recorded_threads, [(f"discovery-{index}", child_id)], ) + elif worker_home in {"external-sqlite", "external-shared-home"}: + # Native keeps rollouts in its Codex home even when its SQLite + # index lives elsewhere and recovery chooses a different index. + sessions = selected_home / "sessions" / "2026" / "01" / "01" + sessions.mkdir(parents=True, exist_ok=True) + for thread_id, path in worker_threads.items(): + recorded = sessions / f"rollout-{thread_id}.jsonl" + path.rename(recorded) + worker_threads[thread_id] = recorded + _state_graph( + {"CODEX_SQLITE_HOME": str(root / "original-external-sqlite")}, + worker_threads, + [(f"discovery-{index}", child_id)], + ) result = _complete_scan(fixture)["scan"]["usage"] assert snapshot.read_bytes() == original_bytes return result From ba996ac64ad7e95a65152336129273264a20a7bf Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 15:21:06 +0000 Subject: [PATCH 128/251] Clear resolved rollout lookup warnings --- .../scripts/workbench_scan_usage.py | 4 +- .../tests/test_workbench_scan_usage.py | 40 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 766fd1b959..b007871178 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -184,7 +184,9 @@ def collect_scan_usage( # Absence from one known index is not missing usage when another has it. missing_thread_ids.difference_update(seen_thread_ids) if not missing_thread_ids: - warnings.difference_update({"scan_root_unavailable", "codex_state_unavailable"}) + warnings.difference_update( + {"scan_root_unavailable", "codex_state_unavailable", "rollout_unavailable"} + ) if not sessions: return _unavailable_usage( diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index dbf34128d9..7711193e60 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -657,6 +657,8 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N "current-mismatched", "external-sqlite", "external-shared-home", + "external-missing-copy", + "external-missing-child", "unavailable", ], ) @@ -846,7 +848,12 @@ def complete(index: int) -> dict[str, Any]: recorded_threads, [(f"discovery-{index}", child_id)], ) - elif worker_home in {"external-sqlite", "external-shared-home"}: + elif worker_home in { + "external-sqlite", + "external-shared-home", + "external-missing-copy", + "external-missing-child", + }: # Native keeps rollouts in its Codex home even when its SQLite # index lives elsewhere and recovery chooses a different index. sessions = selected_home / "sessions" / "2026" / "01" / "01" @@ -860,6 +867,23 @@ def complete(index: int) -> dict[str, Any]: worker_threads, [(f"discovery-{index}", child_id)], ) + if worker_home in {"external-missing-copy", "external-missing-child"}: + first_id = f"discovery-{index}" + with sqlite3.connect(environment["CODEX_STATE_DB"]) as connection: + connection.execute( + "INSERT INTO threads VALUES (?, ?)", + ( + first_id, + str(root / "missing-copy.jsonl") + if worker_home == "external-missing-copy" + else str(worker_threads[first_id]), + ), + ) + if worker_home == "external-missing-child": + connection.execute( + "INSERT INTO thread_spawn_edges VALUES (?, ?)", (first_id, child_id) + ) + worker_threads[child_id].unlink() result = _complete_scan(fixture)["scan"]["usage"] assert snapshot.read_bytes() == original_bytes return result @@ -874,6 +898,20 @@ def complete(index: int) -> dict[str, Any]: assert usage["outputTokens"] == 2 assert usage["threadCount"] == 1 assert usage["missingThreadCount"] == 2 + elif worker_home == "external-missing-child": + assert usage == { + "coverage": "partial", + "source": "codex_rollout", + **_counts(index * 70, 0, 12), + "threadCount": 3, + "missingThreadCount": 1, + "warnings": [ + "codex_state_unavailable", + "rollout_unavailable", + "scan_root_unavailable", + ], + "modelUsage": [{"model": None, **_counts(index * 70, 0, 12)}], + } else: assert usage == { "coverage": "complete", From 8fd0c1d50fc6b1db82c5b7390946940e2cef0e92 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 16:36:37 +0000 Subject: [PATCH 129/251] Reject unsupported scan protocols before completion writes --- .../scripts/deep_scan_workbench.py | 4 +- .../codex-security/scripts/workbench_db.py | 2 + .../codex-security/tests/test_workbench_db.py | 93 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index a1cb47617c..964eedcfaa 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -317,9 +317,11 @@ def require_deep_scan_ready_for_parent_completion( if scan["mode"] != "deep": return run = connection.execute( - "SELECT status, manifest_path FROM deep_scan_runs WHERE scan_id = ?", + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan["id"],), ).fetchone() + if run is not None: + require_supported_deep_scan(run) if run is None or run["status"] != "succeeded" or run["manifest_path"] is None: raise SystemExit( "Deep Scan discovery orchestration must finish and persist its manifest before " diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index ca49138c8d..b42841375f 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1178,6 +1178,8 @@ def complete_budget_exhausted_scan( "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,), ).fetchone() + if run is not None: + deep_scan.require_supported_deep_scan(run) before_selection = ( run is not None and run["status"] == "running" diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index fd46005217..1c340b27eb 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -6,6 +6,7 @@ import runpy import sqlite3 import subprocess +import sys import time import uuid from concurrent.futures import ThreadPoolExecutor @@ -13,6 +14,7 @@ from typing import Any import pytest +import workbench_test_support from workbench_test_support import ( SCRIPT, create_saved_git_workspace, @@ -172,6 +174,97 @@ def budget_scan_fixture( return state_dir, target, scan_dir, scan_id, ledger +@pytest.mark.parametrize("operation", ["complete-scan", "complete-budget-exhausted-scan"]) +@pytest.mark.parametrize("protocol", ["supported", "future-workflow", "future-selection"]) +def test_completion_rejects_unknown_protocol_before_mutation( + workbench_api, monkeypatch, tmp_path, operation, protocol +): + script = str(workbench_api["__file__"]) + monkeypatch.setattr(workbench_test_support, "SCRIPT", script) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "home")) + state_dir, _, scan_dir, scan_id, _ = budget_scan_fixture(tmp_path) + environment = {**os.environ, "CODEX_SECURITY_STATE_DIR": str(state_dir)} + cost_args = ["--scan-id", scan_id, "--cost-json", json.dumps(BUDGET_COST)] + if operation == "complete-scan": + cut_program = """ +import os, runpy, sys +script, *args = sys.argv[1:] +api = runpy.run_path(script, run_name="completion_version_test") +namespace = api["main"].__globals__ +original = namespace["_write_prepared_scan_finalization"] +def after_seal(*args, **kwargs): + original(*args, **kwargs) + os._exit(86) +namespace["_write_prepared_scan_finalization"] = after_seal +sys.argv = [script, *args] +api["main"]() +""" + cut = subprocess.run( + [ + sys.executable, + "-I", + "-B", + "-c", + cut_program, + script, + "complete-budget-exhausted-scan", + *cost_args, + "--message", + BUDGET_WARNING, + ], + env=environment, + capture_output=True, + text=True, + ) + assert cut.returncode == 86, cut.stderr + assert json.loads((scan_dir / "scan-manifest.json").read_text())["scan"]["sealedAt"] + database = state_dir / "workbench.sqlite3" + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT status FROM scans").fetchone() == ("running",) + if protocol == "future-workflow": + connection.execute("UPDATE deep_scan_runs SET workflow_version = 'future/v99'") + elif protocol == "future-selection": + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "finalization_input_json = ?", + (json.dumps({"version": 99}),), + ) + + def snapshot(): + with sqlite3.connect(database) as connection: + return list(connection.iterdump()), { + str(path.relative_to(scan_dir)): path.read_bytes() + for path in scan_dir.rglob("*") + if path.is_file() + } + + before = snapshot() + result = subprocess.run( + [ + sys.executable, + "-I", + "-B", + script, + operation, + *cost_args, + *([] if operation == "complete-scan" else ["--message", BUDGET_WARNING]), + ], + env=environment, + capture_output=True, + text=True, + ) + after = snapshot() + if protocol == "supported": + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["scan"]["progress"]["status"] == "complete" + if operation == "complete-scan": + assert after[1] == before[1] + else: + assert result.returncode != 0 + assert "unsupported" in result.stderr.lower() + assert after == before + + def complete_budget_scan(state_dir: Path, scan_id: str, *, check: bool = True) -> dict[str, object]: return run_workbench( state_dir, From 1405f8300f9bf15c93c463970cd009eca590e54a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 16:58:45 +0000 Subject: [PATCH 130/251] Check completion replay and budget ownership before writes --- .../scripts/deep_scan_workbench.py | 18 ++-- .../codex-security/scripts/workbench_db.py | 11 +- .../codex-security/tests/test_workbench_db.py | 101 ++++++++++++++++++ 3 files changed, 118 insertions(+), 12 deletions(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 964eedcfaa..dcbd7a7b24 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -311,17 +311,23 @@ def deep_scan_deadline_reached(run: sqlite3.Row) -> bool: return elapsed.total_seconds() / 3600 >= run["max_time_hours"] +def find_supported_deep_scan_run( + connection: sqlite3.Connection, scan_id: str +) -> sqlite3.Row | None: + run = connection.execute( + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) + ).fetchone() + if run is not None: + require_supported_deep_scan(run) + return run + + def require_deep_scan_ready_for_parent_completion( connection: sqlite3.Connection, scan: sqlite3.Row ) -> None: if scan["mode"] != "deep": return - run = connection.execute( - "SELECT * FROM deep_scan_runs WHERE scan_id = ?", - (scan["id"],), - ).fetchone() - if run is not None: - require_supported_deep_scan(run) + run = find_supported_deep_scan_run(connection, scan["id"]) if run is None or run["status"] != "succeeded" or run["manifest_path"] is None: raise SystemExit( "Deep Scan discovery orchestration must finish and persist its manifest before " diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index b42841375f..61b18a9af4 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1160,6 +1160,9 @@ def complete_budget_exhausted_scan( scan = require_scan(connection, scan_id) if scan["status"] != "running" or scan["mode"] != "deep" or scan["recipe_json"] is None: raise SystemExit("Only a running CLI Deep Scan can complete after its cost limit.") + handoff.require_current_continuation( + scan, None, error_message="Scan completion is owned by another continuation." + ) recipe = json.loads(scan["recipe_json"], parse_constant=reject_non_finite_json) if not isinstance(recipe, dict) or recipe.get("mode") != "deep": raise SystemExit("Budget-exhausted scan completion requires a Deep Scan launch recipe.") @@ -1174,12 +1177,7 @@ def complete_budget_exhausted_scan( or measured.get("estimatedUsd", 0) <= limit ): raise SystemExit("Deep Scan has not exceeded its configured cost limit.") - run = connection.execute( - "SELECT * FROM deep_scan_runs WHERE scan_id = ?", - (scan_id,), - ).fetchone() - if run is not None: - deep_scan.require_supported_deep_scan(run) + run = deep_scan.find_supported_deep_scan_run(connection, scan_id) before_selection = ( run is not None and run["status"] == "running" @@ -1353,6 +1351,7 @@ def complete_scan_locked( ) -> dict[str, Any]: scan = require_scan(connection, scan_id) if scan["status"] == "complete": + deep_scan.find_supported_deep_scan_run(connection, scan_id) scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) require_recorded_manifest_digest(scan, scan_dir) verify_manifest_binding(scan, read_json_object(scan_dir / ARTIFACTS["manifest"])) diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 1c340b27eb..7a781e5c89 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -265,6 +265,107 @@ def snapshot(): assert after == before +@pytest.mark.parametrize("legacy_digest", [False, True]) +@pytest.mark.parametrize( + "protocol", ["supported", "legacy-no-run", "future-workflow", "future-selection"] +) +def test_completed_replay_checks_protocol_before_cost_or_digest_writes( + workbench_api, monkeypatch, tmp_path, protocol, legacy_digest +): + monkeypatch.setattr(workbench_test_support, "SCRIPT", workbench_api["__file__"]) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "home")) + state_dir, _, scan_dir, scan_id, _ = budget_scan_fixture(tmp_path) + complete_budget_scan(state_dir, scan_id) + database = state_dir / "workbench.sqlite3" + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT status FROM scans").fetchone() == ("complete",) + if legacy_digest: + connection.execute("UPDATE scans SET seal_manifest_digest = NULL") + if protocol == "future-workflow": + connection.execute("UPDATE deep_scan_runs SET workflow_version = 'future/v99'") + elif protocol == "future-selection": + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "finalization_input_json = ?", + (json.dumps({"version": 99}),), + ) + elif protocol == "legacy-no-run": + connection.execute("DELETE FROM deep_scan_runs") + + def snapshot(): + with sqlite3.connect(database) as connection: + return list(connection.iterdump()), { + str(path.relative_to(scan_dir)): path.read_bytes() + for path in scan_dir.rglob("*") + if path.is_file() + } + + before = snapshot() + cost = {**BUDGET_COST, "inputTokens": 1500, "estimatedUsd": 0.0075} + result = run_workbench( + state_dir, + "complete-scan", + "--scan-id", + scan_id, + "--cost-json", + json.dumps(cost), + check=not protocol.startswith("future-"), + ) + after = snapshot() + if protocol.startswith("future-"): + assert after == before + assert result["returncode"] != 0 + assert "unsupported" in result["stderr"].lower() + else: + assert result["scan"]["progress"]["status"] == "complete" + assert result["scan"]["cost"] == cost + assert after[1] == before[1] + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT seal_manifest_digest FROM scans").fetchone()[0] + + +@pytest.mark.parametrize("terminal", [False, True]) +@pytest.mark.parametrize("continuation", ["current", "pending", "claimed"]) +def test_budget_completion_checks_continuation_before_draft_writes( + workbench_api, monkeypatch, tmp_path, terminal, continuation +): + monkeypatch.setattr(workbench_test_support, "SCRIPT", workbench_api["__file__"]) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "home")) + state_dir, _, scan_dir, scan_id, _ = budget_scan_fixture(tmp_path, terminal=terminal) + database = state_dir / "workbench.sqlite3" + with sqlite3.connect(database) as connection: + connection.execute("UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2'") + if continuation != "current": + connection.execute("UPDATE scans SET handoff_status = 'pending'") + if continuation == "claimed": + run_workbench( + state_dir, + "claim-handoff-delivery", + "--scan-id", + scan_id, + "--claim-token", + str(uuid.uuid4()), + ) + + def snapshot(): + with sqlite3.connect(database) as connection: + return list(connection.iterdump()), { + str(path.relative_to(scan_dir)): path.read_bytes() + for path in scan_dir.rglob("*") + if path.is_file() + } + + before = snapshot() + result = complete_budget_scan(state_dir, scan_id, check=continuation == "current") + after = snapshot() + if continuation == "current": + assert result["scan"]["progress"]["status"] == "complete" + else: + assert after == before + assert result["returncode"] != 0 + assert "owned by another continuation" in result["stderr"] + + def complete_budget_scan(state_dir: Path, scan_id: str, *, check: bool = True) -> dict[str, object]: return run_workbench( state_dir, From f255364374b9363eca6a7b7022769dc8bd5c52fd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 19:02:31 +0000 Subject: [PATCH 131/251] Preserve helper CLI behavior through linked plugin paths --- .../codex-security/mcp-app/helpers-main.ts | 8 ++- sdk/typescript/tests-ts/build-plugin.test.ts | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/helpers-main.ts b/plugins/codex-security/mcp-app/helpers-main.ts index 9793f9d43d..fc542cdf61 100644 --- a/plugins/codex-security/mcp-app/helpers-main.ts +++ b/plugins/codex-security/mcp-app/helpers-main.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { existsSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; export { parseCanonicalScanDraft } from "./src/artifact-scan-draft.js"; export { resumeSelectedDeepScan } from "./src/deep-scan/finalization.js"; @@ -8,7 +8,11 @@ import { windowsBinding } from "./src/native"; // Importing the bundled helper from the SDK does not invoke its CLI adapter. const entryPath = import.meta.url.startsWith("file:") ? fileURLToPath(import.meta.url) : import.meta.url; -if (process.argv[1] && resolve(process.argv[1]) === entryPath) runHelper(); +const invokedPath = process.argv[1]; +if ( + invokedPath && existsSync(invokedPath) + && realpathSync(invokedPath) === realpathSync(entryPath) +) runHelper(); function runHelper(): void { let commandLine = process.argv.slice(2); diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 4fe7b964af..020a607882 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -284,6 +284,62 @@ describe("bundled plugin build", () => { ]); expect(helper.stdout).toBe("[]\n"); expect(helper.stderr).toBe(""); + + const repository = await temporaryDirectory(); + const policy = "Preserve this synthetic inherited security policy."; + await writeFixture( + repository, + "SECURITY.md", + `# Synthetic policy\n${policy}\n`, + ); + const alias = join(await temporaryDirectory(), "plugin link"); + await symlink( + root, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + await mkdir(join(root, "scripts"), { recursive: true }); + await copyFile( + new URL("scripts/launch_codex_security_mcp", source), + join(root, "scripts", "launch_codex_security_mcp"), + ); + const node = ( + await execFileAsync("node", ["--print", "process.execPath"]) + ).stdout.trim(); + for (const pluginPath of [root, alias]) { + const linkedHelper = join(pluginPath, "mcp", "helpers.mjs"); + const list = await execFileAsync( + process.platform === "win32" ? node : "/bin/sh", + [ + ...(process.platform === "win32" + ? [linkedHelper] + : [ + join(pluginPath, "scripts", "launch_codex_security_mcp"), + "--helper", + ]), + "resolve-security-md", + "--repo", + repository, + "--list", + ], + { env: { ...process.env, CODEX_MCP_NODE_PATH: node, NODE_PATH: "" } }, + ); + expect(list.stdout).toBe('["SECURITY.md"]\n'); + expect(list.stderr).toBe(""); + const guidance = await execFileAsync(node, [ + linkedHelper, + "resolve-security-md", + "--repo", + repository, + "--scope", + repository, + "--out", + "-", + ]); + expect(guidance.stdout).toContain(policy); + expect(guidance.stderr).toBe(""); + } + }); test("builds from a source snapshot without Git metadata", async () => { From 1249ab6d133d65c3036c69b7ac7ea1b1eee2432e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 19:12:26 +0000 Subject: [PATCH 132/251] test: align linked helper fixture formatting --- sdk/typescript/tests-ts/build-plugin.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 020a607882..db52ed0624 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -339,7 +339,6 @@ describe("bundled plugin build", () => { expect(guidance.stdout).toContain(policy); expect(guidance.stderr).toBe(""); } - }); test("builds from a source snapshot without Git metadata", async () => { From 5a8d684cf7841b36557c443b87365a96f8cceadb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 22:05:19 +0000 Subject: [PATCH 133/251] Remove redundant worker cancellation branches --- .../mcp-app/src/deep-scan/worker-runner.ts | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 98266396ee..107b8f778d 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -206,16 +206,6 @@ export class DeepScanWorkerRunner { return { type: "discovery", status: "canceled", workerId }; } - if (this.options.signal.aborted) { - await this.persistWorkerCancellation({ - workerId, - kind: "discovery", - promptPath, - artifactDir - }, outcome.attempt, outcome.threadId); - return { type: "discovery", status: "canceled", workerId }; - } - const acceptance = { id: workerId, scanId: run.scanId, @@ -370,29 +360,9 @@ export class DeepScanWorkerRunner { }; } if (outcome.status === "canceled") throw abortError(); - if (this.options.signal.aborted) { - await this.persistWorkerCancellation({ - workerId: reducerId, - kind: "dedup", - promptPath, - artifactDir - }, outcome.attempt, outcome.threadId); - throw abortError(this.options.signal.reason); - } - if (!reducerValidation) { throw new Error(`${reducerId} completed without validated reducer artifacts.`); } - if (this.options.signal.aborted) { - await this.persistWorkerCancellation({ - workerId: reducerId, - kind: "dedup", - promptPath, - artifactDir - }, outcome.attempt, outcome.threadId); - throw abortError(this.options.signal.reason); - } - const commit = { id: reducerId, scanId: run.scanId, From 3d795c414c5fb5cfb6410537d06598a65960b0af Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 22:52:25 +0000 Subject: [PATCH 134/251] Give boolean test cases distinct report names --- sdk/typescript/tests-ts/api.test.ts | 4 ++-- sdk/typescript/tests-ts/cli-patch-results.test.ts | 4 ++-- sdk/typescript/tests-ts/cost.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 1e7d35f56a..3c5c169bda 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -4773,7 +4773,7 @@ describe("CodexSecurity orchestration", () => { ["standard", true], ["deep", true], ] as const)( - "enforces priced usage with an unpriced remainder (%s, raised limit: %s)", + "enforces priced usage with an unpriced remainder (%s, raised limit: %j)", async (mode, raised) => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -7484,7 +7484,7 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s ["deep", false], ["deep", true], ] as const)( - "isolates concurrent managed %s sessions at the Codex child boundary (capture=%s)", + "isolates concurrent managed %s sessions at the Codex child boundary (capture=%j)", async (mode, captureSummary) => { const clients: TestClient[] = []; try { diff --git a/sdk/typescript/tests-ts/cli-patch-results.test.ts b/sdk/typescript/tests-ts/cli-patch-results.test.ts index febd492431..1bf6476276 100644 --- a/sdk/typescript/tests-ts/cli-patch-results.test.ts +++ b/sdk/typescript/tests-ts/cli-patch-results.test.ts @@ -117,7 +117,7 @@ lines.on("line", (line) => { }); test.each([false, true])( - "fails a no-op with full output %s and preserves local changes", + "fails a no-op with full output %j and preserves local changes", async (fullOutput) => { const fixture = await repositoryFixture(); await writeFile(join(fixture.directory, "app.ts"), "staged change\n"); @@ -178,7 +178,7 @@ lines.on("line", (line) => { }); test.each([false, true])( - "checks patch changes outside a Git repository: %s", + "checks patch changes outside a Git repository: %j", async (apply) => { const fixture = await repositoryFixture(); await rm(join(fixture.directory, ".git"), { diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index cfc1160aa6..9c72c9ac1e 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -2659,7 +2659,7 @@ describe("recorded Deep worker homes", () => { ["prefix-last", false], ["prefix-last", true], ] as const)( - "prices copied response records (%s, attribution: %s)", + "prices copied response records (%s, attribution: %j)", async (copy, attributed) => { const home = await codexHome(); const recordedHome = await codexHome(); From 8f318715e24223f7c247c903fcdf8bac254dc70d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 02:37:55 +0000 Subject: [PATCH 135/251] Clarify draft tool metadata guidance --- .../src/server/compact-artifact-tools.ts | 4 +-- .../tests/test_compact_artifact_server.mjs | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/server/compact-artifact-tools.ts b/plugins/codex-security/mcp-app/src/server/compact-artifact-tools.ts index f514f2e745..c8c514244a 100644 --- a/plugins/codex-security/mcp-app/src/server/compact-artifact-tools.ts +++ b/plugins/codex-security/mcp-app/src/server/compact-artifact-tools.ts @@ -203,7 +203,7 @@ export function registerScanDraftTools( registerCompactTool(server, { name: "record_codex_security_scan_draft", title: "Record Codex Security Scan Draft", - description: "Save semantic findings and coverage as an unsealed draft. Use complete:false for progress checkpoints, then complete:true for the final result; keep unvalidated candidates in coverage.deferred.", + description: "Save semantic findings and coverage as an unsealed draft. Use complete:false for progress checkpoints, then complete:true for the final result; keep unvalidated candidates in coverage.deferred. Pass the required top-level scanId. Omit workbench-owned scope.includePaths and scope.excludePaths. Omit coverage.scanId and other coverage metadata: documentType, schemaVersion, mode, includePaths, excludePaths, receiptRefs, inventoryStrategy. Omit findingId, occurrenceId, and fingerprints from findings.", inputSchema: scanDraftInputSchema, readOnly: false, handler: async (value, requestContext) => { @@ -297,7 +297,7 @@ export function registerCompactWorkerArtifactTools( registerCompactTool(server, { name: "record_codex_security_scan_draft", title: "Record Codex Security Scan Draft", - description: "Save this Standard worker's semantic findings and coverage. Use complete:false for progress checkpoints, then complete:true for its final result; keep unvalidated candidates in coverage.deferred.", + description: "Save this Standard worker's semantic findings and coverage. Use complete:false for progress checkpoints, then complete:true for its final result; keep unvalidated candidates in coverage.deferred. Pass the required top-level scanId. Omit workbench-owned scope.includePaths and scope.excludePaths. Omit coverage.scanId and other coverage metadata: documentType, schemaVersion, mode, includePaths, excludePaths, receiptRefs, inventoryStrategy. Omit findingId, occurrenceId, and fingerprints from findings.", inputSchema: scanDraftInputSchema, readOnly: false, handler: async (value) => recordCodexSecurityWorkerScanDraft( diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs index af2138c5b7..70c20620e5 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs @@ -934,6 +934,23 @@ function requireToolError(result, expected, label) { assert.match(result.content?.[0]?.text ?? "", expected, label); } +function assertScanDraftToolGuidance(tool) { + assert.ok(tool, "The draft tool must be listed."); + assert.match(tool.description, /Pass the required top-level scanId\./u); + assert.match( + tool.description, + /Omit workbench-owned scope\.includePaths and scope\.excludePaths\./u + ); + assert.match(tool.description, /Omit coverage\.scanId and other coverage metadata:/u); + for (const field of [ + "documentType", "schemaVersion", "mode", "includePaths", "excludePaths", + "receiptRefs", "inventoryStrategy", "findingId", "occurrenceId", "fingerprints" + ]) { + assert.ok(tool.description.includes(field), `The draft guidance must identify ${field}.`); + } + assert.equal(tool.inputSchema.required.includes("scanId"), true); +} + async function testParentToolList(bundle) { const stateRoot = await mkdtemp(path.join(temporaryRoot, "parent-tool-state-")); const client = await startClient(bundle, { CODEX_SECURITY_STATE_DIR: stateRoot }); @@ -952,6 +969,7 @@ async function testParentToolList(bundle) { `Model-visible MCP tool ${projectedName} exceeds Codex's 64-character limit.` ); } + assertScanDraftToolGuidance(tools.find((tool) => tool.name === "record_codex_security_scan_draft")); const names = new Set(tools.map((tool) => tool.name)); assert.equal( names.has("record_codex_security_worker_threat_model"), @@ -1083,6 +1101,7 @@ async function testDiscoveryWorkerToolList(bundle) { assert.deepEqual(tools.map((tool) => tool.name), ["record_codex_security_scan_draft"]); const [tool] = tools; + assertScanDraftToolGuidance(tool); const projectedName = `mcp__cs_artifacts__${tool.name}`; assert.ok( projectedName.length <= 64, @@ -1109,6 +1128,18 @@ async function testDiscoveryWorkerToolList(bundle) { } }; + for (const arguments_ of [ + { ...input, scope: { includePaths: ["src"], excludePaths: [] } }, + { ...input, coverage: { ...input.coverage, scanId } } + ]) { + requireToolError( + await client.callTool({ name: tool.name, arguments: arguments_ }), + /expected never/, + "The draft description must not change rejection of workbench-owned metadata." + ); + await assert.rejects(readFile(resultPath), { code: "ENOENT" }); + } + requireToolError( await client.callTool({ name: tool.name, From d64a5e6971e7558d544b6d51ba6f96a337351d1f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 02:51:39 +0000 Subject: [PATCH 136/251] Clarify ownership in draft tool guidance --- .../mcp-app/src/server/compact-artifact-tools.ts | 4 ++-- .../tests/test_compact_artifact_server.mjs | 15 ++++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/server/compact-artifact-tools.ts b/plugins/codex-security/mcp-app/src/server/compact-artifact-tools.ts index c8c514244a..e2ede3569f 100644 --- a/plugins/codex-security/mcp-app/src/server/compact-artifact-tools.ts +++ b/plugins/codex-security/mcp-app/src/server/compact-artifact-tools.ts @@ -203,7 +203,7 @@ export function registerScanDraftTools( registerCompactTool(server, { name: "record_codex_security_scan_draft", title: "Record Codex Security Scan Draft", - description: "Save semantic findings and coverage as an unsealed draft. Use complete:false for progress checkpoints, then complete:true for the final result; keep unvalidated candidates in coverage.deferred. Pass the required top-level scanId. Omit workbench-owned scope.includePaths and scope.excludePaths. Omit coverage.scanId and other coverage metadata: documentType, schemaVersion, mode, includePaths, excludePaths, receiptRefs, inventoryStrategy. Omit findingId, occurrenceId, and fingerprints from findings.", + description: "Save semantic findings and coverage as an unsealed draft. Use complete:false for progress checkpoints, then complete:true for the final result; keep unvalidated candidates in coverage.deferred. Provide the required top-level scanId. The workbench supplies the following fields; omit them from your draft: findingId, occurrenceId, and fingerprints within each finding; includePaths and excludePaths within scope; and documentType, schemaVersion, scanId, mode, includePaths, excludePaths, receiptRefs, and inventoryStrategy within coverage.", inputSchema: scanDraftInputSchema, readOnly: false, handler: async (value, requestContext) => { @@ -297,7 +297,7 @@ export function registerCompactWorkerArtifactTools( registerCompactTool(server, { name: "record_codex_security_scan_draft", title: "Record Codex Security Scan Draft", - description: "Save this Standard worker's semantic findings and coverage. Use complete:false for progress checkpoints, then complete:true for its final result; keep unvalidated candidates in coverage.deferred. Pass the required top-level scanId. Omit workbench-owned scope.includePaths and scope.excludePaths. Omit coverage.scanId and other coverage metadata: documentType, schemaVersion, mode, includePaths, excludePaths, receiptRefs, inventoryStrategy. Omit findingId, occurrenceId, and fingerprints from findings.", + description: "Save this Standard worker's semantic findings and coverage. Use complete:false for progress checkpoints, then complete:true for its final result; keep unvalidated candidates in coverage.deferred. Provide the required top-level scanId. The workbench supplies the following fields; omit them from your draft: findingId, occurrenceId, and fingerprints within each finding; includePaths and excludePaths within scope; and documentType, schemaVersion, scanId, mode, includePaths, excludePaths, receiptRefs, and inventoryStrategy within coverage.", inputSchema: scanDraftInputSchema, readOnly: false, handler: async (value) => recordCodexSecurityWorkerScanDraft( diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs index 70c20620e5..410cbc4040 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs @@ -936,18 +936,15 @@ function requireToolError(result, expected, label) { function assertScanDraftToolGuidance(tool) { assert.ok(tool, "The draft tool must be listed."); - assert.match(tool.description, /Pass the required top-level scanId\./u); + assert.match(tool.description, /Provide the required top-level scanId\./u); assert.match( tool.description, - /Omit workbench-owned scope\.includePaths and scope\.excludePaths\./u + /findingId, occurrenceId, and fingerprints within each finding; includePaths and excludePaths within scope;/u + ); + assert.match( + tool.description, + /documentType, schemaVersion, scanId, mode, includePaths, excludePaths, receiptRefs, and inventoryStrategy within coverage\./u ); - assert.match(tool.description, /Omit coverage\.scanId and other coverage metadata:/u); - for (const field of [ - "documentType", "schemaVersion", "mode", "includePaths", "excludePaths", - "receiptRefs", "inventoryStrategy", "findingId", "occurrenceId", "fingerprints" - ]) { - assert.ok(tool.description.includes(field), `The draft guidance must identify ${field}.`); - } assert.equal(tool.inputSchema.required.includes("scanId"), true); } From 8e9c9c3bba5a91d2d61cfc084f2370bdfd37cb3e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 02:57:48 +0000 Subject: [PATCH 137/251] Keep draft tool tests focused on behavior --- .../tests/test_compact_artifact_server.mjs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs index 410cbc4040..c7063c781b 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs @@ -934,20 +934,6 @@ function requireToolError(result, expected, label) { assert.match(result.content?.[0]?.text ?? "", expected, label); } -function assertScanDraftToolGuidance(tool) { - assert.ok(tool, "The draft tool must be listed."); - assert.match(tool.description, /Provide the required top-level scanId\./u); - assert.match( - tool.description, - /findingId, occurrenceId, and fingerprints within each finding; includePaths and excludePaths within scope;/u - ); - assert.match( - tool.description, - /documentType, schemaVersion, scanId, mode, includePaths, excludePaths, receiptRefs, and inventoryStrategy within coverage\./u - ); - assert.equal(tool.inputSchema.required.includes("scanId"), true); -} - async function testParentToolList(bundle) { const stateRoot = await mkdtemp(path.join(temporaryRoot, "parent-tool-state-")); const client = await startClient(bundle, { CODEX_SECURITY_STATE_DIR: stateRoot }); @@ -966,7 +952,6 @@ async function testParentToolList(bundle) { `Model-visible MCP tool ${projectedName} exceeds Codex's 64-character limit.` ); } - assertScanDraftToolGuidance(tools.find((tool) => tool.name === "record_codex_security_scan_draft")); const names = new Set(tools.map((tool) => tool.name)); assert.equal( names.has("record_codex_security_worker_threat_model"), @@ -1098,7 +1083,6 @@ async function testDiscoveryWorkerToolList(bundle) { assert.deepEqual(tools.map((tool) => tool.name), ["record_codex_security_scan_draft"]); const [tool] = tools; - assertScanDraftToolGuidance(tool); const projectedName = `mcp__cs_artifacts__${tool.name}`; assert.ok( projectedName.length <= 64, From 3db3e3de95950347ab4a07797baa75865db8d4b4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 04:27:15 +0000 Subject: [PATCH 138/251] Remove unused coordinator scheduler result bookkeeping --- .../mcp-app/src/deep-scan/coordinator.ts | 43 ++----------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 03f9d58fb5..2b10c7ff22 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -1,6 +1,5 @@ import { publishSelectedDeepScan } from "./finalization.js"; import { randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; import { basename, dirname, join } from "node:path"; import { createDeepScanArtifacts, @@ -52,10 +51,6 @@ type AcceptedReducer = Omit; interface SchedulerResult { reason: DeepScanTerminalReason; omittedWorkerIds: string[]; - canceledWorkerIds: string[]; - accepted: AcceptedDiscovery[]; - mergedWorkerIds: string[]; - reducers: AcceptedReducer[]; result?: DeepReductionInput; resultPath?: string; } @@ -618,12 +613,6 @@ export class DeepScanCoordinator { .filter((worker) => worker.kind === "discovery" && worker.mergeState === "merged") .map((worker) => worker.id) ); - const mergedDiscoveries: AcceptedDiscovery[] = recovered.filter((worker) => ( - mergedIds.has(worker.id) - )); - const canceledWorkerIds = (this.state.persistedWorkers ?? []) - .filter((worker) => worker.kind === "discovery" && worker.status === "canceled") - .map((worker) => worker.id); const omittedWorkerIds: string[] = []; const recoveredReducers = await this.recoverCompletedReducers(recovered); const reducerOutcomes = recoveredReducers.reducers; @@ -716,16 +705,12 @@ export class DeepScanCoordinator { const workerId = entries[index]?.[0]; if (workerId) active.delete(workerId); if (result.status === "rejected") { - if (workerId) removeValue(canceledWorkerIds, workerId); firstFailure ??= result.reason; continue; } const outcome = result.value; if (outcome.status === "failed") { - if (outcome.replaceableFailureKind) { - canceledWorkerIds.push(outcome.workerId); - } else { - removeValue(canceledWorkerIds, outcome.workerId); + if (!outcome.replaceableFailureKind) { firstFailure ??= outcome.error; } } else if (outcome.status === "succeeded") { @@ -737,9 +722,6 @@ export class DeepScanCoordinator { } else if (!buffer.some((worker) => worker.id === outcome.worker.id)) { buffer.push(outcome.worker); } - removeValue(canceledWorkerIds, outcome.worker.id); - } else { - canceledWorkerIds.push(outcome.workerId); } } return firstFailure; @@ -759,10 +741,7 @@ export class DeepScanCoordinator { } this.state = outcome.run; previousReducerResultPath = outcome.resultPath; - mergedDiscoveries.push(...outcome.consumed); - const { result: acceptedResult, ...metadata } = outcome; - latestResult = acceptedResult; - reducerOutcomes.push(metadata); + latestResult = outcome.result; return undefined; }; @@ -848,7 +827,6 @@ export class DeepScanCoordinator { const consecutiveErrors = outcome.consecutiveErrors ?? (this.state.consecutiveErrors ?? 0) + 1; this.state = { ...this.state, consecutiveErrors }; - canceledWorkerIds.push(outcome.workerId); this.log({ event: "discovery_worker_replaced", scanId: this.state.scanId, @@ -875,7 +853,6 @@ export class DeepScanCoordinator { throw outcome.error; } if (outcome.status === "canceled") { - canceledWorkerIds.push(outcome.workerId); if ( !this.abortController.signal.aborted && !this.discoveryAbortController.signal.aborted @@ -916,17 +893,13 @@ export class DeepScanCoordinator { reducerFailures = 0; this.state = outcome.run; previousReducerResultPath = outcome.resultPath; - mergedDiscoveries.push(...outcome.consumed); - const { result: acceptedResult, ...metadata } = outcome; - latestResult = acceptedResult; - reducerOutcomes.push(metadata); + latestResult = outcome.result; if ( !this.discoveryDeadlineReached && outcome.run.noNewStreak >= config.stopAfterNoNew && buffer.length === 0 ) { stopReason = "saturated"; - canceledWorkerIds.push(...active.keys()); this.abortController.abort("deep_scan_saturated"); } } @@ -947,10 +920,6 @@ export class DeepScanCoordinator { return { reason: stopReason ?? "capped", omittedWorkerIds: unique(omittedWorkerIds), - canceledWorkerIds: unique(canceledWorkerIds), - accepted, - mergedWorkerIds: unique(mergedDiscoveries.map((worker) => worker.id)), - reducers: reducerOutcomes, result: latestResult, resultPath: previousReducerResultPath, }; @@ -1143,12 +1112,6 @@ function unique(values: string[]): string[] { return [...new Set(values)]; } -function removeValue(values: string[], value: string): void { - for (let index = values.length - 1; index >= 0; index -= 1) { - if (values[index] === value) values.splice(index, 1); - } -} - function cloneState(state: DeepScanRunState): DeepScanRunState { return { ...state, config: { ...state.config } }; } From f6f023afae1b24bb6a3da26610ae52e91399c95a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 04:34:19 +0000 Subject: [PATCH 139/251] Retain only the latest recovered reducer result and path --- .../mcp-app/src/deep-scan/coordinator.ts | 31 +++++-------------- .../tests/test_deep_scan_attempt_replay.mjs | 2 +- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 2b10c7ff22..29e1536f8d 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -17,8 +17,7 @@ import { DeepScanWorkerRunner } from "./worker-runner.js"; import type { AcceptedDiscovery, DedupOutcome, - DiscoveryOutcome, - SuccessfulDedupOutcome + DiscoveryOutcome } from "./worker-runner.js"; import { boundedDeepScanErrorPair, @@ -46,8 +45,6 @@ type SchedulerSettlement = | { status: "fulfilled"; outcome: SchedulerOutcome } | { status: "rejected"; error: unknown }; -type AcceptedReducer = Omit; - interface SchedulerResult { reason: DeepScanTerminalReason; omittedWorkerIds: string[]; @@ -615,11 +612,10 @@ export class DeepScanCoordinator { ); const omittedWorkerIds: string[] = []; const recoveredReducers = await this.recoverCompletedReducers(recovered); - const reducerOutcomes = recoveredReducers.reducers; let latestResult = recoveredReducers.result; let buffer: AcceptedDiscovery[] = recovered.filter((worker) => !mergedIds.has(worker.id)); let reducer: Promise | undefined; - let previousReducerResultPath = reducerOutcomes.at(-1)?.resultPath; + let previousReducerResultPath = recoveredReducers.resultPath; let dispatched = this.state.dispatchedCount; let workerSequence = Math.max( dispatched, @@ -959,18 +955,17 @@ export class DeepScanCoordinator { private async recoverCompletedReducers( discoveries: AcceptedDiscovery[] - ): Promise<{ reducers: AcceptedReducer[]; result?: DeepReductionInput }> { + ): Promise<{ result?: DeepReductionInput; resultPath?: string }> { const discoveriesById = new Map(discoveries.map((worker) => [worker.id, worker])); const inputs = this.state.persistedDedupInputs ?? []; - const outcomes: AcceptedReducer[] = []; let latestResult: DeepReductionInput | undefined; + let latestResultPath: string | undefined; const completedReducers = (this.state.persistedWorkers ?? []) .filter((worker) => worker.kind === "dedup" && worker.status === "succeeded") .sort((left, right) => ( workerLabelSequence(left, "dedup") - workerLabelSequence(right, "dedup") || left.id.localeCompare(right.id) )); - let noNewStreak = 0; for (const worker of completedReducers) { // A later merge claim can retain a legacy aggregate's accepted reference. const resultPath = worker.acceptedResultPath @@ -997,12 +992,12 @@ export class DeepScanCoordinator { } const accepted = consumed as AcceptedDiscovery[]; const claim = this.state.persistedMergeClaims?.find((item) => item.workerId === worker.id); - const { newFindings, result } = await validateReducerArtifacts({ + const { result } = await validateReducerArtifacts({ artifacts: this.artifacts, artifactDir: worker.artifactDir, resultPath, reducerId: worker.id, - previousReducerResultPath: claim ? claim.previousResultPath : outcomes.at(-1)?.resultPath + previousReducerResultPath: claim ? claim.previousResultPath : latestResultPath }, this.state.scanId); if (result.sourceCoverage === undefined) { const context = { @@ -1021,19 +1016,9 @@ export class DeepScanCoordinator { result.sourceCoverage = aggregateSourceCoverage(sources.discoveries, latestResult ?? null); } latestResult = result; - noNewStreak = newFindings > 0 ? 0 : noNewStreak + accepted.length; - outcomes.push({ - type: "dedup", - id: worker.id, - consumed: accepted, - resultPath, - newFindings, - attempt: worker.attempt, - ...(worker.threadId ? { threadId: worker.threadId } : {}), - run: { ...this.state, noNewStreak } - }); + latestResultPath = resultPath; } - return { reducers: outcomes, result: latestResult }; + return { result: latestResult, resultPath: latestResultPath }; } private reducerReady( diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs index b18a93105e..9a45820a6d 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs @@ -123,7 +123,7 @@ async function testResponseLoss(responseLosses) { assert.deepEqual(recovered.map(worker => worker.resultPath), [discovery.worker.resultPath, second.worker.resultPath]); await rm(path.join(path.dirname(merged.resultPath), "..", "result.json")); const reducers = await resumed.recoverCompletedReducers(recovered); - assert.equal(reducers.reducers[0].resultPath, merged.resultPath); + assert.equal(reducers.resultPath, merged.resultPath); assert.deepEqual(reducers.result, merged.result); assert.equal(await readFile(merged.resultPath, "utf8"), beforeRecovery, "recovery cannot rewrite accepted bytes"); From b4a0a192659eb84eab27da999e80a8eb4f559312 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 04:55:10 +0000 Subject: [PATCH 140/251] docs: distinguish Deep Scan coverage from completion --- plugins/codex-security/references/scan-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/codex-security/references/scan-contract.md b/plugins/codex-security/references/scan-contract.md index 5b449af8c1..3ccd770a52 100644 --- a/plugins/codex-security/references/scan-contract.md +++ b/plugins/codex-security/references/scan-contract.md @@ -106,7 +106,7 @@ Use CWE taxonomy separately. Do not include file names, line numbers, scan IDs, `coverage.json` records scan scope and completion information. Standard and diff summaries also describe reviewed surfaces and outstanding work. -For a Deep parent scan, the host copies the configured paths into `includePaths` and `excludePaths` and sets `completeness` from the coordinator's outcome. A successful aggregate uses `complete`; `surfaces`, `explicitExclusions`, and `deferred` are empty arrays, and `openQuestions` is omitted. If the configured time limit expires before any review completes, the coordinator writes `partial` and records the explanation in `deferred`. Stopped outcomes follow the [stopped-result recovery rules](#stopped-result-recovery). +For a Deep parent scan, the host copies the configured paths into `includePaths` and `excludePaths`. Deep v2 aggregates preserve accepted source coverage in `surfaces`, `explicitExclusions`, `deferred`, `openQuestions`, and `reviews`, including recorded source provenance and receipt references. `completeness` is `partial` when an accepted source is partial, work is deferred, or a surface has disposition `needs_follow_up`; otherwise it is `unknown` when source coverage is unknown, and `complete` when neither condition applies. A successful aggregate or a sealed `completed` scan can therefore retain partial or unknown coverage. Report canonical coverage separately from lifecycle completion. If the configured time limit expires before any review completes, the coordinator writes `partial` and records the explanation in `deferred`. Stopped outcomes follow the [stopped-result recovery rules](#stopped-result-recovery). Each Deep worker writes an ordinary Standard result, including its own coverage. A reducer submits `record_codex_security_deep_reduction({ scanId, findings, scope?, threatModel? })`; its saved results and checkpoints contain the accepted findings and optional scope and threat-model context. From 65659f9e3715f4858d1bf68b800b2907bc418eb4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 05:33:28 +0000 Subject: [PATCH 141/251] fix(plugin): expose canonical coverage in completion responses --- plugins/codex-security/mcp-app/server.ts | 8 +++- .../tests/test_compact_artifact_server.mjs | 46 ++++++++++++++++--- .../codex-security/scripts/workbench_db.py | 23 ++++++++-- .../test_workbench_completion_binding.py | 33 +++++++++++++ 4 files changed, 97 insertions(+), 13 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index dec3b69f9a..a609c202cb 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -1162,12 +1162,16 @@ export function createCodexSecurityServer(): McpServer { _meta: modelActionMeta }, async ({ scanId, handoffClaimToken }) => { try { - return scanActionResult(await runWorkbench([ + const result = await runWorkbench([ "complete-scan", "--scan-id", scanId, ...optionalArg("--claim-token", handoffClaimToken) - ]), "Validated and indexed the completed Codex Security scan."); + ]); + const coverage = result.coverageSummary as JsonObject; + return scanActionResult(result, + `Validated and indexed the completed Codex Security scan. Canonical coverage: ${coverage.completeness}; ${coverage.surfaceCount} surfaces; ${coverage.deferredCount} deferred items; ${coverage.explicitExclusionCount} explicit exclusions.` + ); } catch (error) { throw new Error(completionFailureMessage(error)); } diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs index c7063c781b..285080822a 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.mjs @@ -203,10 +203,8 @@ async function testCompactDiffScanCompletion(bundle, runtimeLabel) { }), `${runtimeLabel}: record compact diff canonical semantics` ); - requireSuccessfulTool( - await call("complete_codex_security_scan", { scanId, handoffClaimToken }), - `${runtimeLabel}: complete compact diff scan` - ); + const completion = await call("complete_codex_security_scan", { scanId, handoffClaimToken }); + requireSuccessfulTool(completion, `${runtimeLabel}: complete compact diff scan`); const completed = requireSuccessfulTool( await call("get_codex_security_completed_scan", { scanId, handoffClaimToken }), `${runtimeLabel}: read completed compact diff scan` @@ -219,6 +217,9 @@ async function testCompactDiffScanCompletion(bundle, runtimeLabel) { ); assert.equal(completed.coverage.inventoryStrategy, "diff"); assert.equal(completed.findings.findings.length, 0); + await assertCompletionCoverageAndReplay( + call, { scanId, handoffClaimToken }, completion, completed.coverage + ); } finally { await client.close(); } @@ -591,10 +592,13 @@ async function testSemanticScanDraftCompletion(bundle, runtimeLabel) { assert.equal(reporting.status, "running"); assert.deepEqual(reporting.phaseProgress, { completed: 0, total: 0, unit: null }); - const completed = requireSuccessfulTool(await call( + const completion = await call( "complete_codex_security_scan", { scanId, handoffClaimToken } - ), `${runtimeLabel}: finalize the accepted draft exactly once`); + ); + const completed = requireSuccessfulTool( + completion, `${runtimeLabel}: finalize the accepted draft exactly once` + ); assert.equal(completed.scan.progress.status, "complete"); assert.equal(completed.scan.reportAvailable, true); @@ -623,6 +627,9 @@ async function testSemanticScanDraftCompletion(bundle, runtimeLabel) { assert.equal(results.manifest.scan.target.kind, "directory_snapshot"); assert.equal(results.coverage.inventoryStrategy, "directory"); assert.equal(results.coverage.completeness, "partial"); + await assertCompletionCoverageAndReplay( + call, { scanId, handoffClaimToken }, completion, results.coverage + ); assert.deepEqual(results.coverage.includePaths, ["."]); assert.deepEqual(results.coverage.excludePaths, []); assert.equal(results.coverage.surfaces[0].disposition, "reported"); @@ -676,6 +683,33 @@ async function testSemanticScanDraftCompletion(bundle, runtimeLabel) { } } +async function assertCompletionCoverageAndReplay(call, identity, completion, coverage) { + const expected = { + completeness: coverage.completeness, + surfaceCount: coverage.surfaces.length, + deferredCount: coverage.deferred.length, + explicitExclusionCount: coverage.explicitExclusions.length + }; + const scan = requireSuccessfulTool(completion).scan; + const snapshot = () => Promise.all([ + "scan-manifest.json", "findings.json", "coverage.json", "report.md" + ].map((name) => readFile(path.join(scan.scanDir, name), "utf8"))); + const sealed = await snapshot(); + const replay = await call("complete_codex_security_scan", identity); + for (const result of [completion, replay]) { + const structured = requireSuccessfulTool(result); + assert.equal(structured.scan.progress.status, "complete"); + assert.equal(result.content[0].type, "text"); + assert.match(result.content[0].text, new RegExp(`Canonical coverage: ${expected.completeness}\\b`)); + assert.match(result.content[0].text, new RegExp(`\\b${expected.surfaceCount} surfaces\\b`)); + assert.match(result.content[0].text, new RegExp(`\\b${expected.deferredCount} deferred items\\b`)); + assert.match(result.content[0].text, new RegExp(`\\b${expected.explicitExclusionCount} explicit exclusions\\b`)); + assert.deepEqual(structured.coverageSummary, expected); + assert.deepEqual(structured.scan.usage, scan.usage); + } + assert.deepEqual(await snapshot(), sealed, "completion replay preserves sealed artifacts"); +} + async function snapshotScanDraft(scanDirectory) { return Promise.all(["scan-manifest.json", "findings.json", "coverage.json"].map( async (artifact) => { diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 61b18a9af4..4663695ff2 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1340,6 +1340,19 @@ def budget_exhausted_draft( ) +def completed_scan_context( + connection: sqlite3.Connection, scan_id: str, coverage: dict[str, Any] +) -> dict[str, Any]: + context = scan_context(connection, scan_id) + context["coverageSummary"] = { + "completeness": coverage["completeness"], + "surfaceCount": len(coverage["surfaces"]), + "deferredCount": len(coverage.get("deferred", [])), + "explicitExclusionCount": len(coverage.get("explicitExclusions", [])), + } + return context + + def complete_scan_locked( connection: sqlite3.Connection, scan_id: str, @@ -1356,7 +1369,7 @@ def complete_scan_locked( require_recorded_manifest_digest(scan, scan_dir) verify_manifest_binding(scan, read_json_object(scan_dir / ARTIFACTS["manifest"])) try: - manifest, _, _ = finalize_scan( + manifest, _, coverage = finalize_scan( scan_dir, expected_coverage_mode=expected_coverage_mode(scan), ) @@ -1367,7 +1380,7 @@ def complete_scan_locked( pin_legacy_manifest_digest(connection, scan["id"], manifest_digest) if cost_json is not None and scan["recipe_json"] is not None: scan_usage.reconcile_completed_scan_cost(connection, scan, cost_json) - return scan_context(connection, scan["id"]) + return completed_scan_context(connection, scan["id"], coverage) if scan["status"] != "running": raise SystemExit("Only a running scan can be completed.") handoff.require_current_continuation( @@ -1453,7 +1466,7 @@ def add_warning() -> None: ) add_warning() wrote = True - manifest, findings, _ = _write_prepared_scan_finalization(prepared) + manifest, findings, coverage = _write_prepared_scan_finalization(prepared) except ContractError as exc: # Replay a validated Deep aggregate after an output write fails. if (wrote and scan["mode"] != "deep") or ( @@ -1502,7 +1515,7 @@ def add_warning() -> None: scan = require_scan(connection, scan["id"]) if scan["status"] == "complete": connection.commit() - return scan_context(connection, scan["id"]) + return completed_scan_context(connection, scan["id"], coverage) if scan["status"] != "running": raise SystemExit("Only a running scan can be completed.") deep_scan.require_deep_scan_ready_for_parent_completion(connection, scan) @@ -1553,7 +1566,7 @@ def add_warning() -> None: except BaseException: connection.rollback() raise - context = scan_context(connection, scan["id"]) + context = completed_scan_context(connection, scan["id"], coverage) context["targetWarnings"] = target_warnings return context diff --git a/plugins/codex-security/tests/test_workbench_completion_binding.py b/plugins/codex-security/tests/test_workbench_completion_binding.py index dbcdafd9f4..e51af0b7e0 100644 --- a/plugins/codex-security/tests/test_workbench_completion_binding.py +++ b/plugins/codex-security/tests/test_workbench_completion_binding.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +import pytest from workbench_test_support import ( create_saved_workspace, initialize_git_repository, @@ -69,6 +70,38 @@ def _start_deep_scan_with_draft_findings(tmp_path: Path) -> tuple[Path, str, Pat return state_dir, scan_id, scan_dir +@pytest.mark.parametrize("completeness", ["partial", "complete", "unknown"]) +def test_deep_completion_returns_sealed_coverage_on_replay( + tmp_path: Path, completeness: str +) -> None: + state_dir, scan_id, scan_dir = _start_deep_scan_with_draft_findings(tmp_path) + coverage_path = scan_dir / "coverage.json" + coverage = json.loads(coverage_path.read_text()) + coverage["completeness"] = completeness + coverage["deferred"] = ( + [{"id": "external-control", "reason": "Deployment configuration is unavailable."}] + if completeness == "partial" + else [] + ) + coverage_path.write_text(json.dumps(coverage)) + completed = run_workbench(state_dir, "complete-scan", "--scan-id", scan_id) + canonical = json.loads(coverage_path.read_text()) + expected = { + "completeness": canonical["completeness"], + "surfaceCount": len(canonical["surfaces"]), + "deferredCount": len(canonical["deferred"]), + "explicitExclusionCount": len(canonical["explicitExclusions"]), + } + artifacts = ["scan-manifest.json", "findings.json", "coverage.json", "report.md"] + sealed = [(scan_dir / name).read_bytes() for name in artifacts] + replayed = run_workbench(state_dir, "complete-scan", "--scan-id", scan_id) + for result in (completed, replayed): + assert result["scan"]["progress"]["status"] == "complete" + assert result["coverageSummary"] == expected + assert result["scan"]["usage"] == completed["scan"]["usage"] + assert [(scan_dir / name).read_bytes() for name in artifacts] == sealed + + def register_cli_scan(state_dir: Path, target: Path, scan_dir: Path) -> dict[str, Any]: scan_dir.mkdir(mode=0o700) return run_workbench( From 7be88f1836266cebef9b6c5bdfd86c59dbc6ad53 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 06:26:13 +0000 Subject: [PATCH 142/251] test: preserve checkpoint publication fault coverage --- .../test_checkpoint_publication_authority.py | 174 +++++++++++++++++- 1 file changed, 167 insertions(+), 7 deletions(-) diff --git a/plugins/codex-security/tests/test_checkpoint_publication_authority.py b/plugins/codex-security/tests/test_checkpoint_publication_authority.py index a079f280cd..1204e3ccef 100644 --- a/plugins/codex-security/tests/test_checkpoint_publication_authority.py +++ b/plugins/codex-security/tests/test_checkpoint_publication_authority.py @@ -2,12 +2,120 @@ import copy import json +import uuid from argparse import Namespace import pytest from test_deep_scan_successful_publication import add_worker from test_deep_scan_successful_publication import publication_scan as publication_scan -from workbench_test_support import write_checkpoint +from test_workbench_standard_deep_results import deep_scan_fixture, worker_paths +from workbench_test_support import run_workbench, write_checkpoint, write_completed_contract + + +def test_public_stop_retains_accepted_partial_evidence_and_newer_rejection(tmp_path): + state, home, target, scan_dir, scan_id = deep_scan_fixture(tmp_path, workers=2) + environment = {"CODEX_HOME": str(home)} + contract = tmp_path / "contract" + contract.mkdir() + write_completed_contract(contract, scan_id, target, relative_path="app.py") + finding = json.loads((contract / "findings.json").read_text())["findings"][0] + deferred = { + "candidateId": "pending-query", + "reason": "Validation is pending.", + "paths": ["app.py"], + } + coverage = { + "completeness": "partial", + "surfaces": [], + "explicitExclusions": [], + "deferred": [deferred], + } + workers = [] + for accepted in (True, False): + name = "accepted" if accepted else "interrupted" + prompt, output, result = worker_paths(scan_dir, name) + worker_id = str(uuid.uuid4()) + worker_args = ( + "upsert-deep-scan-worker", + "--scan-id", + scan_id, + "--worker-id", + worker_id, + "--kind", + "discovery", + "--prompt-path", + str(prompt), + "--artifact-dir", + str(output), + "--attempt", + "1", + ) + run_workbench(state, *worker_args, "--status", "running", environment=environment) + current = copy.deepcopy(finding) + current["identity"]["anchor"] = name + current["extensions"] = {"candidateId": name} + draft = {"scanId": scan_id, "complete": True, "findings": [current], "coverage": coverage} + result.write_text(json.dumps(draft)) + write_checkpoint(output / "checkpoints", draft) + if accepted: + run_workbench( + state, + *worker_args, + "--status", + "succeeded", + "--result-manifest-path", + str(result), + environment=environment, + ) + else: + rejected = { + **draft, + "complete": False, + "findings": [], + "coverage": { + **coverage, + "surfaces": [ + { + "candidateId": name, + "label": "Reviewed candidate", + "disposition": "rejected", + "receiptRefs": [], + } + ], + }, + } + head = write_checkpoint(output / "checkpoints", rejected) + (output / "checkpoint-head.json").write_text(json.dumps({"checkpoint": head.name})) + workers.append(worker_id) + + stopped = run_workbench( + state, + "fail-deep-scan", + "--scan-id", + scan_id, + "--message", + "Original worker failure.", + "--deep-status", + "interrupted", + environment=environment, + )["deepScan"] + assert stopped["status"] == "interrupted" + scan = run_workbench(state, "get-scan", "--scan-id", scan_id)["scan"] + assert scan["findingCount"] == 1 + findings = json.loads((scan_dir / "findings.json").read_text())["findings"] + assert [item["identity"]["anchor"] for item in findings] == ["accepted"] + retained_coverage = json.loads((scan_dir / "coverage.json").read_text()) + assert retained_coverage["completeness"] == "partial" + assert any(item.get("candidateId") == "pending-query" for item in retained_coverage["deferred"]) + assert any( + item.get("candidateId") == "interrupted" and item["disposition"] == "rejected" + for item in retained_coverage["surfaces"] + ) + assert scan["failureMessage"] == "Original worker failure." + assert {worker["id"]: worker["status"] for worker in stopped["workers"]} == { + workers[0]: "succeeded", + workers[1]: "canceled", + } @pytest.mark.parametrize("archived", [False, True], ids=["current", "archived"]) @@ -111,9 +219,9 @@ def test_newer_checkpoint_disposition_precedes_older_archived_head( scan = publication_scan() (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) result = add_worker(workbench_db, scan, status="canceled") - old = result.parent / "attempts" / "attempt-1" + old = result.parent / "attempts" / "attempt-2" save_disposition(scan, old, "rejected" if disposition == "reported" else "reported") - current = result.parent / "attempts" / "attempt-2" if archived else result.parent + current = result.parent / "attempts" / "attempt-10" if archived else result.parent draft = save_disposition(scan, current, disposition) (current / "result.json").write_text(json.dumps(draft)) @@ -129,19 +237,42 @@ def test_newer_checkpoint_disposition_precedes_older_archived_head( def test_frozen_stopped_replay_ignores_later_worker_head_changes( workbench_api, workbench_db, publication_scan, monkeypatch, head_change ): + import finalize_scan_contract + scan = publication_scan() (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) result = add_worker(workbench_db, scan, status="canceled") previous = save_disposition(scan, result.parent, "reported") result.write_text(json.dumps(previous)) save_disposition(scan, result.parent, "rejected") - saved = workbench_api["saved_results"] + checkpoint_name = json.loads((result.parent / "checkpoint-head.json").read_text())["checkpoint"] + directory = result.parent.relative_to(scan.scan_dir).as_posix() + expected_heads = {directory: f"{directory}/checkpoints/{checkpoint_name}"} + original_outputs = { + name: (scan.scan_dir / name).read_bytes() + for name in ("findings.json", "coverage.json", "scan-manifest.json") + } + write_bytes = finalize_scan_contract.write_scan_local_bytes + failed_writes = [] - def fail_before_publication(*args, **kwargs): - raise OSError("Synthetic publication interruption") + def fail_coverage_write(directory, relative, payload, **kwargs): + if relative != "coverage.json" or failed_writes: + return write_bytes(directory, relative, payload, **kwargs) + # Exercise the real writer after findings have reached disk. Remove the + # temporary obstruction before the publisher restores its old outputs. + failed_writes.append(json.loads((directory / "findings.json").read_text())) + path = directory / relative + previous_bytes = path.read_bytes() + path.unlink() + path.mkdir() + try: + return write_bytes(directory, relative, payload, **kwargs) + finally: + path.rmdir() + path.write_bytes(previous_bytes) with monkeypatch.context() as patch: - patch.setattr(saved, "_write_prepared_scan_finalization", fail_before_publication) + patch.setattr(finalize_scan_contract, "write_scan_local_bytes", fail_coverage_write) workbench_api["fail_scan"]( workbench_db, Namespace( @@ -149,8 +280,22 @@ def fail_before_publication(*args, **kwargs): ), ) row = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + assert len(failed_writes) == 1 + assert "scanId" in failed_writes[0] + assert row["status"] == "failed" + assert row["failure_message"] == "Audit stopped." assert row["retained_source_digests_json"] assert row["seal_manifest_digest"] is None + assert all( + (scan.scan_dir / name).read_bytes() == contents + for name, contents in original_outputs.items() + ) + original_sources = row["retained_source_digests_json"] + original_run = dict( + workbench_db.execute( + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan.scan_id,) + ).fetchone() + ) head = result.parent / "checkpoint-head.json" if head_change == "replaced": save_disposition(scan, result.parent, "reported") @@ -169,6 +314,21 @@ def fail_before_publication(*args, **kwargs): assert replayed["findingCount"] == 0 assert json.loads((scan.scan_dir / "findings.json").read_text())["findings"] == [] assert json.loads(result.read_text()) == previous + row = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + assert row["failure_message"] == "Audit stopped." + assert row["retained_source_digests_json"] == original_sources + assert json.loads(row["retained_checkpoint_heads_json"]) == expected_heads + assert row["seal_manifest_digest"] + manifest = json.loads((scan.scan_dir / "scan-manifest.json").read_text()) + assert manifest["scan"]["preservedCheckpointHeads"] == expected_heads + assert ( + dict( + workbench_db.execute( + "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan.scan_id,) + ).fetchone() + ) + == original_run + ) def test_explicit_recovery_observes_head_change_between_existing_checkpoints( From dac731e8f94e77bd7631e5e82f634dfdacd02f78 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 05:58:26 +0000 Subject: [PATCH 143/251] test(plugin): assert completion coverage fixture branches --- .../codex-security/tests/test_workbench_completion_binding.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/codex-security/tests/test_workbench_completion_binding.py b/plugins/codex-security/tests/test_workbench_completion_binding.py index e51af0b7e0..357cf0044d 100644 --- a/plugins/codex-security/tests/test_workbench_completion_binding.py +++ b/plugins/codex-security/tests/test_workbench_completion_binding.py @@ -86,6 +86,8 @@ def test_deep_completion_returns_sealed_coverage_on_replay( coverage_path.write_text(json.dumps(coverage)) completed = run_workbench(state_dir, "complete-scan", "--scan-id", scan_id) canonical = json.loads(coverage_path.read_text()) + assert canonical["completeness"] == completeness + assert len(canonical["deferred"]) == (1 if completeness == "partial" else 0) expected = { "completeness": canonical["completeness"], "surfaceCount": len(canonical["surfaces"]), From bbc3b23c5dc96f2dc8a0fa618fd7b5106ea612a2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 08:16:18 +0000 Subject: [PATCH 144/251] Preserve coverage rejection reasons in reports Render canonical surface reasons alongside distinct notes, while keeping existing note formatting and evidence references. Exercise reason-only and combined metadata through accepted budget-completion artifacts. --- .../scripts/report_projection.py | 6 ++++- .../tests/test_budget_coverage_provenance.py | 15 +++++++++--- .../tests/test_report_projection.py | 24 ++++++++++++++++--- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/plugins/codex-security/scripts/report_projection.py b/plugins/codex-security/scripts/report_projection.py index cc404e5f8f..a4e3638570 100644 --- a/plugins/codex-security/scripts/report_projection.py +++ b/plugins/codex-security/scripts/report_projection.py @@ -513,7 +513,11 @@ def _target_scope_lines(target: dict[str, Any]) -> list[str]: def _surface_notes(surface: dict[str, Any]) -> str: - notes = surface.get("notes", "No additional canonical notes were recorded.") + notes = surface.get( + "notes", surface.get("reason", "No additional canonical notes were recorded.") + ) + if "notes" in surface and "reason" in surface and surface["reason"] != notes: + notes += f" {surface['reason']}" source = _coverage_source(surface) if source: notes = f"{source}. {notes}" diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py index 479c45bbcf..d713179d42 100644 --- a/plugins/codex-security/tests/test_budget_coverage_provenance.py +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -24,12 +24,18 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( "label": "Filesystem boundary", "disposition": "needs_follow_up", "notes": "Filesystem race remains untested.", + "reason": "The caller's filesystem policy is unknown.", }, { "label": "Template boundary", "disposition": "needs_follow_up", "notes": "Template caller policy remains untested.", }, + { + "label": "Configuration boundary", + "disposition": "rejected", + "reason": "The input comes only from trusted application configuration.", + }, ] receipts = {} for index, surface in enumerate(surfaces, start=1): @@ -106,8 +112,8 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) assert coverage["completeness"] == "partial" actual = coverage["surfaces"] - assert [(item["label"], item["notes"]) for item in actual] == [ - (item["label"], item["notes"]) for item in surfaces + assert [(item["label"], item.get("notes"), item.get("reason")) for item in actual] == [ + (item["label"], item.get("notes"), item.get("reason")) for item in surfaces ] assert len({item["id"] for item in actual}) == len(surfaces) for original, retained in zip(surfaces, actual, strict=True): @@ -126,7 +132,10 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( assert obligation["surfaceIds"] == [item["id"] for item in actual] assert {"workerId": worker_id, "attempt": 1, "completeness": "partial"} in coverage["reviews"] report = (scan.scan_dir / "report.md").read_text() - assert all(surface["notes"] in report for surface in surfaces) + for surface in surfaces: + for field in ("notes", "reason"): + if field in surface: + assert surface[field] in report assert deferred["reason"] in report assert json.loads((scan.scan_dir / "findings.json").read_text())["findings"] == [] assert tuple(workbench_db.execute(counter_query).fetchone()) == counters diff --git a/plugins/codex-security/tests/test_report_projection.py b/plugins/codex-security/tests/test_report_projection.py index 47495d7d54..ecaf7821f9 100644 --- a/plugins/codex-security/tests/test_report_projection.py +++ b/plugins/codex-security/tests/test_report_projection.py @@ -890,7 +890,22 @@ def test_projection_keeps_deferred_follow_up_with_open_questions() -> None: assert "Surfaces: parser-surface." in markdown -def test_projection_includes_surface_evidence_receipts() -> None: +@pytest.mark.parametrize( + ("details", "expected"), + [ + ({"notes": "Reviewed parser entrypoints."}, "Reviewed parser entrypoints."), + ({"reason": "Caller policy is unknown."}, "Caller policy is unknown."), + ( + {"notes": "Reviewed parser entrypoints.", "reason": "Caller policy is unknown."}, + "Reviewed parser entrypoints. Caller policy is unknown.", + ), + ({"notes": "Policy checked.", "reason": "Policy checked."}, "Policy checked."), + ({"notes": ""}, ""), + ({"notes": " \t "}, ""), + ({}, "No additional canonical notes were recorded."), + ], +) +def test_projection_includes_surface_evidence_receipts(details: dict, expected: str) -> None: manifest, findings, coverage = canonical_documents() coverage["surfaces"] = [ { @@ -898,10 +913,13 @@ def test_projection_includes_surface_evidence_receipts() -> None: "label": "Parser", "disposition": "no_issue_found", "receiptRefs": ["artifacts/receipts/parser.jsonl"], - "notes": "Reviewed parser entrypoints.", + **details, } ] markdown = PROJECTION.build_report_markdown(manifest, findings, coverage) - assert "Reviewed parser entrypoints. Evidence: artifacts/receipts/parser.jsonl" in markdown + row = next(line for line in markdown.splitlines() if "artifacts/receipts/parser.jsonl" in line) + assert row.endswith( + f"| {expected + ' ' if expected else ''}Evidence: artifacts/receipts/parser.jsonl |" + ) From 8e67e0769a37a6425ba1607f466bb0ec5c39cada Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 09:29:01 +0000 Subject: [PATCH 145/251] test: retain frozen checkpoint migration state checks --- .../tests/test_checkpoint_schema.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 plugins/codex-security/tests/test_checkpoint_schema.py diff --git a/plugins/codex-security/tests/test_checkpoint_schema.py b/plugins/codex-security/tests/test_checkpoint_schema.py new file mode 100644 index 0000000000..bb471267cb --- /dev/null +++ b/plugins/codex-security/tests/test_checkpoint_schema.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +import sqlite3 +from contextlib import closing + +import pytest + + +@pytest.mark.parametrize("upgrade", [False, True], ids=["fresh", "upgrade"]) +def test_frozen_checkpoint_head_migration_preserves_scan_state(workbench_api, upgrade): + migrations = workbench_api["MIGRATIONS"] + timestamp = "2026-07-01T00:00:00Z" + + def migrate(connection, selected): + workbench_api["apply_schema_migrations"]( + connection, selected, lambda: timestamp, workbench_api["backfill_security_targets"] + ) + + with closing(sqlite3.connect(":memory:")) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + migrate( + connection, + tuple(item for item in migrations if item[0] != 47) if upgrade else migrations, + ) + connection.execute( + "INSERT INTO workspaces (id, created_at, updated_at) VALUES (?, ?, ?)", + ("workspace", timestamp, timestamp), + ) + for status in ("running", "failed"): + connection.execute( + "INSERT INTO scans (id, workspace_id, target_path, target_revision, scope, " + "mode, scan_dir, status, phase, started_at, created_at, updated_at, " + "failure_message, retained_source_digests_json) " + "VALUES (?, 'workspace', 'target', 'revision', '.', 'deep', ?, ?, " + "'discovery', ?, ?, ?, ?, ?)", + ( + status, + f"scans/{status}", + status, + timestamp, + timestamp, + timestamp, + "Original stop reason." if status == "failed" else None, + json.dumps({"workers/review/result.json": "b" * 64}) + if status == "failed" + else None, + ), + ) + before = [dict(row) for row in connection.execute("SELECT * FROM scans ORDER BY id")] + migrate(connection, migrations) + after = [dict(row) for row in connection.execute("SELECT * FROM scans ORDER BY id")] + for original, updated in zip(before, after, strict=True): + assert updated.pop("retained_checkpoint_heads_json") is None + original.pop("retained_checkpoint_heads_json", None) + assert updated == original + + heads = json.dumps({"workers/review": "workers/review/checkpoints/" + "a" * 64 + ".json"}) + connection.execute( + "UPDATE scans SET retained_checkpoint_heads_json = ? WHERE id = 'failed'", (heads,) + ) + migrate(connection, migrations) + assert ( + connection.execute( + "SELECT retained_checkpoint_heads_json FROM scans WHERE id = 'failed'" + ).fetchone()[0] + == heads + ) + assert ( + connection.execute( + "SELECT retained_checkpoint_heads_json FROM scans WHERE id = 'running'" + ).fetchone()[0] + is None + ) + assert [ + tuple(row) + for row in connection.execute( + "SELECT version, name FROM schema_migrations WHERE version = 47" + ) + ] == [(47, "freeze stopped scan checkpoint selections")] + assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] From 90748242d2e0601d2d73f8cde77ac8c06d7853fb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 09:59:02 +0000 Subject: [PATCH 146/251] Preserve coverage source descriptions during projection --- .../src/deep-scan/artifact-validation.ts | 16 +++++-- .../tests/deep_scan_coverage_fixture.mjs | 6 +++ .../test_deep_scan_coverage_descriptions.mjs | 48 +++++++++++++++++++ .../scripts/workbench_saved_results.py | 7 +++ .../tests/test_budget_coverage_provenance.py | 20 +++++++- 5 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_coverage_descriptions.mjs diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index 099add31bd..48abc1bf1b 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -254,14 +254,20 @@ export function projectDiscoveryCoverage( const prefix = `${worker.id}-attempt-${worker.attempt ?? "unknown"}`; const surfaces = coverage.surfaces as Record[]; const surfaceIds = new Map(surfaces.map((surface, index) => [surface.id, `${prefix}-surface-${index + 1}`])); - const project = (item: Record) => ({ - ...structuredClone(item), - provenance: { + const project = (item: Record) => { + const projected = structuredClone(item); + const sourceProvenance = typeof projected.provenance === "object" && projected.provenance !== null + && !Array.isArray(projected.provenance) ? projected.provenance as Record : {}; + // Preserve source descriptions while deriving identity only from the accepted owner. + for (const key of ["workerId", "attempt", "sourceId", "candidateId"]) delete sourceProvenance[key]; + projected.provenance = { + ...sourceProvenance, ...provenance, ...(item.id === undefined ? {} : { sourceId: item.id }), ...(item.candidateId === undefined ? {} : { candidateId: item.candidateId }), - }, - }); + }; + return projected; + }; return { completeness: coverage.completeness, reviews: [{ ...provenance, completeness: coverage.completeness }], diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index 05cfb0b3ab..a0af00fa9b 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -34,6 +34,7 @@ export async function publishCoverageFixture(root, completeness, { legacyAttempts = false, splitSeededReducers = false, selectedRecovery = false, + sourceProvenance, } = {}) { const runtimePath = path.join(root, "fixture-runtime.mjs"); await writeFile(runtimePath, bundled.outputFiles[0].contents); @@ -101,6 +102,11 @@ export async function publishCoverageFixture(root, completeness, { deferred: pending ? [{ id: "same-id", candidateId: "candidate-1", reason: index === 0 ? "Verify entry boundaries." : "Verify symbolic links.", paths: ["source.py"], surfaceIds: ["shared-surface"] }] : [], openQuestions: pending ? [{ question: `Deployment question ${index + 1}.` }] : [], }; + if (sourceProvenance !== undefined) { + for (const field of ["surfaces", "explicitExclusions", "deferred", "openQuestions"]) { + for (const item of coverage[field]) item.provenance = structuredClone(sourceProvenance); + } + } await mkdir(path.join(artifactDir, "artifacts"), { recursive: true }); await writeFile(path.join(artifactDir, "artifacts", "review.md"), "Synthetic review evidence.\n"); const resultPath = path.join(artifactDir, "result.json"); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coverage_descriptions.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_coverage_descriptions.mjs new file mode 100644 index 0000000000..00acf28083 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coverage_descriptions.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { publishCoverageFixture } from "./deep_scan_coverage_fixture.mjs"; + +const descriptions = { source: "source-review", worker: "focused-reviewer", task: "Check the filesystem boundary." }; +const sourceProvenance = { + ...descriptions, + workerId: "worker-local-label", attempt: 99, sourceId: "worker-local-source", candidateId: "worker-local-candidate", +}; + +for (const options of [ + {}, + { resume: true }, + { resume: true, continueAfterResume: true }, + { resume: true, selectedRecovery: true, discardMutableResults: true }, + { resume: true, selectedRecovery: true, discardMutableResults: true, splitSeededReducers: true }, +]) { + test(`coverage source descriptions survive publication and recovery: ${JSON.stringify(options)}`, async () => { + const root = await mkdtemp(path.join(tmpdir(), "deep-coverage-descriptions-")); + try { + const fixture = path.join(root, "fixture"); + await mkdir(fixture, { mode: 0o700 }); + const { scanDir } = await publishCoverageFixture(fixture, "partial", { + ...options, immutableInputs: true, materialFindings: true, sourceProvenance, + }); + const coverage = JSON.parse(await readFile(path.join(scanDir, "coverage.json"), "utf8")); + assert.equal(coverage.completeness, "partial"); + assert.equal(new Set(coverage.deferred.map((item) => item.candidateId)).size, 2); + for (const field of ["surfaces", "explicitExclusions", "deferred", "openQuestions"]) { + assert.ok(coverage[field].length > 0); + for (const item of coverage[field]) { + const review = coverage.reviews.find((review) => review.workerId === item.provenance.workerId); + assert.ok(review, "host identity names an actual independent review"); + assert.deepEqual(item.provenance, { + ...descriptions, workerId: review.workerId, attempt: review.attempt, + ...(field === "surfaces" ? { sourceId: "shared-surface" } : {}), + ...(field === "deferred" ? { sourceId: "same-id", candidateId: "candidate-1" } : {}), + }); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 5d69234a1d..024ba81bf7 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1457,7 +1457,14 @@ def retain(field: str, item: dict[str, Any]) -> None: for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions"): for index, original in enumerate(source.get(field, [])): item = copy.deepcopy(original if isinstance(original, dict) else {"question": original}) + source_provenance = item.get("provenance") + if not isinstance(source_provenance, dict): + source_provenance = {} + # Keep source descriptions; the accepted owner supplies identity. + for key in ("workerId", "attempt", "sourceId", "candidateId"): + source_provenance.pop(key, None) item["provenance"] = { + **source_provenance, **provenance, **({"sourceId": item["id"]} if "id" in item else {}), **({"candidateId": item["candidateId"]} if "candidateId" in item else {}), diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py index d713179d42..a1925c6c98 100644 --- a/plugins/codex-security/tests/test_budget_coverage_provenance.py +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -19,6 +19,18 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( worker_id = add_worker(workbench_db, scan).parent.name output = scan.scan_dir / "artifacts/deep_discovery/workers/discovery-0001/output" output.mkdir(parents=True) + descriptions = { + "source": "source-review", + "worker": "focused-reviewer", + "task": "Check the filesystem boundary.", + } + source_provenance = { + **descriptions, + "workerId": "worker-local-label", + "attempt": 99, + "sourceId": "worker-local-source", + "candidateId": "worker-local-candidate", + } surfaces = [ { "label": "Filesystem boundary", @@ -39,6 +51,7 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( ] receipts = {} for index, surface in enumerate(surfaces, start=1): + surface["provenance"] = source_provenance if explicit_ids: surface["id"] = f"source-surface-{index}" relative = f"artifacts/review-{index}.md" @@ -47,7 +60,10 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( receipt.parent.mkdir(exist_ok=True) receipts[receipt] = f"Independent review receipt {index}.\n".encode() receipt.write_bytes(receipts[receipt]) - deferred = {"reason": "Both independent validation tasks remain unfinished."} + deferred = { + "reason": "Both independent validation tasks remain unfinished.", + "provenance": source_provenance, + } if explicit_ids: deferred["surfaceIds"] = [surface["id"] for surface in surfaces] contents = json.dumps( @@ -118,6 +134,7 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( assert len({item["id"] for item in actual}) == len(surfaces) for original, retained in zip(surfaces, actual, strict=True): assert retained["provenance"] == { + **descriptions, "workerId": worker_id, "attempt": 1, **({"sourceId": original["id"]} if explicit_ids else {}), @@ -128,6 +145,7 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( for path, receipt_contents in receipts.items(): assert path.read_bytes() == receipt_contents obligation = next(item for item in coverage["deferred"] if item["reason"] == deferred["reason"]) + assert obligation["provenance"] == {**descriptions, "workerId": worker_id, "attempt": 1} if explicit_ids: assert obligation["surfaceIds"] == [item["id"] for item in actual] assert {"workerId": worker_id, "attempt": 1, "completeness": "partial"} in coverage["reviews"] From a603a9181a981972df7aff1e4f30fd6dfa424acc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 11:59:12 +0000 Subject: [PATCH 147/251] Preserve coverage identity when replaying budget drafts --- .../scripts/workbench_saved_results.py | 15 ++++ .../tests/test_budget_coverage_provenance.py | 84 +++++++++++++++++-- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 024ba81bf7..a7a1e39bd8 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1451,6 +1451,21 @@ def retain(field: str, item: dict[str, Any]) -> None: # A committed budget draft can be replayed before the scan is sealed. # These IDs and provenance identify the same immutable accepted review. items = coverage.setdefault(field, []) + if "id" in item: + for index, existing in enumerate(items): + existing_provenance = ( + existing.get("provenance") if isinstance(existing, dict) else None + ) + if ( + isinstance(existing_provenance, dict) + and existing.get("id") == item["id"] + and all( + existing_provenance.get(key) == value for key, value in provenance.items() + ) + ): + # Refresh an older projection from the same accepted bytes. + items[index] = item + return if item not in items: items.append(item) diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py index a1925c6c98..72fbbcc316 100644 --- a/plugins/codex-security/tests/test_budget_coverage_provenance.py +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -2,6 +2,7 @@ import hashlib import json +import sqlite3 from argparse import Namespace import pytest @@ -12,8 +13,15 @@ @pytest.mark.parametrize("explicit_ids", [True, False], ids=["named-surfaces", "omitted-ids"]) +@pytest.mark.parametrize("legacy_replay", [False, True], ids=["fresh", "legacy-draft-replay"]) def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( - workbench_api, workbench_db, publication_scan, explicit_ids + workbench_api, + workbench_db, + publication_scan, + explicit_ids, + legacy_replay, + tmp_path, + monkeypatch, ): scan = publication_scan() worker_id = add_worker(workbench_db, scan).parent.name @@ -76,6 +84,19 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( "completeness": "partial", "surfaces": surfaces, "deferred": [deferred], + "explicitExclusions": [ + { + "pattern": "vendor/", + "reason": "External dependencies were excluded.", + "provenance": source_provenance, + } + ], + "openQuestions": [ + { + "question": "Which deployment controls apply?", + "provenance": source_provenance, + } + ], }, } ).encode() @@ -115,14 +136,53 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( for name in ("scan-manifest.json", "findings.json", "coverage.json"): (scan.scan_dir / name).unlink() - workbench_api["complete_budget_exhausted_scan"]( - workbench_db, - Namespace( - scan_id=scan.scan_id, - cost_json=json.dumps(BUDGET_COST), - message="Scan reached its original cost limit.", - ), + budget = workbench_api["complete_budget_exhausted_scan"] + args = Namespace( + scan_id=scan.scan_id, + cost_json=json.dumps(BUDGET_COST), + message="Scan reached its original cost limit.", ) + if legacy_replay: + saved = budget.__globals__["saved_results"] + retain = saved.retain_unmerged_budget_coverage + + def legacy_projection(*args): + retain(*args) + # Older writers kept host identity but omitted source descriptions. + for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions"): + for item in args[2].get(field, []): + if item.get("provenance", {}).get("workerId") == worker_id: + for key in descriptions: + item["provenance"].pop(key, None) + + def interrupt_before_seal(*args): + raise RuntimeError("Publication interrupted after the budget draft committed.") + + database = tmp_path / "interrupted-budget.sqlite3" + with sqlite3.connect(database) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + workbench_db.backup(connection) + with monkeypatch.context() as patch: + patch.setattr(saved, "retain_unmerged_budget_coverage", legacy_projection) + patch.setitem(budget.__globals__, "complete_scan_locked", interrupt_before_seal) + with pytest.raises(RuntimeError, match="budget draft committed"): + budget(connection, args) + # Reconnect to the committed old draft and run the real public completion. + with sqlite3.connect(database) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + assert ( + connection.execute("SELECT status FROM deep_scan_runs").fetchone()[0] == "succeeded" + ) + assert connection.execute("SELECT status FROM scans").fetchone()[0] == "running" + assert ( + connection.execute("SELECT seal_manifest_digest FROM scans").fetchone()[0] is None + ) + budget(connection, args) + connection.backup(workbench_db) + else: + budget(workbench_db, args) assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) @@ -146,6 +206,14 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( assert path.read_bytes() == receipt_contents obligation = next(item for item in coverage["deferred"] if item["reason"] == deferred["reason"]) assert obligation["provenance"] == {**descriptions, "workerId": worker_id, "attempt": 1} + assert sum(item["reason"] == deferred["reason"] for item in coverage["deferred"]) == 1 + for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions"): + assert len({item["id"] for item in coverage[field]}) == len(coverage[field]) + for field in ("explicitExclusions", "openQuestions"): + assert len(coverage[field]) == 1 + item = coverage[field][0] + assert item["id"] == f"{worker_id}-attempt-1-{field}-1" + assert item["provenance"] == {**descriptions, "workerId": worker_id, "attempt": 1} if explicit_ids: assert obligation["surfaceIds"] == [item["id"] for item in actual] assert {"workerId": worker_id, "attempt": 1, "completeness": "partial"} in coverage["reviews"] From 4f044158ad63f3f028b3f18d0e7473a5275923f7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 12:11:19 +0000 Subject: [PATCH 148/251] Recover repeated coverage projections after interruption --- .../scripts/workbench_saved_results.py | 12 +++++++++--- .../tests/test_budget_coverage_provenance.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index a7a1e39bd8..f78d0fd622 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1452,6 +1452,7 @@ def retain(field: str, item: dict[str, Any]) -> None: # These IDs and provenance identify the same immutable accepted review. items = coverage.setdefault(field, []) if "id" in item: + matches = [] for index, existing in enumerate(items): existing_provenance = ( existing.get("provenance") if isinstance(existing, dict) else None @@ -1463,9 +1464,14 @@ def retain(field: str, item: dict[str, Any]) -> None: existing_provenance.get(key) == value for key, value in provenance.items() ) ): - # Refresh an older projection from the same accepted bytes. - items[index] = item - return + matches.append(index) + if matches: + # Refresh older projections from the same accepted bytes, even + # if an interrupted writer saved more than one copy. + items[matches[0]] = item + for index in reversed(matches[1:]): + del items[index] + return if item not in items: items.append(item) diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py index 72fbbcc316..4ce4607264 100644 --- a/plugins/codex-security/tests/test_budget_coverage_provenance.py +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import hashlib import json import sqlite3 @@ -13,13 +14,13 @@ @pytest.mark.parametrize("explicit_ids", [True, False], ids=["named-surfaces", "omitted-ids"]) -@pytest.mark.parametrize("legacy_replay", [False, True], ids=["fresh", "legacy-draft-replay"]) +@pytest.mark.parametrize("replay", ["fresh", "legacy", "duplicated"]) def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( workbench_api, workbench_db, publication_scan, explicit_ids, - legacy_replay, + replay, tmp_path, monkeypatch, ): @@ -142,7 +143,7 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( cost_json=json.dumps(BUDGET_COST), message="Scan reached its original cost limit.", ) - if legacy_replay: + if replay != "fresh": saved = budget.__globals__["saved_results"] retain = saved.retain_unmerged_budget_coverage @@ -150,8 +151,15 @@ def legacy_projection(*args): retain(*args) # Older writers kept host identity but omitted source descriptions. for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions"): - for item in args[2].get(field, []): + items = args[2].get(field, []) + for item in list(items): if item.get("provenance", {}).get("workerId") == worker_id: + if replay == "duplicated" and any( + key in item["provenance"] for key in descriptions + ): + # A second interrupted writer appended the described + # projection beside the old item before sealing. + items.append(copy.deepcopy(item)) for key in descriptions: item["provenance"].pop(key, None) From 8df806d2e6b2a1f9d28f71466942fe796b02d1a2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 13:42:28 +0000 Subject: [PATCH 149/251] Validate Deep Scan writeup files before accepting results --- .../src/deep-scan/artifact-validation.ts | 27 +++++++++- .../test_deep_scan_artifact_validation.mjs | 52 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index 48abc1bf1b..e6143a84a7 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { parsePersistedScanDraft, @@ -90,9 +91,28 @@ export async function readDiscoveryAuditDraft( expectedScanId, parsePersistedScanDraft ); + if (result.complete !== false) await validateWriteupFiles(artifacts, result); return result; } +async function validateWriteupFiles( + artifacts: DeepScanArtifacts, + result: Pick, +): Promise { + for (const [index, finding] of result.findings.entries()) { + const reportPath = (finding.writeup as { reportPath: string } | undefined)?.reportPath; + if (!reportPath) continue; + try { + await requireRegularFile(join(artifacts.scanDir, reportPath), artifacts.scanDir, false); + } catch (cause) { + throw new Error( + `findings[${index}].writeup.reportPath: expected a file inside the scan directory: ${reportPath}`, + { cause }, + ); + } + } +} + /** Validate the complete aggregate and derive convergence from stable finding identities. */ export async function validateReducerArtifacts(input: { artifacts: DeepScanArtifacts; @@ -133,11 +153,14 @@ export async function validateReducerArtifacts(input: { if (input.sources) { result = reconcileDeepReduction(result, input.sources.discoveries, input.sources.previous); + } else { + validateRetainedFindings(result, [], previous); + } + await validateWriteupFiles(artifacts, result); + if (input.sources) { const persisted = deepReductionForPersistence(result, input.persistSourceCoverage); await saveScanDraftCheckpoint({ root: artifactDir, repoRoot: artifacts.scanDir, layout: "reducer" }, persisted); await writeJsonAtomic(resultPath, persisted); - } else { - validateRetainedFindings(result, [], previous); } const previousFindingIds = new Set((previous?.findings ?? []).map(scanFindingIdentity)); return { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.mjs index 74ad7e7554..79c0e7bd88 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.mjs @@ -20,6 +20,7 @@ const bundle = await build({ write: false }); const { + readDiscoveryAuditDraft, validateDiscoveryArtifacts, validateReducerArtifacts } = await import( @@ -34,12 +35,63 @@ try { await testDiscoveryValidation(root); await testReducerValidation(root); await testEmptyDiscoveryAndReduction(root); + await testWriteupReferences(root); } finally { await rm(root, { recursive: true, force: true }); } console.log("deep scan artifact validation tests passed"); +async function testWriteupReferences(root) { + const artifacts = await createLayout(path.join(root, "writeups")); + const reportPath = "findings/example/example.md"; + const result = draft([{ + ...finding("example", "src/a.js"), + writeup: { reportPath }, + }]); + const worker = await createWorker(artifacts, "discovery-0001", "worker-001", result); + const artifactDir = path.join(artifacts.dedupRoot, "dedup-0001", "output"); + const resultPath = path.join(artifactDir, "result.json"); + await mkdir(artifactDir, { recursive: true }); + const { coverage: _coverage, ...reduction } = result; + await writeResult(resultPath, reduction); + const validateDiscovery = () => validateDiscoveryArtifacts(artifacts, worker.resultPath, scanId); + const validateReducer = () => validateReducerArtifacts({ + artifacts, artifactDir, resultPath, reducerId: "dedup-0001", + sources: { discoveries: [{ workerId: worker.id, result: reduction }], previous: null }, + }, scanId); + + for (const validate of [validateDiscovery, validateReducer]) { + await assert.rejects(validate(), /findings\[0\]\.writeup\.reportPath/); + } + assert.deepEqual(JSON.parse(await readFile(resultPath, "utf8")), reduction, + "rejected results remain available as original attempt evidence"); + + await writeResult(worker.resultPath, { ...result, complete: false }); + assert.equal((await readDiscoveryAuditDraft(artifacts, worker.resultPath, scanId)).complete, false); + await writeResult(worker.resultPath, result); + + const workerReport = path.join(path.dirname(worker.resultPath), reportPath); + await mkdir(path.dirname(workerReport), { recursive: true }); + await writeFile(workerReport, "# Worker-local report\n"); + await assert.rejects(validateDiscovery(), /writeup\.reportPath/, + "publication resolves report paths from the parent scan directory"); + + const parentReport = path.join(artifacts.scanDir, reportPath); + await mkdir(path.dirname(parentReport), { recursive: true }); + await writeFile(parentReport, ""); + assert.deepEqual((await validateDiscovery()).findings[0].writeup, { reportPath }); + assert.deepEqual((await validateReducer()).result.findings[0].writeup, { reportPath }, + "valid references are retained, including empty regular files accepted by publication"); + + if (process.platform !== "win32") { + await rm(parentReport); + await symlink(workerReport, parentReport); + await assert.rejects(validateDiscovery(), /writeup\.reportPath/); + await assert.rejects(validateReducer(), /writeup\.reportPath/); + } +} + async function testDiscoveryValidation(root) { const artifacts = await createLayout(path.join(root, "discovery")); const result = draft([finding("shared", "src/a.js")], { From 005f5bdb6d2f2e7caf45bf9a47ecb2f354848016 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 21:32:02 +0000 Subject: [PATCH 150/251] Stream plain saved-logs JSON before aggregate formatting --- sdk/typescript/src/cli-scan-logs-json.ts | 26 ++ sdk/typescript/src/cli.ts | 33 ++- sdk/typescript/tests-ts/cli-scan-logs.test.ts | 265 ++++++++++++++++++ .../tests-ts/support/cli-logs-large.mts | 92 ++++++ 4 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 sdk/typescript/src/cli-scan-logs-json.ts create mode 100644 sdk/typescript/tests-ts/cli-scan-logs.test.ts create mode 100644 sdk/typescript/tests-ts/support/cli-logs-large.mts diff --git a/sdk/typescript/src/cli-scan-logs-json.ts b/sdk/typescript/src/cli-scan-logs-json.ts new file mode 100644 index 0000000000..ea9daa066e --- /dev/null +++ b/sdk/typescript/src/cli-scan-logs-json.ts @@ -0,0 +1,26 @@ +import { Formatter } from "incur"; +import type { readSavedScanLogs } from "./scan-logs.js"; + +// Format records separately: a saved log document can exceed the runtime's +// maximum string length even though each session and event fits comfortably. +export async function* scanLogsJson( + logs: Awaited>, +): AsyncGenerator { + yield Buffer.from( + `{\n "scanId": ${JSON.stringify(logs.scanId)},\n "threadId": ${JSON.stringify(logs.threadId)}`, + ); + for (const key of ["sessions", "events"] as const) { + yield Buffer.from(`,\n "${key}": [`); + let first = true; + for (const record of logs[key]) { + const formatted = Formatter.format(record, "json").replaceAll( + "\n", + "\n ", + ); + yield Buffer.from(`${first ? "\n" : ",\n"} ${formatted}`); + first = false; + } + yield Buffer.from(first ? "]" : "\n ]"); + } + yield Buffer.from("\n}\n"); +} diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 9c00f32afc..e877930f25 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -45,6 +45,7 @@ import { pipeline } from "node:stream/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify, stripVTControlCharacters } from "node:util"; import { Cli, z } from "incur"; +import { scanLogsJson } from "./cli-scan-logs-json.js"; import { parse as parseToml } from "smol-toml"; import { classifyConnectionFailure, @@ -1773,6 +1774,7 @@ export async function main( let exitCode = 0; let frameworkExit: number | undefined; let frameworkOutput = ""; + let streamedLogs: AsyncIterable | undefined; let renderedHistory: string | undefined; let renderedPublication: string | undefined; let renderedPolicy: string | undefined; @@ -2120,18 +2122,33 @@ export async function main( .describe("Scan identifier or unique prefix (default: latest)."), }), output: z.record(z.string(), z.unknown()).optional(), - async run({ args }) { + async run({ args, format }) { const scanId = args.scanId ?? (await latestScans(1, "any"))?.[0]?.scanId; if (scanId === undefined) return; - return await history( + const result = await history( ["get-scan", "--scan-id", scanId], - async (value) => - (await readSavedScanLogs( + async (value) => { + const logs = await readSavedScanLogs( value["scan"] as ScanLogSource, codexSecurityCredentialHome(dependencies.environment), - )) as unknown as JsonObject, + ); + // Incur owns filtering, envelopes and token controls. Keep those + // requests on its formatter; plain JSON needs no aggregate string. + if ( + format === "json" && + !argv.some((argument) => + /^--(?:filter-output|full-output|token-count|token-limit|token-offset)(?:=|$)/u.test( + argument, + ), + ) + ) { + streamedLogs = scanLogsJson(logs); + } + return logs as unknown as JsonObject; + }, ); + return streamedLogs === undefined ? result : undefined; }, }) .command("resume", { @@ -5682,11 +5699,13 @@ export async function main( return 2; } } - if (frameworkOutput.length === 0) return exitCode; + if (frameworkOutput.length === 0 && streamedLogs === undefined) + return exitCode; try { await writeCliOutput( output, - renderedPolicy ?? + streamedLogs ?? + renderedPolicy ?? renderedPatch ?? renderedPublication ?? renderedHistory ?? diff --git a/sdk/typescript/tests-ts/cli-scan-logs.test.ts b/sdk/typescript/tests-ts/cli-scan-logs.test.ts new file mode 100644 index 0000000000..f57a4675af --- /dev/null +++ b/sdk/typescript/tests-ts/cli-scan-logs.test.ts @@ -0,0 +1,265 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Writable } from "node:stream"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; +import { describe, expect, test } from "bun:test"; +import { Cli, Formatter, z } from "incur"; +import { main } from "../src/cli.js"; +import { scanLogsJson } from "../src/cli-scan-logs-json.js"; +import { readSavedScanLogs } from "../src/scan-logs.js"; +import { VERSION } from "../src/version.js"; +import { capture, dependencies } from "./cli-fixtures.js"; + +async function fixture() { + const state = await realpath(await mkdtemp(join(tmpdir(), "saved-logs-"))); + const home = join(state, "codex-home"); + await mkdir(join(home, "sessions"), { recursive: true }); + const events = [ + { type: "session_meta", payload: { id: "thread-1" } }, + { + type: "event_msg", + payload: { + message: 'full values: \" \\ \n \u0000 😀', + nested: [null, false, 2], + }, + }, + ]; + await writeFile( + join(home, "sessions", "rollout.jsonl"), + events.map((event) => JSON.stringify(event)).join("\n"), + ); + const scan = { scanId: "scan-1", continuationThreadId: "thread-1" }; + const logs = await readSavedScanLogs(scan, home); + const deps = dependencies({ + environment: { CODEX_SECURITY_STATE_DIR: state }, + onWorkbench: () => ({ scan }), + }); + deps.createSecurity = () => { + throw new Error("Reading logs must not start Codex"); + }; + return { state, logs, deps }; +} + +async function referenceOutput(args: string[], logs: unknown) { + let text = ""; + await Cli.create("codex-security", { version: VERSION, update: false }) + .command( + Cli.create("scans").command("logs", { + args: z.object({ scanId: z.string() }), + run: () => logs, + }), + ) + .serve(["scans", "logs", "scan-1", ...args], { + stdout: (value) => { + text += value; + }, + }); + return text; +} + +function withoutDuration(text: string) { + const value = JSON.parse(text); + value.meta.duration = "duration"; + return value; +} + +describe("saved logs JSON output", () => { + test.each( + [ + [], + ["--json"], + ["--format", "json"], + ["--format=json"], + ["--format", "toon"], + ["--format", "jsonl"], + ["--format", "yaml"], + ["--format", "md"], + ["--json", "--format", "toon"], + ["--format", "toon", "--json"], + ["--json", "--filter-output", "scanId"], + ["--json", "--filter-output", "events[0,1]"], + ["--json", "--filter-output", "missing"], + ["--json", "--filter-output", "scanId,threadId"], + ["--json", "--token-count"], + ["--json", "--token-limit", "4"], + ["--json", "--token-offset", "1", "--token-limit", "4"], + ["--json", "--token-offset", "999999"], + ["--json", "--token-limit", "999999"], + ["--json", "--full-output"], + ["--json", "--full-output", "--filter-output", "scanId"], + ["--json", "--full-output", "--token-limit", "4"], + ["--json", "--full-output", "--token-count"], + ].map((args) => [args]), + )("preserves Incur output for %j", async (args) => { + const f = await fixture(); + try { + const stdout = capture(); + const stderr = capture(); + expect( + await main( + ["scans", "logs", "scan-1", ...args], + stdout.stream, + stderr.stream, + f.deps, + ), + ).toBe(0); + const expected = await referenceOutput( + args.flatMap((arg) => + arg === "--format=json" ? ["--format", "json"] : [arg], + ), + f.logs, + ); + if (args.includes("--full-output") && !args.includes("--token-count")) { + expect(withoutDuration(stdout.text())).toEqual( + withoutDuration(expected), + ); + } else { + expect(stdout.text()).toBe(expected); + } + expect(stderr.text()).toBe(""); + } finally { + await rm(f.state, { recursive: true, force: true }); + } + }); + + test.each( + [ + ["--format", "invalid"], + ["--format=invalid"], + ["--json", "--token-limit", "invalid"], + ["--json", "--token-offset", "invalid"], + ["--json", "--filter-output"], + ].map((args) => [args]), + )("preserves invalid-option failure for %j", async (args) => { + const stdout = capture(); + const stderr = capture(); + expect( + await main( + ["scans", "logs", "scan-1", ...args], + stdout.stream, + stderr.stream, + dependencies(), + ), + ).toBe(2); + expect(stdout.text()).toBe(""); + expect(stderr.text()).not.toBe(""); + }); + + test("preserves JSON conversion and empty arrays", async () => { + const f = await fixture(); + try { + f.logs.events.push({ + threadId: "thread-1", + event: { + bigint: 42n, + missing: undefined, + values: [NaN, Infinity, -0], + }, + }); + for (const logs of [f.logs, { ...f.logs, sessions: [], events: [] }]) { + const chunks = []; + for await (const chunk of scanLogsJson(logs)) + chunks.push(Buffer.from(chunk)); + expect(Buffer.concat(chunks).toString()).toBe( + `${Formatter.format(logs, "json")}\n`, + ); + } + } finally { + await rm(f.state, { recursive: true, force: true }); + } + }); + + test("waits for output backpressure and leaves the stream open", async () => { + const f = await fixture(); + try { + const chunks: Buffer[] = []; + const output = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + setImmediate(() => { + chunks.push(Buffer.from(chunk)); + callback(); + }); + }, + }); + expect( + await main( + ["scans", "logs", "scan-1", "--json"], + output, + capture().stream, + f.deps, + ), + ).toBe(0); + expect(Buffer.concat(chunks).toString()).toBe( + `${Formatter.format(f.logs, "json")}\n`, + ); + expect(output.writableEnded).toBe(false); + expect(output.listenerCount("error")).toBe(0); + } finally { + await rm(f.state, { recursive: true, force: true }); + } + }); + + test("reports a failed output write", async () => { + const f = await fixture(); + try { + const stderr = capture(); + const output = new Writable({ + write(_chunk, _encoding, callback) { + callback(new Error("synthetic write failure")); + }, + }); + expect( + await main( + ["scans", "logs", "scan-1", "--json"], + output, + stderr.stream, + f.deps, + ), + ).toBe(2); + expect(stderr.text()).toContain("synthetic write failure"); + } finally { + await rm(f.state, { recursive: true, force: true }); + } + }); + + test("writes a complete document larger than Node's maximum string", async () => { + // Keep the bundle beneath the SDK so external dependencies resolve normally. + const bundle = await mkdtemp( + join(import.meta.dir, "..", ".logs-boundary-"), + ); + const state = await mkdtemp(join(tmpdir(), "large-saved-logs-")); + try { + const built = await Bun.build({ + entrypoints: [join(import.meta.dir, "support", "cli-logs-large.mts")], + outdir: bundle, + target: "node", + packages: "external", + }); + expect(built.success).toBe(true); + const result = await promisify(execFile)( + "node", + [ + "--input-type=module", + "--eval", + `await import(${JSON.stringify(pathToFileURL(built.outputs[0]!.path).href)})`, + "synthetic-launcher", + state, + ], + { encoding: "utf8" }, + ); + const proof = JSON.parse(result.stdout); + expect(proof.exitCode).toBe(0); + expect(proof.bytes).toBeGreaterThan(proof.maximumStringLength); + expect(proof.bytes).toBe(proof.expectedBytes); + expect(proof.sha256).toBe(proof.expectedSha256); + expect(result.stderr).toBe(""); + } finally { + await rm(bundle, { recursive: true, force: true }); + await rm(state, { recursive: true, force: true }); + } + }); +}); diff --git a/sdk/typescript/tests-ts/support/cli-logs-large.mts b/sdk/typescript/tests-ts/support/cli-logs-large.mts new file mode 100644 index 0000000000..2ac765fbdc --- /dev/null +++ b/sdk/typescript/tests-ts/support/cli-logs-large.mts @@ -0,0 +1,92 @@ +import { constants } from "node:buffer"; +import { createHash } from "node:crypto"; +import { mkdir, open, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { Writable } from "node:stream"; +import { main } from "../../src/cli.js"; +import { dependencies } from "../cli-fixtures.js"; + +const state = await realpath(process.argv[2]!); +const sessions = join(state, "codex-home", "sessions"); +await mkdir(sessions, { recursive: true }); +const path = join(sessions, "rollout.jsonl"); +const metadata = { type: "session_meta", payload: { id: "thread-1" } }; +let payload: unknown = Array(4096).fill(0); +// Pretty indentation expands a small input into a large document economically. +for (let i = 0; i < 32; i++) payload = { nested: payload }; +const event = (sequence: number) => ({ type: "event_msg", sequence, payload }); +const header = { + scanId: "scan-1", + threadId: "thread-1", + sessions: [{ threadId: "thread-1", parentThreadId: null, path }], + events: [{ threadId: "thread-1", event: metadata }], +}; +const first = JSON.stringify(header, null, 2); +const prefix = first.slice(0, first.lastIndexOf("\n ]")); +const suffix = "\n ]\n}\n"; +const record = (sequence: number) => + JSON.stringify( + { events: [{ threadId: "thread-1", event: event(sequence) }] }, + null, + 2, + ).slice('{\n "events": [\n'.length, -"\n ]\n}".length); +const count = Math.ceil(constants.MAX_STRING_LENGTH / record(0).length) + 1; +const expected = createHash("sha256"); +let expectedBytes = 0; +function expectChunk(text: string) { + expected.update(text); + expectedBytes += Buffer.byteLength(text); +} +expectChunk(prefix); +const file = await open(path, "wx"); +try { + await file.write(`${JSON.stringify(metadata)}\n`); + for (let i = 0; i < count; i++) { + await file.write(`${JSON.stringify(event(i))}\n`); + expectChunk(`,\n${record(i)}`); + } +} finally { + await file.close(); +} +expectChunk(suffix); +let bytes = 0; +const actual = createHash("sha256"); +const output = new Writable({ + write(chunk, _encoding, callback) { + bytes += chunk.length; + actual.update(chunk); + callback(); + }, +}); +const deps = dependencies({ + environment: { CODEX_SECURITY_STATE_DIR: state }, + onWorkbench: () => ({ + scan: { scanId: "scan-1", continuationThreadId: "thread-1" }, + }), +}); +deps.createSecurity = () => { + throw new Error("Reading logs must not start Codex"); +}; +try { + const exitCode = await main( + ["scans", "logs", "scan-1", "--json"], + output, + process.stderr, + deps, + ); + console.log( + JSON.stringify({ + exitCode, + bytes, + sha256: actual.digest("hex"), + expectedBytes, + expectedSha256: expected.digest("hex"), + maximumStringLength: constants.MAX_STRING_LENGTH, + events: count + 1, + }), + ); + process.exitCode = exitCode; +} catch (error) { + console.error(error); + process.exitCode = 2; +} From 01b0346993219813bb9b3614e73b49b2278f80ba Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 21:34:46 +0000 Subject: [PATCH 151/251] Include saved-logs formatter in package checks --- sdk/typescript/scripts/check-package.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 0553d5ac5a..66850779f0 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -171,6 +171,7 @@ const distFiles = new Set( "auth", "bulk-scan-discovery", "cli", + "cli-scan-logs-json", "classify-severity", "classify-scan-severity", "severity-store", From c8aa1abd1069005391d156436284ef8d5d896e13 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 22:13:28 +0000 Subject: [PATCH 152/251] Clarify scan draft candidate transitions and preservation --- plugins/codex-security/references/core-scan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/codex-security/references/core-scan.md b/plugins/codex-security/references/core-scan.md index 9eb77afe05..75ecefd741 100644 --- a/plugins/codex-security/references/core-scan.md +++ b/plugins/codex-security/references/core-scan.md @@ -9,7 +9,7 @@ Perform one complete, evidence-backed security audit of the exact supplied repos 3. While the baseline runs, read `threat-model.md` once and obtain its independent architecture review within the available worker allowance. Verify its resource rows against their actual consumers, use the returned canonical `threatModel` as the generated model, and build source-backed investigation packets from it. Carry that object and its evidence into the final result instead of reconstructing a shorter summary. Preserve any user-supplied threat model unchanged as the authoritative security assumptions; map its real surfaces and controls without replacing it. 4. Group related source-backed security questions into investigation packets. Each group shares its plausible attacker, protected asset, entry points, expected controls, sensitive operations, component relationships, and actual repository-relative source anchors. Keep each question concrete, preserve distinct attacker boundaries and security mechanisms, and let investigators establish the detailed dataflow. 5. Launch focused investigator subagents with `fork_turns: "none"` as soon as useful packet groups exist. Choose their number and assignments from the amount, complexity, and independence of source-backed work, bounded by the supplied available subagent allowance; use fewer for related packets and more only when distinct surfaces justify them. Keep mapping other surfaces while they run. Send each only its focused-investigator prompt below, assigned packets, investigator perspective, repository path, authorized scope, any supplied scoped-source inventory, exact user context, supplied threat model, applicable packet-specific security guidance and its resolver command, the optional authoritative knowledge-base location, and verified search command. Do not include this reference or another worker's prompt. Supporting code may be outside a requested path, but an affected entry point, control, or operation must be in scope. -6. Before combining or revalidating any returned baseline or investigator result, persist it through the caller's bound `record_codex_security_scan_draft` tool when available, using `complete: false` and partial coverage. Give each candidate a stable `candidateId`; put candidates awaiting parent validation in `coverage.deferred` with a meaningful reason and their original finding payload under `candidate`. Preserve returned counterevidence and unresolved questions too. Checkpoint again after each validation decision, without waiting for other workers or the final report. Put source-validated findings in `findings` with the same `provenance.candidateId`; for a rejection, retain the candidate ID, original evidence, and source-backed counterevidence on a `rejected` coverage surface. An unfinished scan must retain its saved findings and pending candidates without presenting pending work as validated. Reconcile source coverage before combining findings. Union only the baseline and focused investigators' `fully_reviewed_files` with files the parent fully security-audited, then intersect that set with the supplied authorized inventory or an inventory of the selected current scope. Architecture mapping alone and supporting files outside that inventory do not count toward completed audit coverage. Finish the remaining in-scope files in coherent groups, reusing available investigators within the same allowance. Inspect implementation-owning generated or compressed code as data. Do not add overlapping worker counts or claim that a search hit completed a file. Keep this one transient set; do not create a separate progress ledger or receipt format. If a user limit or unavailable source prevents completion, identify the actual remaining paths and report partial coverage. Then combine baseline and investigator findings once. Group observations only when they share the same broken security control and effective remediation; preserve every affected route, operation, sink, and supporting source location. Never merge different security failures solely because they share a CWE. +6. Before combining or revalidating any returned baseline or investigator result, persist it through the caller's bound `record_codex_security_scan_draft` tool when available, using `complete: false` and partial coverage. Give each candidate a stable `candidateId`; put candidates awaiting parent validation in `coverage.deferred` with a meaningful reason and their original finding payload under `candidate`. Preserve returned counterevidence and unresolved questions too. Checkpoint again after each validation decision, without waiting for other workers or the final report. Reuse the saved candidate's `candidateId` (or its existing deferred `id`) when recording a validation decision. Put source-validated findings in `findings` with that `provenance.candidateId`; for a rejection, set the same `candidateId` on a `rejected` coverage surface and retain original evidence and source-backed counterevidence. A prose surface summary alone does not resolve a different candidate. Later drafts preserve omitted findings and unresolved candidates. An unfinished scan must retain its saved findings and pending candidates without presenting pending work as validated. Reconcile source coverage before combining findings. Union only the baseline and focused investigators' `fully_reviewed_files` with files the parent fully security-audited, then intersect that set with the supplied authorized inventory or an inventory of the selected current scope. Architecture mapping alone and supporting files outside that inventory do not count toward completed audit coverage. Finish the remaining in-scope files in coherent groups, reusing available investigators within the same allowance. Inspect implementation-owning generated or compressed code as data. Do not add overlapping worker counts or claim that a search hit completed a file. Keep this one transient set; do not create a separate progress ledger or receipt format. If a user limit or unavailable source prevents completion, identify the actual remaining paths and report partial coverage. Then combine baseline and investigator findings once. Group observations only when they share the same broken security control and effective remediation; preserve every affected route, operation, sink, and supporting source location. Never merge different security failures solely because they share a CWE. 7. Independently validate each unique finding against local source once. Establish its attacker, entry point, trust boundary, attacker-controlled dataflow, transformations, broken control, sensitive operation, prerequisites, effective mitigations, strongest counterevidence, and concrete impact. Record concise, source-backed `rootCause.summary`, `validation.summary`, `attackPath.dataflow.summary`, and `attackPath.reachability.summary` alongside their supporting facts; determine impact, likelihood, and severity from those established facts. State optional configuration, dependency-version, or deployment prerequisites; do not require proof of a real deployment or runtime reproduction. A public library or parser boundary is sufficient when callers control the input. Reject only with source-backed counterevidence, preserve valid baseline findings, record material unresolved proof gaps, and apply the severity rules below. 8. Assemble complete semantic `scope`, `threatModel`, `findings`, and `coverage` using the plugin's `examples/completed-scan/` and `schemas/` as shape references, never as values to copy. Use the canonical field mapping and scenario reconciliation in `threat-model.md`, preserving supplied models unchanged and retaining source-backed architecture, capability, deployment, and uncertainty facts. Give each finding a stable lowercase vulnerability-family `ruleId`, its precise `taxonomy.category` and `taxonomy.cwe` values, genuine `provenance.source`, an instance when separately reported findings would otherwise collide, a `root_control` location when identifiable, all materially affected locations, calibrated severity and rationale, confidence and rationale, verified nonempty source evidence, attacker-to-sink reachability, and practical remediation. Write source evidence as `codeEvidence` entries with required `id`, `label`, `path`, `startLine`, `code`, and `explanation` fields; `endLine`, `language`, and `role` are optional. Use `code`, never `snippet`, and write new root-cause details as `rootCause`, never `root_cause`. Follow `finding-detail-fields.md` when constructing rich finding details. Use actual coverage surface labels and dispositions; report reviewed surfaces, explicit exclusions, deferred work, and unresolved questions honestly, and mark coverage `complete` only when the requested source scope was actually reviewed. Preserve every genuine finding, evidence item, user-supplied assumption, and unresolved proof gap in the caller's complete semantic result. From 7b81d39fa1c2d6d419039fa4c8f937eea27de523 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 13 Sep 2026 22:30:00 +0000 Subject: [PATCH 153/251] Preserve stale-skills notice in streamed saved-log JSON --- sdk/typescript/src/cli-scan-logs-json.ts | 5 ++ sdk/typescript/src/cli.ts | 14 +++-- sdk/typescript/tests-ts/cli-scan-logs.test.ts | 54 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/src/cli-scan-logs-json.ts b/sdk/typescript/src/cli-scan-logs-json.ts index ea9daa066e..8bceaacc5b 100644 --- a/sdk/typescript/src/cli-scan-logs-json.ts +++ b/sdk/typescript/src/cli-scan-logs-json.ts @@ -5,6 +5,7 @@ import type { readSavedScanLogs } from "./scan-logs.js"; // maximum string length even though each session and event fits comfortably. export async function* scanLogsJson( logs: Awaited>, + cta?: unknown, ): AsyncGenerator { yield Buffer.from( `{\n "scanId": ${JSON.stringify(logs.scanId)},\n "threadId": ${JSON.stringify(logs.threadId)}`, @@ -22,5 +23,9 @@ export async function* scanLogsJson( } yield Buffer.from(first ? "]" : "\n ]"); } + if (cta !== undefined) { + const formatted = Formatter.format(cta, "json").replaceAll("\n", "\n "); + yield Buffer.from(`,\n "cta": ${formatted}`); + } yield Buffer.from("\n}\n"); } diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index e877930f25..a44de3ae63 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -1774,7 +1774,7 @@ export async function main( let exitCode = 0; let frameworkExit: number | undefined; let frameworkOutput = ""; - let streamedLogs: AsyncIterable | undefined; + let streamedLogs: Awaited> | undefined; let renderedHistory: string | undefined; let renderedPublication: string | undefined; let renderedPolicy: string | undefined; @@ -2143,7 +2143,7 @@ export async function main( ), ) ) { - streamedLogs = scanLogsJson(logs); + streamedLogs = logs; } return logs as unknown as JsonObject; }, @@ -5702,9 +5702,17 @@ export async function main( if (frameworkOutput.length === 0 && streamedLogs === undefined) return exitCode; try { + // Incur can add a stale-skills CTA after the logs handler returns. + const logOutput = + streamedLogs === undefined + ? undefined + : scanLogsJson( + streamedLogs, + frameworkOutput ? JSON.parse(frameworkOutput).cta : undefined, + ); await writeCliOutput( output, - streamedLogs ?? + logOutput ?? renderedPolicy ?? renderedPatch ?? renderedPublication ?? diff --git a/sdk/typescript/tests-ts/cli-scan-logs.test.ts b/sdk/typescript/tests-ts/cli-scan-logs.test.ts index f57a4675af..9b0e1bbdc8 100644 --- a/sdk/typescript/tests-ts/cli-scan-logs.test.ts +++ b/sdk/typescript/tests-ts/cli-scan-logs.test.ts @@ -67,6 +67,60 @@ function withoutDuration(text: string) { } describe("saved logs JSON output", () => { + test("preserves the stale installed-skills CTA after saved logs", async () => { + const f = await fixture(); + const previousDataHome = process.env["XDG_DATA_HOME"]; + try { + const dataHome = join(f.state, "data"); + const skillPath = join(f.state, "skills", "codex-security-scans"); + await mkdir(join(dataHome, "incur"), { recursive: true }); + await mkdir(skillPath, { recursive: true }); + await writeFile( + join(skillPath, "SKILL.md"), + "Previously installed skill.", + ); + await writeFile( + join(dataHome, "incur", "codex-security.json"), + JSON.stringify({ + hash: "previous-command-hash", + skills: ["codex-security-scans"], + paths: [skillPath], + }), + ); + process.env["XDG_DATA_HOME"] = dataHome; + for (const args of [ + ["--json"], + ["--format", "json"], + ["--format=json"], + ]) { + const stdout = capture(); + const stderr = capture(); + expect( + await main( + ["scans", "logs", "scan-1", ...args], + stdout.stream, + stderr.stream, + f.deps, + ), + ).toBe(0); + const expected = await referenceOutput(["--json"], f.logs); + expect(Object.keys(JSON.parse(expected))).toEqual([ + "scanId", + "threadId", + "sessions", + "events", + "cta", + ]); + expect(stdout.text()).toBe(expected); + expect(stderr.text()).toBe(""); + } + } finally { + if (previousDataHome === undefined) delete process.env["XDG_DATA_HOME"]; + else process.env["XDG_DATA_HOME"] = previousDataHome; + await rm(f.state, { recursive: true, force: true }); + } + }); + test.each( [ [], From 59abaa789dfe2c1ad538619be5c3708471737b10 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 00:09:58 +0000 Subject: [PATCH 154/251] Fix custom validation compatibility with current scan guidance --- sdk/typescript/src/custom-validation-prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/src/custom-validation-prompt.ts b/sdk/typescript/src/custom-validation-prompt.ts index 93a1ec42ef..4cabb3f1ac 100644 --- a/sdk/typescript/src/custom-validation-prompt.ts +++ b/sdk/typescript/src/custom-validation-prompt.ts @@ -9,7 +9,7 @@ import { PLUGIN_NAME } from "./runtime.js"; // the ordinary validation sequence with a custom-validation request. const SOURCES = { "references/core-scan.md": - "77b082eb8613cf93427ff730e4ae5d85b0a0dca37c02a8af1ea69f679ac3d1d9", + "a489a22c84f041a9cfbfccc69b47a0dd2a4d5e7113dbcb40301ab0a793aa6379", "skills/security-scan/SKILL.md": "5b8f5d7debeca14c6b37e8e7ba737671362b8eb4b7f49e693c99c6bd04bc8fa0", "skills/security-diff-scan/SKILL.md": From 60b6ad9bed906f9d4b3b75898381cba103fe2b32 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 00:38:12 +0000 Subject: [PATCH 155/251] fix: read recovered Deep Scan logs from the recorded Codex home --- sdk/typescript/src/scan-logs.ts | 27 ++++++ sdk/typescript/tests-ts/cli-scan-logs.test.ts | 51 ++++++++++- sdk/typescript/tests-ts/scan-logs.test.ts | 84 +++++++++++++++++++ 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/scan-logs.ts b/sdk/typescript/src/scan-logs.ts index dbe33be226..b36016a889 100644 --- a/sdk/typescript/src/scan-logs.ts +++ b/sdk/typescript/src/scan-logs.ts @@ -1,4 +1,5 @@ import { createReadStream } from "node:fs"; +import { readFile } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; import { createInterface } from "node:readline"; import { sessionFiles } from "./cost.js"; @@ -117,6 +118,32 @@ export async function readScanLogs(options: ScanLogOptions) { ? [options.codexHome] : options.codexHome, ); + if (options.scanDirectory !== undefined) { + try { + const saved: unknown = JSON.parse( + await readFile( + join( + options.scanDirectory, + "artifacts", + "deep_discovery", + "execution-settings.json", + ), + "utf8", + ), + ); + if ( + isRecord(saved) && + saved["version"] === 1 && + isRecord(saved["settings"]) + ) { + const home = saved["settings"]["codexHome"]; + if (typeof home === "string" && home !== "") homes.add(home); + } + } catch (error) { + if (!isRecord(error) || error["code"] !== "ENOENT") throw error; + } + } + // Recovered workers retain their original home; the parent can use the current home. for (const directory of ["sessions", "archived_sessions"]) { for (const home of homes) { for await (const session of scanSessions(home, directory)) { diff --git a/sdk/typescript/tests-ts/cli-scan-logs.test.ts b/sdk/typescript/tests-ts/cli-scan-logs.test.ts index 9b0e1bbdc8..a467047218 100644 --- a/sdk/typescript/tests-ts/cli-scan-logs.test.ts +++ b/sdk/typescript/tests-ts/cli-scan-logs.test.ts @@ -31,8 +31,33 @@ async function fixture() { join(home, "sessions", "rollout.jsonl"), events.map((event) => JSON.stringify(event)).join("\n"), ); - const scan = { scanId: "scan-1", continuationThreadId: "thread-1" }; - const logs = await readSavedScanLogs(scan, home); + const originalHome = join(state, "original-home"); + const scanDir = join(state, "scan"); + const settingsDirectory = join(scanDir, "artifacts", "deep_discovery"); + await mkdir(settingsDirectory, { recursive: true }); + await mkdir(join(originalHome, "sessions"), { recursive: true }); + await writeFile( + join(settingsDirectory, "execution-settings.json"), + JSON.stringify({ + version: 1, + settings: { + codexHome: originalHome, + codexPath: join(originalHome, "codex"), + }, + }), + ); + await writeFile( + join(originalHome, "sessions", "worker.jsonl"), + JSON.stringify({ type: "session_meta", payload: { id: "worker" } }) + "\n", + ); + const scan = { + scanId: "scan-1", + continuationThreadId: "thread-1", + mode: "deep", + scanDir, + executionThreadIds: ["worker"], + }; + const logs = await readSavedScanLogs(scan, [home, originalHome]); const deps = dependencies({ environment: { CODEX_SECURITY_STATE_DIR: state }, onWorkbench: () => ({ scan }), @@ -67,6 +92,28 @@ function withoutDuration(text: string) { } describe("saved logs JSON output", () => { + test("loads the recorded worker home through the saved logs command", async () => { + const f = await fixture(); + try { + const stdout = capture(); + expect( + await main( + ["scans", "logs", "scan-1", "--json"], + stdout.stream, + capture().stream, + f.deps, + ), + ).toBe(0); + expect( + JSON.parse(stdout.text()).sessions.map( + ({ threadId }: { threadId: string }) => threadId, + ), + ).toEqual(["thread-1", "worker"]); + } finally { + await rm(f.state, { recursive: true, force: true }); + } + }); + test("preserves the stale installed-skills CTA after saved logs", async () => { const f = await fixture(); const previousDataHome = process.env["XDG_DATA_HOME"]; diff --git a/sdk/typescript/tests-ts/scan-logs.test.ts b/sdk/typescript/tests-ts/scan-logs.test.ts index 059e0e42fd..9b30a0f152 100644 --- a/sdk/typescript/tests-ts/scan-logs.test.ts +++ b/sdk/typescript/tests-ts/scan-logs.test.ts @@ -82,6 +82,90 @@ function commandEvent(command: string, id: string, timestamp?: string) { } describe("saved scan logs", () => { + test.each(["current", "original"])( + "reads recovered Deep workers from the recorded home with the owner in the %s home", + async (ownerHome) => { + const current = await temporaryHome(); + const original = await temporaryHome(); + const scanDir = await temporaryHome(); + const settingsDirectory = join(scanDir, "artifacts", "deep_discovery"); + await mkdir(settingsDirectory, { recursive: true }); + await writeFile( + join(settingsDirectory, "execution-settings.json"), + JSON.stringify({ + version: 1, + settings: { codexHome: original, codexPath: join(original, "codex") }, + }), + ); + const startedAt = "2026-08-11T12:00:00.000Z"; + const timestamp = "2026-08-11T12:01:00.000Z"; + await writeSession( + ownerHome === "current" ? current : original, + "owner", + [ + { + type: "turn_context", + timestamp, + payload: { turn_id: "scan-turn" }, + }, + commandEvent("scan owner", "owner-call", timestamp), + { + type: "turn_context", + timestamp, + payload: { turn_id: "later-turn" }, + }, + commandEvent("unrelated owner turn", "other-turn", timestamp), + ], + ); + for (const id of ["discovery", "resumed-discovery", "reducer"]) { + await writeSession(original, id, [commandEvent(id, id, timestamp)]); + } + await writeSession( + original, + "worker-child", + [commandEvent("worker child", "child-call", timestamp)], + "discovery", + ); + // Keep the existing first-home preference for duplicate active rollouts. + await writeSession(current, "reducer", [ + commandEvent("current reducer", "current-reducer-call", timestamp), + ]); + for (const home of [current, original]) { + await writeSession(home, "unrelated", [ + commandEvent("unrelated scan", "unrelated-call", timestamp), + ]); + } + const result = await readSavedScanLogs( + { + scanId: "scan-1", + mode: "deep", + scanDir, + continuationThreadId: "owner", + executionAttribution: { + formatVersion: 1, + executionThreadIds: ["discovery", "resumed-discovery", "reducer"], + owner: { threadId: "owner", turnId: "scan-turn", startedAt }, + startedAt, + completedAt: "2026-08-11T12:02:00.000Z", + }, + }, + current, + ); + expect(result.sessions.map(({ threadId }) => threadId).sort()).toEqual([ + "discovery", + "owner", + "reducer", + "resumed-discovery", + "worker-child", + ]); + expect( + result.events.filter(({ threadId }) => threadId === "reducer"), + ).toHaveLength(2); + expect(JSON.stringify(result)).toContain("current reducer"); + expect(JSON.stringify(result)).not.toContain("unrelated"); + }, + ); + test("collects known desktop and CLI threads across active and archived homes without duplicates", async () => { const desktop = await temporaryHome(); const cli = await temporaryHome(); From cbac786f54856db3c4d92daf54a8e64117af597c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 01:05:00 +0000 Subject: [PATCH 156/251] refactor: share recorded scan home lookup --- sdk/typescript/src/cost.ts | 28 ++++----------------------- sdk/typescript/src/scan-logs.ts | 27 +++----------------------- sdk/typescript/src/scan-sessions.ts | 30 +++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 48 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 860e705bb6..c5042fdc28 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { open, readdir, readFile, realpath } from "node:fs/promises"; +import { open, readdir, realpath } from "node:fs/promises"; import { join } from "node:path"; import { estimateScanCost, @@ -16,6 +16,7 @@ import { attributedScanThreads, isAttributedScanEvent, isScanArtifactDirectory, + recordedScanCodexHome, sessionParentThreadId, sessionStartedAt, type ScanExecutionAttribution, @@ -267,29 +268,8 @@ export class ScanCostTracker { const unreadable: Array<{ session: SessionUsage; error: unknown }> = []; const homes = new Set([this.#options.codexHome]); if (this.#options.scanDirectory !== undefined) { - try { - const saved: unknown = JSON.parse( - await readFile( - join( - this.#options.scanDirectory, - "artifacts", - "deep_discovery", - "execution-settings.json", - ), - "utf8", - ), - ); - if ( - isRecord(saved) && - saved["version"] === 1 && - isRecord(saved["settings"]) - ) { - const home = saved["settings"]["codexHome"]; - if (typeof home === "string" && home !== "") homes.add(home); - } - } catch (error) { - if (!isMissingFile(error)) throw error; - } + const home = await recordedScanCodexHome(this.#options.scanDirectory); + if (home !== undefined) homes.add(home); } // Recovery restores workers to their recorded home; the SDK parent can // continue in the current home. Apply the same scan membership to both. diff --git a/sdk/typescript/src/scan-logs.ts b/sdk/typescript/src/scan-logs.ts index b36016a889..cf825929bc 100644 --- a/sdk/typescript/src/scan-logs.ts +++ b/sdk/typescript/src/scan-logs.ts @@ -1,5 +1,4 @@ import { createReadStream } from "node:fs"; -import { readFile } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; import { createInterface } from "node:readline"; import { sessionFiles } from "./cost.js"; @@ -9,6 +8,7 @@ import { attributedScanThreads, isAttributedScanEvent, isScanArtifactDirectory, + recordedScanCodexHome, sessionParentThreadId, sessionStartedAt, type ScanExecutionAttribution, @@ -119,29 +119,8 @@ export async function readScanLogs(options: ScanLogOptions) { : options.codexHome, ); if (options.scanDirectory !== undefined) { - try { - const saved: unknown = JSON.parse( - await readFile( - join( - options.scanDirectory, - "artifacts", - "deep_discovery", - "execution-settings.json", - ), - "utf8", - ), - ); - if ( - isRecord(saved) && - saved["version"] === 1 && - isRecord(saved["settings"]) - ) { - const home = saved["settings"]["codexHome"]; - if (typeof home === "string" && home !== "") homes.add(home); - } - } catch (error) { - if (!isRecord(error) || error["code"] !== "ENOENT") throw error; - } + const home = await recordedScanCodexHome(options.scanDirectory); + if (home !== undefined) homes.add(home); } // Recovered workers retain their original home; the parent can use the current home. for (const directory of ["sessions", "archived_sessions"]) { diff --git a/sdk/typescript/src/scan-sessions.ts b/sdk/typescript/src/scan-sessions.ts index 47b4c90918..925f536012 100644 --- a/sdk/typescript/src/scan-sessions.ts +++ b/sdk/typescript/src/scan-sessions.ts @@ -1,5 +1,35 @@ +import { readFile } from "node:fs/promises"; import { isAbsolute, join, relative, sep } from "node:path"; +export async function recordedScanCodexHome( + scanDirectory: string, +): Promise { + try { + const saved: unknown = JSON.parse( + await readFile( + join( + scanDirectory, + "artifacts", + "deep_discovery", + "execution-settings.json", + ), + "utf8", + ), + ); + if ( + isRecord(saved) && + saved["version"] === 1 && + isRecord(saved["settings"]) + ) { + const home = saved["settings"]["codexHome"]; + if (typeof home === "string" && home !== "") return home; + } + } catch (error) { + if (!isRecord(error) || error["code"] !== "ENOENT") throw error; + } + return undefined; +} + export interface ScanExecutionAttribution { formatVersion: 1; legacy?: true; From 0fa83d8de85695cdd461e828658ab105d8dea34c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 04:04:49 +0000 Subject: [PATCH 157/251] Fix saved log truncation from copied rollout prefixes --- sdk/typescript/src/scan-logs.ts | 35 ++++- sdk/typescript/tests-ts/cli-scan-logs.test.ts | 20 ++- sdk/typescript/tests-ts/scan-logs.test.ts | 128 +++++++++++++++++- 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/src/scan-logs.ts b/sdk/typescript/src/scan-logs.ts index cf825929bc..ecd25fb49c 100644 --- a/sdk/typescript/src/scan-logs.ts +++ b/sdk/typescript/src/scan-logs.ts @@ -113,6 +113,7 @@ export async function findScanSession( export async function readScanLogs(options: ScanLogOptions) { const logs = new Map(); + const sessionPaths = new Map(); const homes = new Set( typeof options.codexHome === "string" ? [options.codexHome] @@ -126,7 +127,12 @@ export async function readScanLogs(options: ScanLogOptions) { for (const directory of ["sessions", "archived_sessions"]) { for (const home of homes) { for await (const session of scanSessions(home, directory)) { - if (!logs.has(session.threadId)) logs.set(session.threadId, session); + const paths = sessionPaths.get(session.threadId); + if (paths) paths.push(session.path); + else { + logs.set(session.threadId, session); + sessionPaths.set(session.threadId, [session.path]); + } } } } @@ -172,7 +178,14 @@ export async function readScanLogs(options: ScanLogOptions) { const sessions: SessionLog[] = []; for (const threadId of included) { const session = logs.get(threadId); - if (session !== undefined) sessions.push(session); + if (session !== undefined) { + // Compare only scan-owned logs. Keep traversal order unless a later copy + // contains every recorded event followed by additional events. + for (const path of sessionPaths.get(threadId)!.slice(1)) { + if (await extendsSessionLog(path, session.path)) session.path = path; + } + sessions.push(session); + } } const events: Record[] = []; for (const session of sessions) { @@ -235,6 +248,24 @@ export async function readScanLogs(options: ScanLogOptions) { }; } +async function extendsSessionLog( + path: string, + previousPath: string, +): Promise { + const previous = sessionEvents(previousPath); + try { + for await (const event of sessionEvents(path)) { + const recorded = await previous.next(); + if (recorded.done) return true; + if (JSON.stringify(event) !== JSON.stringify(recorded.value)) + return false; + } + return false; + } finally { + await previous.return(undefined); + } +} + function belongsToScan( session: SessionLog, root: SessionLog, diff --git a/sdk/typescript/tests-ts/cli-scan-logs.test.ts b/sdk/typescript/tests-ts/cli-scan-logs.test.ts index a467047218..424558b75e 100644 --- a/sdk/typescript/tests-ts/cli-scan-logs.test.ts +++ b/sdk/typescript/tests-ts/cli-scan-logs.test.ts @@ -47,9 +47,18 @@ async function fixture() { }), ); await writeFile( - join(originalHome, "sessions", "worker.jsonl"), + join(home, "sessions", "worker.jsonl"), JSON.stringify({ type: "session_meta", payload: { id: "worker" } }) + "\n", ); + await writeFile( + join(originalHome, "sessions", "worker.jsonl"), + [ + { type: "session_meta", payload: { id: "worker" } }, + { type: "event_msg", payload: { message: "recorded worker suffix" } }, + ] + .map((event) => JSON.stringify(event) + "\n") + .join(""), + ); const scan = { scanId: "scan-1", continuationThreadId: "thread-1", @@ -92,7 +101,7 @@ function withoutDuration(text: string) { } describe("saved logs JSON output", () => { - test("loads the recorded worker home through the saved logs command", async () => { + test("loads the same-thread recorded worker suffix through the saved logs command", async () => { const f = await fixture(); try { const stdout = capture(); @@ -109,6 +118,13 @@ describe("saved logs JSON output", () => { ({ threadId }: { threadId: string }) => threadId, ), ).toEqual(["thread-1", "worker"]); + expect(JSON.parse(stdout.text()).events).toContainEqual({ + threadId: "worker", + event: { + type: "event_msg", + payload: { message: "recorded worker suffix" }, + }, + }); } finally { await rm(f.state, { recursive: true, force: true }); } diff --git a/sdk/typescript/tests-ts/scan-logs.test.ts b/sdk/typescript/tests-ts/scan-logs.test.ts index 9b30a0f152..a733e3e162 100644 --- a/sdk/typescript/tests-ts/scan-logs.test.ts +++ b/sdk/typescript/tests-ts/scan-logs.test.ts @@ -82,6 +82,110 @@ function commandEvent(command: string, id: string, timestamp?: string) { } describe("saved scan logs", () => { + test.each(["sessions", "archived_sessions"])( + "reads the complete same-thread recorded copy from %s with scan turn attribution", + async (directory) => { + const current = await temporaryHome(); + const original = await temporaryHome(); + const scanDir = await temporaryHome(); + const settings = join(scanDir, "artifacts", "deep_discovery"); + await mkdir(settings, { recursive: true }); + await writeFile( + join(settings, "execution-settings.json"), + JSON.stringify({ version: 1, settings: { codexHome: original } }), + ); + const timestamp = "2026-08-11T12:01:00.000Z"; + const prefix = [ + { type: "turn_context", timestamp, payload: { turn_id: "scan-turn" } }, + commandEvent("prefix", "prefix-call", timestamp), + ]; + const suffix = commandEvent("saved suffix", "suffix-call", timestamp); + await writeSession(current, "owner", prefix); + const currentPath = join( + current, + "sessions", + "2026", + "08", + "11", + "rollout-owner.jsonl", + ); + await writeFile( + currentPath, + (await readFile(currentPath, "utf8")) + '\n\n42\n{"type":', + ); + await writeSession(original, "owner", [ + ...prefix, + suffix, + suffix, + { type: "turn_context", timestamp, payload: { turn_id: "other-turn" } }, + commandEvent("unrelated turn", "other-call", timestamp), + ]); + if (directory === "archived_sessions") { + await rename(join(original, "sessions"), join(original, directory)); + } + const result = await readSavedScanLogs( + { + scanId: "scan-1", + mode: "deep", + scanDir, + continuationThreadId: "owner", + executionAttribution: { + formatVersion: 1, + executionThreadIds: [], + owner: { + threadId: "owner", + turnId: "scan-turn", + startedAt: timestamp, + }, + startedAt: timestamp, + completedAt: timestamp, + }, + }, + current, + ); + expect(result.sessions.map(({ threadId }) => threadId)).toEqual([ + "owner", + ]); + expect(result.events.map(({ event }) => event)).toEqual([ + { type: "session_meta", payload: { id: "owner" } }, + ...prefix, + suffix, + suffix, + ]); + }, + ); + + test.each(["equal", "shorter", "divergent"])( + "keeps the first rollout when a later copy is %s", + async (copy) => { + const first = await temporaryHome(); + const second = await temporaryHome(); + const prefix = commandEvent("first", "first-call"); + const suffix = commandEvent("complete", "complete-call"); + await writeSession(first, "owner", [prefix, suffix]); + await writeSession( + second, + "owner", + copy === "equal" + ? [prefix, suffix] + : copy === "shorter" + ? [prefix] + : [prefix, commandEvent("different", "different-call"), suffix], + ); + const result = await readScanLogs({ + scanId: "scan-1", + threadId: "owner", + codexHome: [first, second], + }); + expect(result.sessions[0]?.path).toStartWith(first); + expect(result.events.map(({ event }) => event)).toEqual([ + { type: "session_meta", payload: { id: "owner" } }, + prefix, + suffix, + ]); + }, + ); + test.each(["current", "original"])( "reads recovered Deep workers from the recorded home with the owner in the %s home", async (ownerHome) => { @@ -388,6 +492,7 @@ describe("saved scan logs", () => { test("excludes inherited parent history from worker logs", async () => { const home = await temporaryHome(); + const current = await temporaryHome(); await writeSession(home, "parent", []); const startedAt = "2026-08-11T12:02:00.900Z"; await writeSession( @@ -431,10 +536,24 @@ describe("saved scan logs", () => { startedAt, ); + const path = join( + home, + "sessions", + "2026", + "08", + "11", + "rollout-worker.jsonl", + ); + const currentPath = join(current, "sessions", "rollout-worker.jsonl"); + await mkdir(join(current, "sessions"), { recursive: true }); + await writeFile( + currentPath, + (await readFile(path, "utf8")).split("\n").slice(0, 4).join("\n"), + ); const result = await readScanLogs({ scanId: "scan-1", threadId: "parent", - codexHome: home, + codexHome: [current, home], }); expect(JSON.stringify(result)).toContain("Reviewing authorization"); expect(JSON.stringify(result)).not.toContain("PRIVATE PRE-SCAN"); @@ -609,12 +728,17 @@ describe("saved scan logs", () => { test("does not parse event bodies from unrelated saved sessions", async () => { const home = await temporaryHome(); + const other = await temporaryHome(); await writeSession(home, "parent", [ commandEvent("included", "parent-call"), ]); await writeSession(home, "unrelated", [ commandEvent("UNRELATED_PRIVATE_EVENT_BODY", "unrelated-call"), ]); + await writeSession(other, "unrelated", [ + commandEvent("UNRELATED_PRIVATE_EVENT_BODY", "unrelated-call"), + commandEvent("UNRELATED_PRIVATE_EVENT_BODY", "later-call"), + ]); const originalParse = JSON.parse; let unrelatedBodies = 0; const parseSpy = spyOn(JSON, "parse").mockImplementation( @@ -628,7 +752,7 @@ describe("saved scan logs", () => { const result = await readScanLogs({ scanId: "scan-1", threadId: "parent", - codexHome: home, + codexHome: [home, other], }); expect(result.sessions.map(({ threadId }) => threadId)).toEqual([ "parent", From 4c0039aa15f1b0b8b6a0f08a680021c5c2d53581 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 05:36:02 +0000 Subject: [PATCH 158/251] Compare saved rollout extensions after scan attribution --- sdk/typescript/src/scan-logs.ts | 118 ++++++++++-------- sdk/typescript/tests-ts/cli-scan-logs.test.ts | 94 +++++++++++++- sdk/typescript/tests-ts/scan-logs.test.ts | 97 +++++++++++++- 3 files changed, 252 insertions(+), 57 deletions(-) diff --git a/sdk/typescript/src/scan-logs.ts b/sdk/typescript/src/scan-logs.ts index ecd25fb49c..f9976cc651 100644 --- a/sdk/typescript/src/scan-logs.ts +++ b/sdk/typescript/src/scan-logs.ts @@ -180,59 +180,22 @@ export async function readScanLogs(options: ScanLogOptions) { const session = logs.get(threadId); if (session !== undefined) { // Compare only scan-owned logs. Keep traversal order unless a later copy - // contains every recorded event followed by additional events. + // contains every attributed event followed by additional events. for (const path of sessionPaths.get(threadId)!.slice(1)) { - if (await extendsSessionLog(path, session.path)) session.path = path; + if (await extendsSessionLog(path, session, attribution)) + session.path = path; } sessions.push(session); } } const events: Record[] = []; for (const session of sessions) { - let replaying = false; - let turnId: string | null = null; - for await (const event of sessionEvents(session.path)) { - const payload = event["payload"]; - if ( - isRecord(payload) && - (event["type"] === "turn_context" || - payload["type"] === "task_started") && - typeof payload["turn_id"] === "string" - ) { - turnId = payload["turn_id"]; - } - if (event["type"] === "session_meta" && isRecord(payload)) { - replaying = payload["id"] !== session.threadId; - } - if (replaying) { - if ( - event["type"] !== "event_msg" || - !isRecord(payload) || - payload["type"] !== "task_started" || - typeof payload["started_at"] !== "number" || - session.startedAt === null || - payload["started_at"] < Math.floor(session.startedAt / 1_000) - ) { - continue; - } - replaying = false; - } - if ( - !attribution || - event["type"] === "session_meta" || - isAttributedScanEvent( - attribution, - session.threadId, - event["type"] === "token_usage_record" && - isRecord(payload) && - typeof payload["turn_id"] === "string" - ? payload["turn_id"] - : turnId, - event["timestamp"], - ) - ) { - events.push({ threadId: session.threadId, event }); - } + for await (const event of attributedSessionEvents( + session.path, + session, + attribution, + )) { + events.push({ threadId: session.threadId, event }); } } @@ -248,13 +211,70 @@ export async function readScanLogs(options: ScanLogOptions) { }; } +async function* attributedSessionEvents( + path: string, + session: SessionLog, + attribution: ScanExecutionAttribution | null | undefined, +): AsyncGenerator> { + let replaying = false; + let turnId: string | null = null; + for await (const event of sessionEvents(path)) { + const payload = event["payload"]; + if ( + isRecord(payload) && + (event["type"] === "turn_context" || + payload["type"] === "task_started") && + typeof payload["turn_id"] === "string" + ) { + turnId = payload["turn_id"]; + } + if (event["type"] === "session_meta" && isRecord(payload)) { + replaying = payload["id"] !== session.threadId; + } + if (replaying) { + if ( + event["type"] !== "event_msg" || + !isRecord(payload) || + payload["type"] !== "task_started" || + typeof payload["started_at"] !== "number" || + session.startedAt === null || + payload["started_at"] < Math.floor(session.startedAt / 1_000) + ) { + continue; + } + replaying = false; + } + if ( + !attribution || + event["type"] === "session_meta" || + isAttributedScanEvent( + attribution, + session.threadId, + event["type"] === "token_usage_record" && + isRecord(payload) && + typeof payload["turn_id"] === "string" + ? payload["turn_id"] + : turnId, + event["timestamp"], + ) + ) { + yield event; + } + } +} + async function extendsSessionLog( path: string, - previousPath: string, + session: SessionLog, + attribution: ScanExecutionAttribution | null | undefined, ): Promise { - const previous = sessionEvents(previousPath); + const previous = attributedSessionEvents(session.path, session, attribution); try { - for await (const event of sessionEvents(path)) { + for await (const event of attributedSessionEvents( + path, + session, + attribution, + )) { const recorded = await previous.next(); if (recorded.done) return true; if (JSON.stringify(event) !== JSON.stringify(recorded.value)) diff --git a/sdk/typescript/tests-ts/cli-scan-logs.test.ts b/sdk/typescript/tests-ts/cli-scan-logs.test.ts index 424558b75e..42fb6ea495 100644 --- a/sdk/typescript/tests-ts/cli-scan-logs.test.ts +++ b/sdk/typescript/tests-ts/cli-scan-logs.test.ts @@ -13,11 +13,11 @@ import { readSavedScanLogs } from "../src/scan-logs.js"; import { VERSION } from "../src/version.js"; import { capture, dependencies } from "./cli-fixtures.js"; -async function fixture() { +async function fixture(attributedOwner = false) { const state = await realpath(await mkdtemp(join(tmpdir(), "saved-logs-"))); const home = join(state, "codex-home"); await mkdir(join(home, "sessions"), { recursive: true }); - const events = [ + const events: Record[] = [ { type: "session_meta", payload: { id: "thread-1" } }, { type: "event_msg", @@ -27,6 +27,15 @@ async function fixture() { }, }, ]; + const timestamp = "2026-08-11T12:01:00.000Z"; + if (attributedOwner) { + events.splice(1, 0, { + type: "turn_context", + timestamp, + payload: { turn_id: "scan-turn" }, + }); + Object.assign(events.at(-1)!, { timestamp }); + } await writeFile( join(home, "sessions", "rollout.jsonl"), events.map((event) => JSON.stringify(event)).join("\n"), @@ -36,6 +45,42 @@ async function fixture() { const settingsDirectory = join(scanDir, "artifacts", "deep_discovery"); await mkdir(settingsDirectory, { recursive: true }); await mkdir(join(originalHome, "sessions"), { recursive: true }); + let ownerEvents = events; + if (attributedOwner) { + const repeated = { + type: "event_msg", + timestamp, + payload: { message: "repeated scan occurrence" }, + }; + ownerEvents = [ + ...events, + repeated, + repeated, + { + type: "event_msg", + timestamp, + payload: { message: "recorded non-usage suffix" }, + }, + ]; + await writeFile( + join(home, "sessions", "rollout.jsonl"), + [ + ...events, + { type: "turn_context", timestamp, payload: { turn_id: "other-turn" } }, + { + type: "event_msg", + timestamp, + payload: { message: "unrelated first-copy suffix" }, + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n"), + ); + await writeFile( + join(originalHome, "sessions", "owner.jsonl"), + ownerEvents.map((event) => JSON.stringify(event)).join("\n"), + ); + } await writeFile( join(settingsDirectory, "execution-settings.json"), JSON.stringify({ @@ -65,6 +110,21 @@ async function fixture() { mode: "deep", scanDir, executionThreadIds: ["worker"], + ...(attributedOwner + ? { + executionAttribution: { + formatVersion: 1 as const, + executionThreadIds: ["worker"], + owner: { + threadId: "thread-1", + turnId: "scan-turn", + startedAt: timestamp, + }, + startedAt: timestamp, + completedAt: timestamp, + }, + } + : {}), }; const logs = await readSavedScanLogs(scan, [home, originalHome]); const deps = dependencies({ @@ -74,7 +134,7 @@ async function fixture() { deps.createSecurity = () => { throw new Error("Reading logs must not start Codex"); }; - return { state, logs, deps }; + return { state, logs, deps, ownerEvents }; } async function referenceOutput(args: string[], logs: unknown) { @@ -101,6 +161,34 @@ function withoutDuration(text: string) { } describe("saved logs JSON output", () => { + test("selects the recorded owner suffix after scan attribution through the saved logs command", async () => { + const f = await fixture(true); + try { + const stdout = capture(); + expect( + await main( + ["scans", "logs", "scan-1", "--json"], + stdout.stream, + capture().stream, + f.deps, + ), + ).toBe(0); + const result = JSON.parse(stdout.text()); + expect( + result.sessions.map(({ threadId }: { threadId: string }) => threadId), + ).toEqual(["worker", "thread-1"]); + expect( + result.events + .filter( + ({ threadId }: { threadId: string }) => threadId === "thread-1", + ) + .map(({ event }: { event: unknown }) => event), + ).toEqual(f.ownerEvents); + } finally { + await rm(f.state, { recursive: true, force: true }); + } + }); + test("loads the same-thread recorded worker suffix through the saved logs command", async () => { const f = await fixture(); try { diff --git a/sdk/typescript/tests-ts/scan-logs.test.ts b/sdk/typescript/tests-ts/scan-logs.test.ts index a733e3e162..ed3756ff3a 100644 --- a/sdk/typescript/tests-ts/scan-logs.test.ts +++ b/sdk/typescript/tests-ts/scan-logs.test.ts @@ -82,9 +82,13 @@ function commandEvent(command: string, id: string, timestamp?: string) { } describe("saved scan logs", () => { - test.each(["sessions", "archived_sessions"])( - "reads the complete same-thread recorded copy from %s with scan turn attribution", - async (directory) => { + test.each( + ["sessions", "archived_sessions"].flatMap((directory) => + ["prefix only", "unrelated owner turn"].map((tail) => [directory, tail]), + ), + )( + "reads the complete same-thread recorded copy from %s after %s", + async (directory, tail) => { const current = await temporaryHome(); const original = await temporaryHome(); const scanDir = await temporaryHome(); @@ -100,7 +104,23 @@ describe("saved scan logs", () => { commandEvent("prefix", "prefix-call", timestamp), ]; const suffix = commandEvent("saved suffix", "suffix-call", timestamp); - await writeSession(current, "owner", prefix); + await writeSession(current, "owner", [ + ...prefix, + ...(tail === "unrelated owner turn" + ? [ + { + type: "turn_context", + timestamp, + payload: { turn_id: "other-turn" }, + }, + commandEvent( + "unrelated first copy", + "other-first-call", + timestamp, + ), + ] + : []), + ]); const currentPath = join( current, "sessions", @@ -152,6 +172,66 @@ describe("saved scan logs", () => { suffix, suffix, ]); + expect(result.sessions[0]?.path).toStartWith(join(original, directory!)); + }, + ); + + test.each(["equal", "shorter", "divergent"])( + "keeps the first rollout when attributed occurrences are %s", + async (copy) => { + const first = await temporaryHome(); + const second = await temporaryHome(); + const timestamp = "2026-08-11T12:01:00.000Z"; + const turn = { + type: "turn_context", + timestamp, + payload: { turn_id: "scan-turn" }, + }; + const prefix = commandEvent("first", "first-call", timestamp); + const suffix = commandEvent("complete", "complete-call", timestamp); + const unrelated = { + type: "turn_context", + timestamp, + payload: { turn_id: "other-turn" }, + }; + await writeSession(first, "owner", [turn, prefix, suffix, unrelated]); + await writeSession(second, "owner", [ + turn, + ...(copy === "equal" + ? [prefix, suffix] + : copy === "shorter" + ? [prefix] + : [ + prefix, + commandEvent("different", "different-call", timestamp), + suffix, + ]), + unrelated, + commandEvent("more unrelated work", "other-call", timestamp), + ]); + const result = await readScanLogs({ + scanId: "scan-1", + threadId: "owner", + codexHome: [first, second], + executionAttribution: { + formatVersion: 1, + executionThreadIds: [], + owner: { + threadId: "owner", + turnId: "scan-turn", + startedAt: timestamp, + }, + startedAt: timestamp, + completedAt: timestamp, + }, + }); + expect(result.sessions[0]?.path).toStartWith(first); + expect(result.events.map(({ event }) => event)).toEqual([ + { type: "session_meta", payload: { id: "owner" } }, + turn, + prefix, + suffix, + ]); }, ); @@ -548,7 +628,14 @@ describe("saved scan logs", () => { await mkdir(join(current, "sessions"), { recursive: true }); await writeFile( currentPath, - (await readFile(path, "utf8")).split("\n").slice(0, 4).join("\n"), + (await readFile(path, "utf8")) + .split("\n") + .slice(0, 4) + .join("\n") + .replace( + "PRIVATE PRE-SCAN CONVERSATION", + "OTHER PRE-SCAN CONVERSATION", + ), ); const result = await readScanLogs({ scanId: "scan-1", From df4b9802723b54f7629b444cf89a04b32dfbb184 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 07:30:06 +0000 Subject: [PATCH 159/251] Describe empty partial reports without denying saved validation --- .../scripts/report_projection.py | 4 +- .../tests/test_budget_report_evidence.py | 62 +++++++++++++++++++ .../tests/test_report_projection.py | 4 +- .../codex-security/tests/test_workbench_db.py | 7 +-- 4 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 plugins/codex-security/tests/test_budget_report_evidence.py diff --git a/plugins/codex-security/scripts/report_projection.py b/plugins/codex-security/scripts/report_projection.py index a4e3638570..73c6b90f3f 100644 --- a/plugins/codex-security/scripts/report_projection.py +++ b/plugins/codex-security/scripts/report_projection.py @@ -1029,8 +1029,8 @@ def build_report_markdown( "No vulnerability conclusion can be drawn." if no_source_review else ( - "No findings were validated before the scan reached its cost limit. " - "Review the deferred candidates in Open Questions And Follow Up." + "No findings are included in this partial report. " + "Review the unresolved work in Open Questions And Follow Up." if budget_exhausted else "No reportable findings survived the canonical discovery, validation, " "and reportability gates." diff --git a/plugins/codex-security/tests/test_budget_report_evidence.py b/plugins/codex-security/tests/test_budget_report_evidence.py new file mode 100644 index 0000000000..ca2803aad2 --- /dev/null +++ b/plugins/codex-security/tests/test_budget_report_evidence.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +from argparse import Namespace + +import pytest +from test_budget_selection_publication import accept_unmerged +from test_deep_scan_successful_publication import add_worker +from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_workbench_db import BUDGET_COST + + +@pytest.mark.parametrize("accepted", [False, True], ids=["unfinished-audit", "accepted-unmerged"]) +def test_empty_budget_report_does_not_deny_saved_validation( + workbench_api, workbench_db, publication_scan, accepted +): + scan = publication_scan() + if accepted: + evidence = accept_unmerged(workbench_db, scan) + else: + result = add_worker(workbench_db, scan, status="running") + checkpoints = result.parent / "checkpoints" + checkpoints.mkdir() + evidence = checkpoints / ("a" * 64 + ".json") + evidence.write_text( + json.dumps( + { + "scanId": scan.scan_id, + "complete": False, + "findings": scan.findings, + "coverage": {**scan.coverage, "completeness": "partial"}, + } + ) + ) + (result.parent / "checkpoint-head.json").write_text( + json.dumps({"checkpoint": evidence.name}) + ) + original = evidence.read_bytes() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'discovery', " + "workflow_version = 'deep-security-scan/v2', manifest_path = NULL, " + "terminal_reason = NULL, completed_at = NULL" + ) + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None), + ) + assert evidence.read_bytes() == original + assert json.loads(original)["findings"] + assert json.loads((scan.scan_dir / "findings.json").read_text())["findings"] == [] + assert json.loads((scan.scan_dir / "coverage.json").read_text())["completeness"] == "partial" + report = (scan.scan_dir / "report.md").read_text() + assert "No findings were validated" not in report + assert "No findings are included in this partial report." in report + assert "Review the unresolved work in Open Questions And Follow Up." in report diff --git a/plugins/codex-security/tests/test_report_projection.py b/plugins/codex-security/tests/test_report_projection.py index ecaf7821f9..f5881a3a54 100644 --- a/plugins/codex-security/tests/test_report_projection.py +++ b/plugins/codex-security/tests/test_report_projection.py @@ -764,8 +764,8 @@ def test_projection_explains_unvalidated_findings_after_cost_limit(reason: str) assert "| Reportable findings | 0 |" in markdown assert "| Coverage | partial |" in markdown assert ( - "No findings were validated before the scan reached its cost limit. " - "Review the deferred candidates in Open Questions And Follow Up." + "No findings are included in this partial report. " + "Review the unresolved work in Open Questions And Follow Up." ) in markdown assert "No reportable findings survived" not in markdown assert "## Open Questions And Follow Up" in markdown diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 7a781e5c89..02f11d0391 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -454,7 +454,7 @@ def test_budget_exhaustion_preserves_unvalidated_discovery_as_deferred_work( assert "User input reaches a SQL statement" in coverage["deferred"][0]["reason"] assert coverage["surfaces"][0]["disposition"] == "needs_follow_up" report = (scan_dir / "report.md").read_text() - assert "No findings were validated before the scan reached its cost limit" in report + assert "No findings are included in this partial report" in report assert "User input reaches a SQL statement" in report @@ -594,10 +594,7 @@ def test_budget_exhaustion_preserves_existing_deferred_work_with_one_trusted_mar "id": "scan-cost-limit", "reason": "Validation was deferred because the scan reached its cost limit.", } - assert ( - "No findings were validated before the scan reached its cost limit" - in (scan_dir / "report.md").read_text() - ) + assert "No findings are included in this partial report" in (scan_dir / "report.md").read_text() def test_budget_exhaustion_preserves_capped_discovery(tmp_path: Path) -> None: From 177a966a57327cf01ffcf94186f1266b34d8d1c5 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 09:15:31 +0000 Subject: [PATCH 160/251] test: align budget report assertions with partial summaries --- sdk/typescript/tests-ts/deep-scan-workbench.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts index abe6edcbbb..8c67de03da 100644 --- a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts @@ -908,9 +908,7 @@ describe("deep scan workbench ownership", () => { ]), ); const report = await readFile(join(scanDir, "report.md"), "utf8"); - expect(report).toContain( - "No findings were validated before the scan reached its cost limit.", - ); + expect(report).toContain("partial report"); if (existingDeferred) { expect(report).toContain( "Existing candidate validation dependency was unavailable.", @@ -965,7 +963,7 @@ describe("deep scan workbench ownership", () => { false, ], ] as const)( - "only describes an exhausted scan cost limit for %s", + "uses the partial-report summary for an exhausted scan cost limit: %s", (_description, reason, exhausted) => { const python = Bun.which("python3") ?? Bun.which("python"); expect(python).not.toBeNull(); @@ -987,11 +985,7 @@ describe("deep scan workbench ownership", () => { ); expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); const report = new TextDecoder().decode(result.stdout); - expect( - report.includes( - "No findings were validated before the scan reached its cost limit.", - ), - ).toBe(exhausted); + expect(report.includes("partial report")).toBe(exhausted); expect( report.includes( "No reportable findings survived the canonical discovery, validation, and reportability gates.", From 1c6a5d6ed0b5c0ecdacda88b6aac0b5dd7f5cc96 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 08:51:22 +0000 Subject: [PATCH 161/251] Preserve optional finding identities in budget publication --- .../scripts/workbench_saved_results.py | 6 +- .../tests/test_budget_finding_identity.py | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 plugins/codex-security/tests/test_budget_finding_identity.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index f78d0fd622..07b9451fd6 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -1311,7 +1311,11 @@ def budget_exhausted_draft( if before_selection: # Unmerged discoveries remain in their accepted artifacts. Only the # committed reducer may contribute findings to this partial result. - findings = {"findings": accepted_result["findings"] if accepted_result else []} + findings = { + "findings": copy.deepcopy(accepted_result["findings"]) if accepted_result else [] + } + for finding in findings["findings"]: + _ensure_finding_identity(finding) if accepted_result is not None: coverage = accepted_result["sourceCoverage"] if "threatModel" in accepted_result: diff --git a/plugins/codex-security/tests/test_budget_finding_identity.py b/plugins/codex-security/tests/test_budget_finding_identity.py new file mode 100644 index 0000000000..eb987554b2 --- /dev/null +++ b/plugins/codex-security/tests/test_budget_finding_identity.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import hashlib +import json +from argparse import Namespace + +import pytest +from test_accepted_publication_references import accept_reducer +from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_workbench_db import BUDGET_COST + + +@pytest.mark.parametrize("identity_kind", ["omitted", "candidate", "authored"]) +def test_budget_publication_identifies_accepted_semantic_findings( + workbench_api, workbench_db, publication_scan, identity_kind +): + scan = publication_scan() + finding = scan.findings[0] + finding["title"] = "Archive extraction crosses output boundary" + finding.pop("identity", None) + finding.pop("extensions", None) + expected = {"anchor": "archive-extraction-crosses-output-boundary"} + if identity_kind == "candidate": + finding["extensions"] = {"candidateId": "archive-candidate"} + expected = {"anchor": "archive-candidate"} + elif identity_kind == "authored": + finding["identity"] = {"anchor": "authored-anchor", "instance": "first-route"} + expected = finding["identity"].copy() + _, accepted, _ = accept_reducer(workbench_db, scan) + original = accepted.read_bytes() + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'discovery', " + "workflow_version = 'deep-security-scan/v2', manifest_path = NULL, " + "terminal_reason = NULL, completed_at = NULL" + ) + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None), + ) + published = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert len(published) == 1 + assert published[0]["identity"] == expected + assert published[0]["codeEvidence"] == finding["codeEvidence"] + assert published[0]["remediation"] == finding["remediation"] + assert accepted.read_bytes() == original + selection = json.loads( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + ) + assert selection["resultSha256"] == hashlib.sha256(original).hexdigest() + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" From e6a43b89944694cb36b19415669f1525b7ae0214 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 09:45:13 +0000 Subject: [PATCH 162/251] Preserve accepted findings with generated sibling identities --- .../scripts/workbench_saved_results.py | 27 ++- .../tests/test_budget_finding_identity.py | 229 ++++++++++++++++-- 2 files changed, 237 insertions(+), 19 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 07b9451fd6..fa80f37579 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -546,6 +546,30 @@ def _ensure_finding_identity(finding: Any, *, candidate_only: bool = False) -> N finding["identity"] = {"anchor": anchor} +def _ensure_finding_identities(findings: list[dict[str, Any]]) -> None: + def key(finding: dict[str, Any]) -> bytes: + identity = finding["identity"] + return _encoded( + [finding.get("ruleId"), identity.get("anchor"), identity.get("instance", "")] + ) + + # Reserve authored identities even when they follow a generated sibling. + reserved = { + key(finding) + for finding in findings + if isinstance(finding, dict) and isinstance(finding.get("identity"), dict) + } + for finding in findings: + if not isinstance(finding, dict) or "identity" in finding: + continue + _ensure_finding_identity(finding) + suffix = 2 + while key(finding) in reserved: + finding["identity"]["instance"] = f"saved-{suffix}" + suffix += 1 + reserved.add(key(finding)) + + def _retained_findings(finding: dict[str, Any]) -> Iterator[dict[str, Any]]: """Yield canonical and historical findings without trusting candidate IDs.""" pending = [finding] @@ -1314,8 +1338,7 @@ def budget_exhausted_draft( findings = { "findings": copy.deepcopy(accepted_result["findings"]) if accepted_result else [] } - for finding in findings["findings"]: - _ensure_finding_identity(finding) + _ensure_finding_identities(findings["findings"]) if accepted_result is not None: coverage = accepted_result["sourceCoverage"] if "threatModel" in accepted_result: diff --git a/plugins/codex-security/tests/test_budget_finding_identity.py b/plugins/codex-security/tests/test_budget_finding_identity.py index eb987554b2..44bcaec826 100644 --- a/plugins/codex-security/tests/test_budget_finding_identity.py +++ b/plugins/codex-security/tests/test_budget_finding_identity.py @@ -1,16 +1,38 @@ from __future__ import annotations +import copy import hashlib import json +import sqlite3 from argparse import Namespace import pytest from test_accepted_publication_references import accept_reducer from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_publication_stop_interleavings import published_bytes from test_workbench_db import BUDGET_COST +from workbench_test_support import run_workbench -@pytest.mark.parametrize("identity_kind", ["omitted", "candidate", "authored"]) +def prepare_budget_publication(connection, scan): + _, accepted, _ = accept_reducer(connection, scan) + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + with connection: + recipe = json.loads(connection.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + connection.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + connection.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'discovery', " + "workflow_version = 'deep-security-scan/v2', manifest_path = NULL, " + "terminal_reason = NULL, completed_at = NULL" + ) + return accepted, Namespace( + scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None + ) + + +@pytest.mark.parametrize("identity_kind", ["omitted", "candidate", "authored", "anchor-only"]) def test_budget_publication_identifies_accepted_semantic_findings( workbench_api, workbench_db, publication_scan, identity_kind ): @@ -26,23 +48,11 @@ def test_budget_publication_identifies_accepted_semantic_findings( elif identity_kind == "authored": finding["identity"] = {"anchor": "authored-anchor", "instance": "first-route"} expected = finding["identity"].copy() - _, accepted, _ = accept_reducer(workbench_db, scan) + elif identity_kind == "anchor-only": + finding["identity"] = expected = {"anchor": "authored-anchor"} + accepted, args = prepare_budget_publication(workbench_db, scan) original = accepted.read_bytes() - for name in ("scan-manifest.json", "findings.json", "coverage.json"): - (scan.scan_dir / name).unlink() - with workbench_db: - recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) - recipe["maxCostUsd"] = 0.005 - workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) - workbench_db.execute( - "UPDATE deep_scan_runs SET status = 'running', phase = 'discovery', " - "workflow_version = 'deep-security-scan/v2', manifest_path = NULL, " - "terminal_reason = NULL, completed_at = NULL" - ) - workbench_api["complete_budget_exhausted_scan"]( - workbench_db, - Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None), - ) + workbench_api["complete_budget_exhausted_scan"](workbench_db, args) published = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] assert len(published) == 1 assert published[0]["identity"] == expected @@ -54,3 +64,188 @@ def test_budget_publication_identifies_accepted_semantic_findings( ) assert selection["resultSha256"] == hashlib.sha256(original).hexdigest() assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + + +def add_sibling_repairs(scan, count=2): + first = scan.findings[0] + first["title"] = "Shared repair" + first.pop("identity", None) + first.pop("extensions", None) + scan.findings.extend(copy.deepcopy(first) for _ in range(count - 1)) + for index, finding in enumerate(scan.findings, 1): + finding["locations"] = [{"path": "subdir/extract.py", "startLine": index}] + finding["provenance"]["sourceFindingIds"] = [f"review-{index}:candidate-{index}"] + finding["provenance"]["sourceFindings"][0]["id"] = f"review-{index}:candidate-{index}" + finding["codeEvidence"][0].update( + startLine=index, endLine=index, code=f"repair_point_{index}()" + ) + finding["remediation"] = f"Apply distinct repair {index} at this location." + finding["remediationTests"] = [f"Check distinct repair {index}."] + + +@pytest.mark.parametrize( + "case", + [ + "generated", + "candidate", + "authored-first", + "authored-last", + "reserved-instance", + "authored-siblings", + "different-rules", + "different-titles", + ], +) +def test_budget_publication_keeps_generated_sibling_repairs( + workbench_api, workbench_db, publication_scan, case +): + scan = publication_scan() + add_sibling_repairs(scan, 4 if case == "reserved-instance" else 2) + if case == "candidate": + for finding in scan.findings: + finding["extensions"] = {"candidateId": "shared-candidate"} + elif case in {"authored-first", "authored-last"}: + scan.findings[0 if case == "authored-first" else 1]["identity"] = { + "anchor": "shared-repair" + } + elif case == "reserved-instance": + scan.findings[2]["identity"] = {"anchor": "shared-repair"} + scan.findings[3]["identity"] = {"anchor": "shared-repair", "instance": "saved-2"} + elif case == "authored-siblings": + for index, finding in enumerate(scan.findings): + finding["identity"] = {"anchor": "shared-repair", "instance": f"route-{index}"} + elif case == "different-rules": + scan.findings[1]["ruleId"] = "distinct-rule" + elif case == "different-titles": + scan.findings[1]["title"] = "Distinct repair" + originals = copy.deepcopy(scan.findings) + accepted, args = prepare_budget_publication(workbench_db, scan) + original = accepted.read_bytes() + + workbench_api["complete_budget_exhausted_scan"](workbench_db, args) + + published = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert len(published) == len(scan.findings) + assert len({finding["occurrenceId"] for finding in published}) == len(scan.findings) + for expected, actual in zip(scan.findings, published, strict=True): + for key in expected.keys() - {"findingId", "occurrenceId", "fingerprints"}: + assert actual[key] == expected[key] + if "identity" not in expected: + anchor = ( + "shared-candidate" + if case == "candidate" + else expected["title"].lower().replace(" ", "-") + ) + assert actual["identity"]["anchor"] == anchor + assert expected["remediation"] in (scan.scan_dir / "report.md").read_text() + if case in {"different-rules", "different-titles"}: + assert "instance" not in actual["identity"] + selection = workbench_db.execute( + "SELECT finalization_input_json FROM deep_scan_runs" + ).fetchone()[0] + assert json.loads(selection)["resultSha256"] == hashlib.sha256(original).hexdigest() + before = published_bytes(scan) + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert published_bytes(scan) == before + assert ( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + == selection + ) + assert accepted.read_bytes() == original + assert scan.findings == originals + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + + +@pytest.mark.parametrize("identity", [{}, {"instance": "first-route"}, None]) +def test_budget_publication_does_not_repair_authored_invalid_identity( + workbench_api, workbench_db, publication_scan, identity +): + scan = publication_scan() + scan.findings[0]["identity"] = identity + accepted, args = prepare_budget_publication(workbench_db, scan) + original = accepted.read_bytes() + with pytest.raises(SystemExit, match="identity"): + workbench_api["complete_budget_exhausted_scan"](workbench_db, args) + assert accepted.read_bytes() == original + assert scan.findings[0]["identity"] == identity + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] != "complete" + + +def test_budget_publication_rejects_duplicate_authored_identities( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + add_sibling_repairs(scan) + for finding in scan.findings: + finding["identity"] = {"anchor": "shared-repair", "instance": "authored-route"} + accepted, args = prepare_budget_publication(workbench_db, scan) + original = accepted.read_bytes() + with pytest.raises(SystemExit, match="duplicate occurrence identity"): + workbench_api["complete_budget_exhausted_scan"](workbench_db, args) + assert accepted.read_bytes() == original + + +@pytest.mark.parametrize("boundary", ["before-selection-commit", "after-selection-commit"]) +def test_budget_sibling_selection_replays_in_a_fresh_process( + workbench_api, workbench_db, publication_scan, tmp_path, monkeypatch, boundary +): + scan = publication_scan() + add_sibling_repairs(scan) + accepted, args = prepare_budget_publication(workbench_db, scan) + original = accepted.read_bytes() + budget = workbench_api["complete_budget_exhausted_scan"] + drafts = [] + + def interrupt(*args, **kwargs): + drafts.append(json.loads((scan.scan_dir / "findings.json").read_text())["findings"]) + raise RuntimeError("Synthetic publication interruption.") + + state = tmp_path / "state" + state.mkdir(exist_ok=True) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + workbench_db.backup(connection) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + with monkeypatch.context() as patch: + if boundary == "before-selection-commit": + patch.setattr(workbench_api["deep_scan"], "cancel_active_workers", interrupt) + else: + patch.setitem(budget.__globals__, "complete_scan_locked", interrupt) + with pytest.raises(RuntimeError, match="Synthetic publication interruption"): + budget(connection, args) + selection = connection.execute( + "SELECT finalization_input_json FROM deep_scan_runs" + ).fetchone()[0] + if boundary == "before-selection-commit": + assert selection is None + assert not (scan.scan_dir / "findings.json").exists() + else: + assert json.loads(selection)["resultSha256"] == hashlib.sha256(original).hexdigest() + run_workbench( + state, + "complete-budget-exhausted-scan", + "--scan-id", + scan.scan_id, + "--cost-json", + args.cost_json, + ) + published = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert [finding["identity"] for finding in published] == [ + finding["identity"] for finding in drafts[0] + ] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + replayed_selection = connection.execute( + "SELECT finalization_input_json FROM deep_scan_runs" + ).fetchone()[0] + if selection is not None: + assert replayed_selection == selection + assert ( + json.loads(replayed_selection)["resultSha256"] == hashlib.sha256(original).hexdigest() + ) + assert connection.execute("SELECT status FROM scans").fetchone()[0] == "complete" + before = published_bytes(scan) + run_workbench(state, "complete-scan", "--scan-id", scan.scan_id) + assert published_bytes(scan) == before + assert accepted.read_bytes() == original From d3d68a5a5a656b969f9f72ea1c42967396d7ad54 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 09:51:46 +0000 Subject: [PATCH 163/251] test: refresh Windows timings for long scan tests --- sdk/typescript/scripts/ci-test-durations.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/scripts/ci-test-durations.json b/sdk/typescript/scripts/ci-test-durations.json index 40c7b63d3a..4317429624 100644 --- a/sdk/typescript/scripts/ci-test-durations.json +++ b/sdk/typescript/scripts/ci-test-durations.json @@ -2,7 +2,9 @@ "sources": [ "https://github.com/openai/codex-security/actions/runs/33215429477", "https://github.com/openai/codex-security/actions/runs/33227512538", - "https://github.com/openai/codex-security/actions/runs/33233690024" + "https://github.com/openai/codex-security/actions/runs/33233690024", + "https://github.com/openai/codex-security/actions/runs/34813432568", + "https://github.com/openai/codex-security/actions/runs/34822813350" ], "unix": { "api-attribution-concurrency.test.ts": 0.372, @@ -158,6 +160,7 @@ "cost.test.ts": 1.574, "custom-publish.test.ts": 0.582, "custom-validation.test.ts": 20.415, + "deep-finalization.test.ts": 212.524, "deep-progress.test.ts": 0.007, "deep-scan-parent-denials.test.ts": 0.013, "deep-scan-reducer-recovery.test.ts": 0.527, @@ -209,6 +212,7 @@ "scan-history-renderer.test.ts": 0.005, "scan-logs.test.ts": 0.139, "scan-recovery.test.ts": 62.302, + "scan-resume.test.ts": 207.628, "skeleton.test.ts": 0.03, "stopped-scan-results.test.ts": 15.814, "targets-large-output.test.ts": 11.48, From 30c9664e165042e2ffa57c6b559f19d72e7d9339 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 11:19:04 +0000 Subject: [PATCH 164/251] Complete native Deep scans after observers detach --- plugins/codex-security/mcp-app/server.ts | 24 +-- .../mcp-app/src/deep-scan/coordinator.ts | 12 +- .../tests/test_deep_scan_coordinator.mjs | 82 +++++++- .../tests/test_deep_scan_stdio_lifecycle.mjs | 180 +++++++++++++++++- 4 files changed, 284 insertions(+), 14 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index a609c202cb..fe183f04a3 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -727,6 +727,14 @@ export function createCodexSecurityServer(): McpServer { } catch (error: unknown) { return toolErrorResult(deepScanInvocationFailureMessage(error)); } + const completeSelectedParent = async (run: DeepScanRunState) => { + if (run.finalizationInput && run.status === "succeeded") { + await runWorkbench([ + "complete-scan", "--scan-id", run.scanId, "--thread-id", threadId, + ...optionalArg("--claim-token", handoffClaimToken), + ]); + } + }; const preparation = await deepScanStartLock.run(async () => { // A joining observer does not need a usable current home. A new run, // however, must save its original settings before creation can commit. @@ -751,7 +759,10 @@ export function createCodexSecurityServer(): McpServer { }); } const immediate = deepScanTerminalResult(begun.run); - if (immediate) return { begun, immediate }; + const completingLocally = begun.run.status === "succeeded" + && begun.run.finalizationInput + && deepScanCoordinators.get(begun.run.scanId); + if (immediate && !completingLocally) return { begun, immediate }; const started = await startOrJoinDeepScanCoordinator({ begin: begun, registry: deepScanCoordinators, @@ -806,6 +817,7 @@ export function createCodexSecurityServer(): McpServer { ...(handoffClaimToken === undefined ? {} : { handoffClaimToken }) }, runWorkbench, signal, publication); }, + onFinalized: completeSelectedParent, onStopped: async (run) => { await runWorkbench([ "preserve-scan-results", "--scan-id", run.scanId, @@ -821,14 +833,6 @@ export function createCodexSecurityServer(): McpServer { invocationFailure: toolErrorResult(deepScanInvocationFailureMessage(error)) })); if ("invocationFailure" in preparation) return preparation.invocationFailure; - const completeSelectedParent = async (run: DeepScanRunState) => { - if (run.finalizationInput && run.status === "succeeded") { - await runWorkbench([ - "complete-scan", "--scan-id", run.scanId, "--thread-id", threadId, - ...optionalArg("--claim-token", handoffClaimToken), - ]); - } - }; if (preparation.immediate) { try { await completeSelectedParent(preparation.begun.run); } catch (error) { return toolErrorResult(deepScanInvocationFailureMessage(error)); } @@ -839,8 +843,6 @@ export function createCodexSecurityServer(): McpServer { logDeepScanEvent({ event: "coordinator_joined", scanId: begun.run.scanId }); } const terminal = await coordinator.wait(abortSignalFromExtra(extra)); - try { await completeSelectedParent(terminal); } - catch (error) { return toolErrorResult(deepScanInvocationFailureMessage(error)); } const result = deepScanTerminalResult(terminal); if (!result) { return toolErrorResult(deepScanInvocationFailureMessage( diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 29e1536f8d..15f39eb2b1 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -69,6 +69,8 @@ export interface CoordinatorOptions { heartbeatIntervalMs?: number; observeReplacement?: (run: DeepScanRunState) => Promise; onComplete?: (draft: ScanDraftInput, signal: AbortSignal, publication: DeepScanPublication) => Promise; + /** Complete the enclosing scan after selected publication, before local waiters settle. */ + onFinalized?: (run: DeepScanRunState) => Promise; onStopped?: (run: DeepScanRunState) => Promise; } @@ -436,6 +438,9 @@ export class DeepScanCoordinator { publish: async (...args) => { await this.options.onComplete?.(...args); }, finish: (input) => this.options.store.finish(input), }); + if (this.canceled || this.externallyFailed) return; + await this.options.onFinalized?.(cloneState(this.state)); + if (this.canceled || this.externallyFailed) return; this.finishLocally(this.state); } @@ -562,7 +567,12 @@ export class DeepScanCoordinator { && this.state.coordinatorGeneration !== undefined && current.coordinatorGeneration > this.state.coordinatorGeneration ); - if (current.status === "running" && !replacementConfirmed) { + // finish-deep-scan commits before the enclosing scan's finalizer. Its own + // succeeded generation is still ours until that finalizer settles. + const completingSelectedParent = current.status === "succeeded" + && this.state.finalizationInput !== undefined + && current.coordinatorGeneration === this.state.coordinatorGeneration; + if (!replacementConfirmed && (current.status === "running" || completingSelectedParent)) { // A selection response can be lost after its transaction commits. if (current.finalizationInput) this.state = { ...this.state, finalizationInput: current.finalizationInput, terminalReason: current.terminalReason }; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs index 3625954d91..48395fd58e 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs @@ -25,6 +25,84 @@ const { `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); const temporaryRoots = []; +async function testSelectedFinalizationOwner() { + for (const failure of [undefined, "draft", "finish", "parent", "replacement"]) { + const fixture = await fixtureRun({ workers: 1, subagents: 0, stopAfterNoNew: 1, maxDiscoveryRuns: 1 }); + fixture.run.workflowVersion = "deep-security-scan/v2"; + fixture.run.coordinatorGeneration = 3; + fixture.run.finalizationInput = { + version: 1, resultPath: null, resultSha256: null, terminalReason: "capped", + omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z", + }; + const store = new FakeStore(fixture.run); + const finishStarted = deferred(); + const releaseFinish = deferred(); + const parentStarted = deferred(); + const releaseParent = deferred(); + const calls = []; + const events = []; + store.finish = async () => { + calls.push("finish"); + finishStarted.resolve(); + await releaseFinish.promise; + if (failure === "finish") throw new Error("injected selected finish failure"); + if (failure === "replacement") { + store.run = { ...store.run, status: "succeeded", coordinatorGeneration: 4 }; + throw new Error("Deep Scan coordinator generation is stale."); + } + store.run = { ...store.run, status: "succeeded", terminalReason: "capped" }; + return structuredClone(store.run); + }; + const coordinator = new DeepScanCoordinator({ + run: fixture.run, store, pluginRoot: fixture.pluginRoot, + executor: { run: async () => assert.fail("Selected finalization cannot start model work") }, + threadId: "selected-owner", log: (event) => events.push(event), + onComplete: async () => { + calls.push("draft"); + if (failure === "draft") throw new Error("injected selected draft failure"); + }, + onFinalized: async (run) => { + calls.push("parent"); + assert.equal(run.status, "succeeded"); + assert.equal(run.coordinatorGeneration, 3); + parentStarted.resolve(); + await releaseParent.promise; + if (failure === "parent") throw new Error("injected selected parent failure"); + }, + }); + // Rejections stay observed even when the user detaches their only waiter. + const settled = coordinator.settled(); + settled.catch(() => {}); + const observer = new AbortController(); + coordinator.start(); + const detached = coordinator.wait(observer.signal); + observer.abort(); + await assert.rejects(detached, { name: "AbortError" }); + if (failure !== "draft") { + await finishStarted.promise; + assert.deepEqual(calls, ["draft", "finish"]); + assert.equal(await coordinator.wait(undefined, 10), undefined); + releaseFinish.resolve(); + } + if (failure === undefined || failure === "parent") { + await parentStarted.promise; + assert.equal(await coordinator.wait(undefined, 10), undefined, "wait includes enclosing completion"); + releaseParent.resolve(); + } + if (failure && failure !== "replacement") { + await assert.rejects(settled, new RegExp(`injected selected ${failure} failure`)); + assert.equal(store.failCalls, 0, "publication errors retain the original selected stop"); + assert.equal(events.some((event) => event.event === "coordinator_publication_pending"), true); + assert.equal(store.run.status, failure === "parent" ? "succeeded" : "running"); + } else { + assert.equal((await settled).status, "succeeded"); + } + assert.deepEqual(calls, failure === "draft" ? ["draft"] + : failure === "finish" || failure === "replacement" ? ["draft", "finish"] + : ["draft", "finish", "parent"]); + assert.deepEqual(store.run.finalizationInput, fixture.run.finalizationInput); + } +} async function testCappedQueueAndSerialDedup() { const fixture = await fixtureRun({ workers: 3, subagents: 2, stopAfterNoNew: 10, maxDiscoveryRuns: 5 }); const store = new FakeStore(fixture.run); @@ -39,7 +117,8 @@ async function testCappedQueueAndSerialDedup() { retryDelaysMs: [1, 3, 9], clock: immediateClock, handoffClaimToken: "claim-fixture", - onComplete: async (draft) => completedDrafts.push(structuredClone(draft)) + onComplete: async (draft) => completedDrafts.push(structuredClone(draft)), + onFinalized: async () => assert.fail("Legacy scans retain explicit parent completion") }); coordinator.start(); const terminal = await coordinator.wait(undefined, 5_000); @@ -3935,6 +4014,7 @@ function boundedFixtureErrorText(message, maximum) { } try { + await testSelectedFinalizationOwner(); await testCappedQueueAndSerialDedup(); await testStandardWorkersReceiveExistingFalsePositiveFeedback(); await testDiscoveryWorkersKeepOneContextAfterPersistedUpdate(); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index e5e5407589..f408e6d051 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -33,9 +33,170 @@ const parentSandboxState = { if (process.platform === "win32") { console.log("deep scan stdio lifecycle test skipped on Windows (POSIX fake Codex executable)"); } else { + for (const mode of ["detached", "joined", "remote", "failure", "lost-response"]) { + await testDeepScanDetachedCompletion(mode); + } await testDeepScanStdioLifecycle(); } +async function testDeepScanDetachedCompletion(mode) { + const fixtureRoot = await mkdtemp(path.join(tmpdir(), "codex-security-deep-detached-")); + const targetPath = path.join(fixtureRoot, "target"); + const stateDir = path.join(fixtureRoot, "state"); + const scanRoot = path.join(fixtureRoot, "scans"); + const codexHome = path.join(fixtureRoot, "codex-home"); + const startLogPath = path.join(fixtureRoot, "started.jsonl"); + const exitLogPath = path.join(fixtureRoot, "exited.jsonl"); + const controlPath = path.join(fixtureRoot, "completion-control"); + const finalizerControlPath = path.join(fixtureRoot, "finalizer-control"); + const finalizerLogPath = path.join(fixtureRoot, "finalizer.jsonl"); + const pythonWrapperPath = path.join(fixtureRoot, "python-wrapper.mjs"); + const fakeCodexPath = path.join(fixtureRoot, "fake-codex.mjs"); + const serverBundlePath = path.join(pluginRoot, "mcp", + installedPluginRoot ? "server.mjs" : `.deep-scan-detached-${randomUUID()}.cjs`); + const threadId = "deep-scan-detached-result-conversation"; + for (const directory of [targetPath, stateDir, scanRoot, path.join(codexHome, "codex-security")]) { + await mkdir(directory, { recursive: true }); + } + await writeFile(path.join(targetPath, "fixture.py"), "print('fixture')\n"); + await writeFile(path.join(codexHome, "codex-security", "config.toml"), + "[deep_scan]\nworkers = 1\nsubagents = 0\nstop_after_no_new = 1\nmax_discovery_runs = 2\n"); + await writeFile(controlPath, "wait-for-completion"); + await writePythonWrapper(pythonWrapperPath); + if (mode !== "detached") await writeFile(finalizerControlPath, mode === "joined" || mode === "remote" ? "wait" : mode); + await writeFakeCodex(fakeCodexPath); + if (!installedPluginRoot) await bundleServer(serverBundlePath); + const environment = { + ...process.env, + OPENAI_API_KEY: "synthetic-stdio-key", CODEX_API_KEY: "", + CODEX_CLI_PATH: fakeCodexPath, CODEX_HOME: codexHome, + CODEX_SECURITY_SCAN_ROOT: scanRoot, CODEX_SECURITY_STATE_DIR: stateDir, + FAKE_CODEX_START_LOG: startLogPath, FAKE_CODEX_EXIT_LOG: exitLogPath, + FAKE_CODEX_RESTART_CONTROL: controlPath, + PYTHON: pythonWrapperPath, REAL_PYTHON: process.env.PYTHON?.trim() || "python3", + FAKE_WORKBENCH_FINALIZER_CONTROL: finalizerControlPath, + FAKE_WORKBENCH_FINALIZER_LOG: finalizerLogPath, + FAKE_CODEX_SIGNAL_CHECKPOINT_CONTROL: path.join(fixtureRoot, "unused-signal-control"), + }; + const server = startServer(serverBundlePath, environment); + let remote; + try { + assertNoError(await server.request(1, "initialize", { + protocolVersion: "2025-11-25", capabilities: {}, + clientInfo: { name: "deep-scan-detached-completion", version: "0.1.0" }, + })); + server.sendRequest(2, "tools/call", toolCall("start_codex_security_deep_scan", + { targetPath, scope: ".", userContext: "Original discovery input" }, threadId)); + const scanId = await waitForScanId({ server, requestId: 2 }); + await waitForDeepScanWorker({ environment, scanId, threadId }); + const [worker] = await waitForJsonLines(startLogPath, 1); + if (mode === "joined") { + server.sendRequest(3, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); + await waitFor(() => server.stderrEvents().some((event) => event.event === "coordinator_joined"), + "second observer to join"); + } + if (mode === "remote") { + remote = startServer(serverBundlePath, environment); + assertNoError(await remote.request(1, "initialize", { + protocolVersion: "2025-11-25", capabilities: {}, + clientInfo: { name: "remote-observer", version: "0.1.0" }, + })); + remote.sendRequest(2, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); + await waitFor(() => remote.stderrEvents().some((event) => event.event === "coordinator_joined"), + "remote request to observe the existing owner"); + } + server.notify("notifications/cancelled", { requestId: 2, reason: "detach original observer" }); + await delay(150); + const detached = await getDeepScan({ environment, scanId, threadId }); + const originalPublicScan = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + assert.equal(detached.status, "running"); + assert.equal(detached.cancelRequested, false); + assertProcessAlive(worker.pid); + assertProcessAlive(server.pid); + + // The already-owned workers proceed after observation ends. + await writeFile(controlPath, "after-restart"); + let finished; + await waitFor(async () => { + finished = await getDeepScan({ environment, scanId, threadId }); + return finished.status === "succeeded"; + }, "detached discovery and reducer to finish"); + assert.equal(finished.workflowVersion, "deep-security-scan/v2"); + assert.equal(finished.finalizationInput.version, 1); + assert.equal(finished.coordinatorGeneration, detached.coordinatorGeneration); + assert.equal(finished.userContext, detached.userContext); + assert.deepEqual(finished.usageOwner, detached.usageOwner); + assert.equal(finished.workers.filter((row) => row.kind === "discovery" && row.status === "succeeded").length, 2); + assert.equal(finished.workers.filter((row) => row.kind === "dedup" && row.status === "succeeded").length, 1); + if (mode === "joined") { + await waitForJsonLines(finalizerLogPath, 1); + // Rejoin while finish-deep-scan is committed but public sealing is blocked. + server.sendRequest(4, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); + await waitFor(() => server.stderrEvents().filter((event) => event.event === "coordinator_joined").length === 2, + "observer to join pending public completion"); + await delay(5_100); // Exercise an actual coordinator heartbeat during finalization. + assert.equal(server.response(3), undefined); + assert.equal(server.response(4), undefined); + assert.equal((await runWorkbench(environment, ["get-scan", "--scan-id", scanId])).scan.progress.status, "running"); + assert.equal((await readJsonLines(finalizerLogPath)).length, 1); + await rm(finalizerControlPath); + for (const id of [3, 4]) assertNoError(await server.waitForResponse(id)); + } + if (mode === "remote") { + // A remote observer retains the aggregate-ready response. It cannot run + // the owning process's public finalizer, even after Deep itself succeeds. + const observed = await remote.waitForResponse(2); + assertNoError(observed); + assert.equal(observed.result.structuredContent.manifestPath, path.join(finished.scanDir, "scan-manifest.json")); + assert.equal((await readJsonLines(finalizerLogPath)).length, 1); + await rm(finalizerControlPath); + } + if (mode === "failure" || mode === "lost-response") { + await waitFor(() => server.stderrEvents().some((event) => event.event === "coordinator_publication_pending"), + "public finalization failure to remain pending"); + const pending = await getDeepScan({ environment, scanId, threadId }); + assert.equal(pending.status, "succeeded"); + assert.equal(pending.terminalReason, finished.terminalReason); + assert.deepEqual(pending.finalizationInput, finished.finalizationInput); + assert.equal((await readJsonLines(finalizerLogPath)).length, 1, "the owner does not add a retry layer"); + const beforeReplay = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + assert.equal(beforeReplay.scan.progress.status, mode === "failure" ? "running" : "complete"); + const manifestBeforeReplay = await readFile(path.join(finished.scanDir, "scan-manifest.json")); + const rejoined = await server.request(5, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); + assertNoError(rejoined); + assert.equal(rejoined.result.structuredContent.manifestPath, path.join(finished.scanDir, "scan-manifest.json")); + if (mode === "lost-response") { + assert.deepEqual(await readFile(path.join(finished.scanDir, "scan-manifest.json")), manifestBeforeReplay); + } + assert.deepEqual((await getDeepScan({ environment, scanId, threadId })).finalizationInput, finished.finalizationInput); + } + let publicScan; + await waitFor(async () => { + publicScan = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + return publicScan.scan.progress.status === "complete"; + }, "public completion without another observer"); + assert.equal(publicScan.scan.progress.status, "complete"); + const manifest = JSON.parse(await readFile(path.join(finished.scanDir, "scan-manifest.json"), "utf8")); + assert.equal(typeof manifest.scan.sealedAt, "string"); + assert.equal(typeof manifest.scan.completedAt, "string"); + assert.equal(publicScan.scan.continuationThreadId, originalPublicScan.scan.continuationThreadId); + assert.deepEqual(publicScan.scan.executionAttribution.owner, originalPublicScan.scan.executionAttribution.owner); + assert.equal(publicScan.scan.executionAttribution.owner.threadId, threadId); + assert.equal((await readJsonLines(startLogPath)).length, 3, "completion and replay launch no extra model workers"); + assert.equal((await readJsonLines(finalizerLogPath)).length, mode === "failure" || mode === "lost-response" ? 2 : 1); + assertProcessAlive(server.pid); + console.log("native selected completion passed", mode, scanId); + } catch (error) { + error.message += `\nMCP stderr:\n${server.stderrText()}`; + throw error; + } finally { + await remote?.stop(); + await server.stop(); + if (!installedPluginRoot) await rm(serverBundlePath, { force: true }); + await rm(fixtureRoot, { recursive: true, force: true }); + } +} + async function testDeepScanStdioLifecycle() { const fixtureRoot = await mkdtemp(path.join(tmpdir(), "codex-security-deep-stdio-")); const targetPath = path.join(fixtureRoot, "target"); @@ -847,6 +1008,9 @@ async function writeFakeCodex(executablePath) { "const root = process.argv[process.argv.indexOf('--cd') + 1];", "appendFileSync(process.env.FAKE_CODEX_START_LOG, JSON.stringify({ pid: process.pid, argv: process.argv.slice(2), stdin, hasExpectedApiKey: process.env.CODEX_API_KEY === 'synthetic-stdio-key' }) + '\\n');", "console.log(JSON.stringify({ type: 'thread.started', thread_id: `stdio-fixture-${process.pid}` }));", + "while (existsSync(process.env.FAKE_CODEX_RESTART_CONTROL) && readFileSync(process.env.FAKE_CODEX_RESTART_CONTROL, 'utf8') === 'wait-for-completion') {", + " await new Promise((resolve) => setTimeout(resolve, 25));", + "}", "if (existsSync(process.env.FAKE_CODEX_RESTART_CONTROL)) {", " const phase = readFileSync(process.env.FAKE_CODEX_RESTART_CONTROL, 'utf8');", " if (phase === 'after-restart' || context.workerLabel === 'discovery-0001') {", @@ -886,7 +1050,7 @@ async function writeFakeCodex(executablePath) { async function writePythonWrapper(executablePath) { await writeFile(executablePath, [ "#!/usr/bin/env node", - 'import { appendFileSync, existsSync, unlinkSync } from "node:fs";', + 'import { appendFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";', 'import { spawnSync } from "node:child_process";', "const args = process.argv.slice(2);", "const control = process.env.FAKE_WORKBENCH_CANCEL_FAILURE_CONTROL;", @@ -898,8 +1062,22 @@ async function writePythonWrapper(executablePath) { " console.error('injected cancel-scan failure');", " process.exit(1);", "}", + "let finalizerMode;", + "if (args[1] === 'complete-scan' && process.env.FAKE_WORKBENCH_FINALIZER_LOG) {", + " appendFileSync(process.env.FAKE_WORKBENCH_FINALIZER_LOG, JSON.stringify(args) + '\\n');", + " const finalizerControl = process.env.FAKE_WORKBENCH_FINALIZER_CONTROL;", + " while (existsSync(finalizerControl) && readFileSync(finalizerControl, 'utf8') === 'wait') {", + " await new Promise((resolve) => setTimeout(resolve, 25));", + " }", + " if (existsSync(finalizerControl)) {", + " finalizerMode = readFileSync(finalizerControl, 'utf8');", + " unlinkSync(finalizerControl);", + " }", + " if (finalizerMode === 'failure') { console.error('injected complete-scan failure'); process.exit(1); }", + "}", "const result = spawnSync(process.env.REAL_PYTHON || 'python3', args, { stdio: 'inherit' });", "if (result.error) throw result.error;", + "if (result.status === 0 && finalizerMode === 'lost-response') { console.error('injected lost completion response'); process.exit(1); }", "process.exit(result.status ?? 1);", "", ].join("\n")); From 0e89f3882d6b6084eb50369191012f9fb7cb7cfd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 11:21:09 +0000 Subject: [PATCH 165/251] Keep native finalization cases in the publication test helper --- .../tests/deep_scan_publication_cases.mjs | 80 +++++++++++++++++++ .../tests/test_deep_scan_coordinator.mjs | 79 ------------------ 2 files changed, 80 insertions(+), 79 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs index 9afb20ddfc..ea36d9f672 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_publication_cases.mjs @@ -7,6 +7,85 @@ export async function testDeepScanPublication({ fixtureRun, FakeStore, FakeExecutor, DeepScanCoordinator, deferred, immediateClock, eventually, }) { + async function testSelectedFinalizationOwner() { + for (const failure of [undefined, "draft", "finish", "parent", "replacement"]) { + const fixture = await fixtureRun({ workers: 1, subagents: 0, stopAfterNoNew: 1, maxDiscoveryRuns: 1 }); + fixture.run.workflowVersion = "deep-security-scan/v2"; + fixture.run.coordinatorGeneration = 3; + fixture.run.finalizationInput = { + version: 1, resultPath: null, resultSha256: null, terminalReason: "capped", + omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z", + }; + const store = new FakeStore(fixture.run); + const finishStarted = deferred(); + const releaseFinish = deferred(); + const parentStarted = deferred(); + const releaseParent = deferred(); + const calls = []; + const events = []; + store.finish = async () => { + calls.push("finish"); + finishStarted.resolve(); + await releaseFinish.promise; + if (failure === "finish") throw new Error("injected selected finish failure"); + if (failure === "replacement") { + store.run = { ...store.run, status: "succeeded", coordinatorGeneration: 4 }; + throw new Error("Deep Scan coordinator generation is stale."); + } + store.run = { ...store.run, status: "succeeded", terminalReason: "capped" }; + return structuredClone(store.run); + }; + const coordinator = new DeepScanCoordinator({ + run: fixture.run, store, pluginRoot: fixture.pluginRoot, + executor: { run: async () => assert.fail("Selected finalization cannot start model work") }, + threadId: "selected-owner", log: (event) => events.push(event), + onComplete: async () => { + calls.push("draft"); + if (failure === "draft") throw new Error("injected selected draft failure"); + }, + onFinalized: async (run) => { + calls.push("parent"); + assert.equal(run.status, "succeeded"); + assert.equal(run.coordinatorGeneration, 3); + parentStarted.resolve(); + await releaseParent.promise; + if (failure === "parent") throw new Error("injected selected parent failure"); + }, + }); + // Rejections stay observed even when the user detaches their only waiter. + const settled = coordinator.settled(); + settled.catch(() => {}); + const observer = new AbortController(); + coordinator.start(); + const detached = coordinator.wait(observer.signal); + observer.abort(); + await assert.rejects(detached, { name: "AbortError" }); + if (failure !== "draft") { + await finishStarted.promise; + assert.deepEqual(calls, ["draft", "finish"]); + assert.equal(await coordinator.wait(undefined, 10), undefined); + releaseFinish.resolve(); + } + if (failure === undefined || failure === "parent") { + await parentStarted.promise; + assert.equal(await coordinator.wait(undefined, 10), undefined, "wait includes enclosing completion"); + releaseParent.resolve(); + } + if (failure && failure !== "replacement") { + await assert.rejects(settled, new RegExp(`injected selected ${failure} failure`)); + assert.equal(store.failCalls, 0, "publication errors retain the original selected stop"); + assert.equal(events.some((event) => event.event === "coordinator_publication_pending"), true); + assert.equal(store.run.status, failure === "parent" ? "succeeded" : "running"); + } else { + assert.equal((await settled).status, "succeeded"); + } + assert.deepEqual(calls, failure === "draft" ? ["draft"] + : failure === "finish" || failure === "replacement" ? ["draft", "finish"] + : ["draft", "finish", "parent"]); + assert.deepEqual(store.run.finalizationInput, fixture.run.finalizationInput); + } + } + async function testSaturationOmitsWorkerAcceptedDuringCancellation() { const fixture = await fixtureRun({ workers: 3, subagents: 0, stopAfterNoNew: 2, maxDiscoveryRuns: 3 }); const store = new FakeStore(fixture.run); @@ -208,6 +287,7 @@ export async function testDeepScanPublication({ }]); } + await testSelectedFinalizationOwner(); await testSaturationOmitsWorkerAcceptedDuringCancellation(); await testSuccessfulDeepCoveragePreservesWorkerReviewStatus(); await testSaturationIgnoresDiscoveryCancellationWriteFailure(); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs index 48395fd58e..80a3e94db4 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.mjs @@ -25,84 +25,6 @@ const { `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); const temporaryRoots = []; -async function testSelectedFinalizationOwner() { - for (const failure of [undefined, "draft", "finish", "parent", "replacement"]) { - const fixture = await fixtureRun({ workers: 1, subagents: 0, stopAfterNoNew: 1, maxDiscoveryRuns: 1 }); - fixture.run.workflowVersion = "deep-security-scan/v2"; - fixture.run.coordinatorGeneration = 3; - fixture.run.finalizationInput = { - version: 1, resultPath: null, resultSha256: null, terminalReason: "capped", - omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z", - }; - const store = new FakeStore(fixture.run); - const finishStarted = deferred(); - const releaseFinish = deferred(); - const parentStarted = deferred(); - const releaseParent = deferred(); - const calls = []; - const events = []; - store.finish = async () => { - calls.push("finish"); - finishStarted.resolve(); - await releaseFinish.promise; - if (failure === "finish") throw new Error("injected selected finish failure"); - if (failure === "replacement") { - store.run = { ...store.run, status: "succeeded", coordinatorGeneration: 4 }; - throw new Error("Deep Scan coordinator generation is stale."); - } - store.run = { ...store.run, status: "succeeded", terminalReason: "capped" }; - return structuredClone(store.run); - }; - const coordinator = new DeepScanCoordinator({ - run: fixture.run, store, pluginRoot: fixture.pluginRoot, - executor: { run: async () => assert.fail("Selected finalization cannot start model work") }, - threadId: "selected-owner", log: (event) => events.push(event), - onComplete: async () => { - calls.push("draft"); - if (failure === "draft") throw new Error("injected selected draft failure"); - }, - onFinalized: async (run) => { - calls.push("parent"); - assert.equal(run.status, "succeeded"); - assert.equal(run.coordinatorGeneration, 3); - parentStarted.resolve(); - await releaseParent.promise; - if (failure === "parent") throw new Error("injected selected parent failure"); - }, - }); - // Rejections stay observed even when the user detaches their only waiter. - const settled = coordinator.settled(); - settled.catch(() => {}); - const observer = new AbortController(); - coordinator.start(); - const detached = coordinator.wait(observer.signal); - observer.abort(); - await assert.rejects(detached, { name: "AbortError" }); - if (failure !== "draft") { - await finishStarted.promise; - assert.deepEqual(calls, ["draft", "finish"]); - assert.equal(await coordinator.wait(undefined, 10), undefined); - releaseFinish.resolve(); - } - if (failure === undefined || failure === "parent") { - await parentStarted.promise; - assert.equal(await coordinator.wait(undefined, 10), undefined, "wait includes enclosing completion"); - releaseParent.resolve(); - } - if (failure && failure !== "replacement") { - await assert.rejects(settled, new RegExp(`injected selected ${failure} failure`)); - assert.equal(store.failCalls, 0, "publication errors retain the original selected stop"); - assert.equal(events.some((event) => event.event === "coordinator_publication_pending"), true); - assert.equal(store.run.status, failure === "parent" ? "succeeded" : "running"); - } else { - assert.equal((await settled).status, "succeeded"); - } - assert.deepEqual(calls, failure === "draft" ? ["draft"] - : failure === "finish" || failure === "replacement" ? ["draft", "finish"] - : ["draft", "finish", "parent"]); - assert.deepEqual(store.run.finalizationInput, fixture.run.finalizationInput); - } -} async function testCappedQueueAndSerialDedup() { const fixture = await fixtureRun({ workers: 3, subagents: 2, stopAfterNoNew: 10, maxDiscoveryRuns: 5 }); const store = new FakeStore(fixture.run); @@ -4014,7 +3936,6 @@ function boundedFixtureErrorText(message, maximum) { } try { - await testSelectedFinalizationOwner(); await testCappedQueueAndSerialDedup(); await testStandardWorkersReceiveExistingFalsePositiveFeedback(); await testDiscoveryWorkersKeepOneContextAfterPersistedUpdate(); From d80ca3a6e3fca8360e632d7d9ad8c83938a1266f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 11:26:49 +0000 Subject: [PATCH 166/251] Wait for the owning finalizer in the remote observer control --- .../mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index f408e6d051..917b369330 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -146,6 +146,7 @@ async function testDeepScanDetachedCompletion(mode) { // A remote observer retains the aggregate-ready response. It cannot run // the owning process's public finalizer, even after Deep itself succeeds. const observed = await remote.waitForResponse(2); + await waitForJsonLines(finalizerLogPath, 1); assertNoError(observed); assert.equal(observed.result.structuredContent.manifestPath, path.join(finished.scanDir, "scan-manifest.json")); assert.equal((await readJsonLines(finalizerLogPath)).length, 1); From 652298cd96678113f593b0be47f00995aa1958d1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 11:35:30 +0000 Subject: [PATCH 167/251] Preserve native completion errors for active observers --- plugins/codex-security/mcp-app/server.ts | 7 ++++++- .../tests/test_deep_scan_stdio_lifecycle.mjs | 20 +++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index fe183f04a3..800722ec6c 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -817,7 +817,12 @@ export function createCodexSecurityServer(): McpServer { ...(handoffClaimToken === undefined ? {} : { handoffClaimToken }) }, runWorkbench, signal, publication); }, - onFinalized: completeSelectedParent, + onFinalized: async (run) => { + try { await completeSelectedParent(run); } + catch (error) { + throw new Error(deepScanInvocationFailureMessage(error), { cause: error }); + } + }, onStopped: async (run) => { await runWorkbench([ "preserve-scan-results", "--scan-id", run.scanId, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 917b369330..97b631dbcd 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -33,7 +33,7 @@ const parentSandboxState = { if (process.platform === "win32") { console.log("deep scan stdio lifecycle test skipped on Windows (POSIX fake Codex executable)"); } else { - for (const mode of ["detached", "joined", "remote", "failure", "lost-response"]) { + for (const mode of ["detached", "joined", "remote", "failure", "active-failure", "lost-response"]) { await testDeepScanDetachedCompletion(mode); } await testDeepScanStdioLifecycle(); @@ -63,7 +63,7 @@ async function testDeepScanDetachedCompletion(mode) { "[deep_scan]\nworkers = 1\nsubagents = 0\nstop_after_no_new = 1\nmax_discovery_runs = 2\n"); await writeFile(controlPath, "wait-for-completion"); await writePythonWrapper(pythonWrapperPath); - if (mode !== "detached") await writeFile(finalizerControlPath, mode === "joined" || mode === "remote" ? "wait" : mode); + if (mode !== "detached") await writeFile(finalizerControlPath, mode === "joined" || mode === "remote" ? "wait" : mode === "active-failure" ? "failure" : mode); await writeFakeCodex(fakeCodexPath); if (!installedPluginRoot) await bundleServer(serverBundlePath); const environment = { @@ -105,7 +105,7 @@ async function testDeepScanDetachedCompletion(mode) { await waitFor(() => remote.stderrEvents().some((event) => event.event === "coordinator_joined"), "remote request to observe the existing owner"); } - server.notify("notifications/cancelled", { requestId: 2, reason: "detach original observer" }); + if (mode !== "active-failure") server.notify("notifications/cancelled", { requestId: 2, reason: "detach original observer" }); await delay(150); const detached = await getDeepScan({ environment, scanId, threadId }); const originalPublicScan = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); @@ -152,16 +152,24 @@ async function testDeepScanDetachedCompletion(mode) { assert.equal((await readJsonLines(finalizerLogPath)).length, 1); await rm(finalizerControlPath); } - if (mode === "failure" || mode === "lost-response") { + if (mode === "failure" || mode === "active-failure" || mode === "lost-response") { await waitFor(() => server.stderrEvents().some((event) => event.event === "coordinator_publication_pending"), "public finalization failure to remain pending"); + if (mode === "active-failure") { + const failed = await server.waitForResponse(2); + assert.equal(failed.result?.isError, true); + const message = failed.result.content.map((item) => item.text).join(" "); + assert.match(message, /injected complete-scan failure/); + assert.match(message, /Do not call start_codex_security_deep_scan again in this response/); + assert.match(message, /no final|Do not.*final/i); + } const pending = await getDeepScan({ environment, scanId, threadId }); assert.equal(pending.status, "succeeded"); assert.equal(pending.terminalReason, finished.terminalReason); assert.deepEqual(pending.finalizationInput, finished.finalizationInput); assert.equal((await readJsonLines(finalizerLogPath)).length, 1, "the owner does not add a retry layer"); const beforeReplay = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); - assert.equal(beforeReplay.scan.progress.status, mode === "failure" ? "running" : "complete"); + assert.equal(beforeReplay.scan.progress.status, mode !== "lost-response" ? "running" : "complete"); const manifestBeforeReplay = await readFile(path.join(finished.scanDir, "scan-manifest.json")); const rejoined = await server.request(5, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); assertNoError(rejoined); @@ -184,7 +192,7 @@ async function testDeepScanDetachedCompletion(mode) { assert.deepEqual(publicScan.scan.executionAttribution.owner, originalPublicScan.scan.executionAttribution.owner); assert.equal(publicScan.scan.executionAttribution.owner.threadId, threadId); assert.equal((await readJsonLines(startLogPath)).length, 3, "completion and replay launch no extra model workers"); - assert.equal((await readJsonLines(finalizerLogPath)).length, mode === "failure" || mode === "lost-response" ? 2 : 1); + assert.equal((await readJsonLines(finalizerLogPath)).length, mode === "failure" || mode === "active-failure" || mode === "lost-response" ? 2 : 1); assertProcessAlive(server.pid); console.log("native selected completion passed", mode, scanId); } catch (error) { From 21f92f67dfa62ea11a7a7b5f4f26c5f52f765439 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:32:04 +0000 Subject: [PATCH 168/251] fix(deep): keep parent finalization with its execution owner --- plugins/codex-security/mcp-app/server.ts | 66 ++++++++++--------- .../mcp-app/src/deep-scan/coordinator.ts | 4 +- .../mcp-app/src/deep-scan/store.ts | 3 +- sdk/typescript/src/api.ts | 4 +- sdk/typescript/src/scan-sessions.ts | 7 +- 5 files changed, 48 insertions(+), 36 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 800722ec6c..2f3cf87a4f 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -727,14 +727,6 @@ export function createCodexSecurityServer(): McpServer { } catch (error: unknown) { return toolErrorResult(deepScanInvocationFailureMessage(error)); } - const completeSelectedParent = async (run: DeepScanRunState) => { - if (run.finalizationInput && run.status === "succeeded") { - await runWorkbench([ - "complete-scan", "--scan-id", run.scanId, "--thread-id", threadId, - ...optionalArg("--claim-token", handoffClaimToken), - ]); - } - }; const preparation = await deepScanStartLock.run(async () => { // A joining observer does not need a usable current home. A new run, // however, must save its original settings before creation can commit. @@ -758,11 +750,14 @@ export function createCodexSecurityServer(): McpServer { threadId }); } - const immediate = deepScanTerminalResult(begun.run); + // Recipes identify SDK executions, including runs predating usageOwner. + const sdkOwned = begun.run.usageOwner?.dedicated + ?? ((await runWorkbench(["get-scan", "--scan-id", begun.run.scanId])).recipe != null); + const immediate = deepScanTerminalResult(begun.run, sdkOwned); const completingLocally = begun.run.status === "succeeded" && begun.run.finalizationInput && deepScanCoordinators.get(begun.run.scanId); - if (immediate && !completingLocally) return { begun, immediate }; + if (immediate && !completingLocally) return { begun, immediate, sdkOwned }; const started = await startOrJoinDeepScanCoordinator({ begin: begun, registry: deepScanCoordinators, @@ -817,8 +812,15 @@ export function createCodexSecurityServer(): McpServer { ...(handoffClaimToken === undefined ? {} : { handoffClaimToken }) }, runWorkbench, signal, publication); }, - onFinalized: async (run) => { - try { await completeSelectedParent(run); } + onFinalized: async (run, signal) => { + // The SDK stops its usage tracker and enforces the budget after this turn. + if (sdkOwned) return; + try { + await runWorkbench([ + "complete-scan", "--scan-id", run.scanId, "--thread-id", threadId, + ...optionalArg("--claim-token", handoffClaimToken), + ], undefined, false, false, signal); + } catch (error) { throw new Error(deepScanInvocationFailureMessage(error), { cause: error }); } @@ -833,22 +835,20 @@ export function createCodexSecurityServer(): McpServer { } } }); - return { begun, ...started }; + return { begun, sdkOwned, ...started }; }).catch((error: unknown) => ({ invocationFailure: toolErrorResult(deepScanInvocationFailureMessage(error)) })); if ("invocationFailure" in preparation) return preparation.invocationFailure; - if (preparation.immediate) { - try { await completeSelectedParent(preparation.begun.run); } - catch (error) { return toolErrorResult(deepScanInvocationFailureMessage(error)); } - return preparation.immediate; - } + // A remote terminal result is aggregate readiness, not ownership of parent completion. + // Explicit completion can replay the selected result after an owner is lost. + if (preparation.immediate) return preparation.immediate; const { begun, coordinator, joined } = preparation; if (joined) { logDeepScanEvent({ event: "coordinator_joined", scanId: begun.run.scanId }); } const terminal = await coordinator.wait(abortSignalFromExtra(extra)); - const result = deepScanTerminalResult(terminal); + const result = deepScanTerminalResult(terminal, preparation.sdkOwned); if (!result) { return toolErrorResult(deepScanInvocationFailureMessage( new Error(`Deep Scan ${terminal.scanId} ended without a terminal result.`) @@ -1639,13 +1639,15 @@ function boundedErrorData(error: unknown): { message: string; name: string } { }; } -function deepScanTerminalResult(run: DeepScanRunState) { +function deepScanTerminalResult(run: DeepScanRunState, sdkOwned = false) { if (run.status === "succeeded") { if (!run.manifestPath) return undefined; return { content: [{ type: "text" as const, - text: `Deep Scan discovery completed. Independent Standard scans have already performed validation and attack-path analysis and have been consolidated into the canonical scan-manifest.json, findings.json, and coverage.json under ${run.scanDir}. The returned manifestPath is the canonical scan-manifest.json, not a legacy discovery manifest. Any instructions requiring parent candidate listing, centralized validation, attack-path analysis, or another draft apply only to the old discovery-only workflow and must be skipped. The authoritative scan ID is ${run.scanId}. Immediately call complete_codex_security_scan once using that scan ID to seal and publish the scan. Return output only after completion succeeds and generated report.md exists. If completion fails, surface that exact error and return no final, no-findings, structured, or benchmark response.` + text: `Deep Scan discovery completed. Independent Standard scans have already performed validation and attack-path analysis and have been consolidated into the canonical scan-manifest.json, findings.json, and coverage.json under ${run.scanDir}. The returned manifestPath is the canonical scan-manifest.json, not a legacy discovery manifest. Any instructions requiring parent candidate listing, centralized validation, attack-path analysis, or another draft apply only to the old discovery-only workflow and must be skipped. The authoritative scan ID is ${run.scanId}. ${sdkOwned + ? "Leave the canonical artifacts unchanged and end this scan turn without calling a completion tool. The SDK will account for the completed turn, enforce its budget, and seal and publish the scan." + : "Immediately call complete_codex_security_scan once using that scan ID to seal and publish the scan. Return output only after completion succeeds and generated report.md exists. If completion fails, surface that exact error and return no final, no-findings, structured, or benchmark response."}` }], structuredContent: { manifestPath: run.manifestPath } }; @@ -1685,11 +1687,12 @@ async function runWorkbench( input?: string | Buffer, selectFinalization = false, withExecutionSettings = false, + signal?: AbortSignal, ): Promise { let pythonCommand: string | undefined; try { pythonCommand = await resolvePythonCommand(); - return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization, withExecutionSettings); + return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization, withExecutionSettings, signal); } catch (error) { const launchError = pythonCommand ? missingPythonHelperMessage(error, pythonCommand) @@ -1710,35 +1713,36 @@ async function executeWorkbenchWithStateSelection( input?: string | Buffer, selectFinalization = false, withExecutionSettings = false, + signal?: AbortSignal, ): Promise { if (WORKBENCH_COMMANDS_WITHOUT_DATABASE.has(args[0] ?? "")) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings, signal); } if (CONFIGURED_WORKBENCH_STATE_DIR) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings, signal); } if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, withExecutionSettings); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, withExecutionSettings, signal); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings, signal); } return await withWorkbenchStateSelectionLock(async () => { if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, withExecutionSettings); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, withExecutionSettings, signal); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings, signal); } try { - const result = await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); + const result = await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings, signal); persistentWorkbenchStateSucceeded = true; return result; } catch (error) { if (!isUnwritableSqliteOpenError(error)) throw error; const fallbackStateDir = await pinFallbackWorkbenchStateDir(); logWorkbenchStateFallback(); - return await executeWorkbench(pythonCommand, args, fallbackStateDir, input, selectFinalization, withExecutionSettings); + return await executeWorkbench(pythonCommand, args, fallbackStateDir, input, selectFinalization, withExecutionSettings, signal); } }); } @@ -1764,6 +1768,7 @@ async function executeWorkbench( input?: string | Buffer, selectFinalization = false, withExecutionSettings = false, + signal?: AbortSignal, ): Promise { const userContextIndex = args.indexOf("--user-context"); const userContext = userContextIndex === -1 ? undefined : args[userContextIndex + 1]; @@ -1778,6 +1783,7 @@ async function executeWorkbench( ? ["-c", `import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](${internalInvocation})`, workbenchScriptPath(), ...workbenchArgs] : [workbenchScriptPath(), ...workbenchArgs]; const execution = execFileAsync(pythonCommand, pythonArgs, { + signal, cwd: PLUGIN_ROOT, env: stateDir ? { ...process.env, CODEX_SECURITY_STATE_DIR: stateDir } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 15f39eb2b1..d1b6101c07 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -70,7 +70,7 @@ export interface CoordinatorOptions { observeReplacement?: (run: DeepScanRunState) => Promise; onComplete?: (draft: ScanDraftInput, signal: AbortSignal, publication: DeepScanPublication) => Promise; /** Complete the enclosing scan after selected publication, before local waiters settle. */ - onFinalized?: (run: DeepScanRunState) => Promise; + onFinalized?: (run: DeepScanRunState, signal: AbortSignal) => Promise; onStopped?: (run: DeepScanRunState) => Promise; } @@ -439,7 +439,7 @@ export class DeepScanCoordinator { finish: (input) => this.options.store.finish(input), }); if (this.canceled || this.externallyFailed) return; - await this.options.onFinalized?.(cloneState(this.state)); + await this.options.onFinalized?.(cloneState(this.state), this.publicationAbortController.signal); if (this.canceled || this.externallyFailed) return; this.finishLocally(this.state); } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index f27313ec01..77c723be42 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -672,7 +672,8 @@ function parseUsageOwner(value: unknown): DeepScanRunState["usageOwner"] { return { threadId: optionalString(owner.threadId) ?? null, turnId: optionalString(owner.turnId) ?? null, - startedAt: requiredString(owner.startedAt, "deepScan.usageOwner.startedAt") + startedAt: requiredString(owner.startedAt, "deepScan.usageOwner.startedAt"), + ...(typeof owner.dedicated === "boolean" ? { dedicated: owner.dedicated } : {}) }; } diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index e838a8980a..ef6b318b2d 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -4178,7 +4178,7 @@ function scanPrompt( "Run this Codex Security scan non-interactively.", ...(mode === "deep" ? [ - `The SDK has already registered this scan. Call start_codex_security_deep_scan with ${JSON.stringify({ scanId })}; never pass targetPath or create another scan.`, + `The SDK has already registered this scan. Call start_codex_security_deep_scan with ${JSON.stringify({ scanId })}; never pass targetPath or create another scan. Leave finalization to the SDK after this turn; do not call a completion tool.`, ] : skillName === "security-scan" || customValidation ? [ @@ -4238,7 +4238,7 @@ function scanPrompt( ] : skillName === "deep-security-scan" ? [ - "The Deep Scan coordinator already wrote the canonical scan artifacts. Call complete_codex_security_scan exactly once without submitting another semantic draft; the workbench owns authoritative metadata, finalization, report generation, and sealing.", + "The Deep Scan coordinator already wrote the canonical scan artifacts. Leave them unchanged and end the scan turn without calling a completion tool; the SDK accounts for the completed turn, enforces its budget, and owns finalization, report generation, and sealing.", ] : [ "Use record_codex_security_scan_draft and complete_codex_security_scan as directed by the selected skill; the workbench owns authoritative metadata, finalization, report generation, and sealing.", diff --git a/sdk/typescript/src/scan-sessions.ts b/sdk/typescript/src/scan-sessions.ts index 925f536012..1cbfa1096a 100644 --- a/sdk/typescript/src/scan-sessions.ts +++ b/sdk/typescript/src/scan-sessions.ts @@ -34,7 +34,12 @@ export interface ScanExecutionAttribution { formatVersion: 1; legacy?: true; executionThreadIds: string[]; - owner: { threadId: string | null; turnId: string | null; startedAt: string }; + owner: { + threadId: string | null; + turnId: string | null; + startedAt: string; + dedicated?: boolean; + }; startedAt: string; completedAt: string | null; } From 477e57b407d4cd2912480f08122fb5588311f6a3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:26:19 +0000 Subject: [PATCH 169/251] test: cover Deep Scan parent finalization process races --- .../tests/test_deep_scan_stdio_lifecycle.mjs | 106 +++++++++- .../tests-ts/deep-finalization.test.ts | 192 ++++++++++++++++-- 2 files changed, 273 insertions(+), 25 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 97b631dbcd..3a224af8d2 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -33,7 +33,7 @@ const parentSandboxState = { if (process.platform === "win32") { console.log("deep scan stdio lifecycle test skipped on Windows (POSIX fake Codex executable)"); } else { - for (const mode of ["detached", "joined", "remote", "failure", "active-failure", "lost-response"]) { + for (const mode of ["cancel-after-seal", "cancel-finalizer", "cancel-finalizer-failure", "late-rejoin", "late-rejoin-failure", "detached", "joined", "remote", "failure", "active-failure", "remote-replay", "lost-response"]) { await testDeepScanDetachedCompletion(mode); } await testDeepScanStdioLifecycle(); @@ -50,6 +50,8 @@ async function testDeepScanDetachedCompletion(mode) { const controlPath = path.join(fixtureRoot, "completion-control"); const finalizerControlPath = path.join(fixtureRoot, "finalizer-control"); const finalizerLogPath = path.join(fixtureRoot, "finalizer.jsonl"); + const committedControlPath = path.join(fixtureRoot, "committed-control"); + const committedLogPath = path.join(fixtureRoot, "committed.jsonl"); const pythonWrapperPath = path.join(fixtureRoot, "python-wrapper.mjs"); const fakeCodexPath = path.join(fixtureRoot, "fake-codex.mjs"); const serverBundlePath = path.join(pluginRoot, "mcp", @@ -63,7 +65,8 @@ async function testDeepScanDetachedCompletion(mode) { "[deep_scan]\nworkers = 1\nsubagents = 0\nstop_after_no_new = 1\nmax_discovery_runs = 2\n"); await writeFile(controlPath, "wait-for-completion"); await writePythonWrapper(pythonWrapperPath); - if (mode !== "detached") await writeFile(finalizerControlPath, mode === "joined" || mode === "remote" ? "wait" : mode === "active-failure" ? "failure" : mode); + if (mode === "cancel-after-seal") await writeFile(committedControlPath, "wait"); + if (mode !== "detached") await writeFile(finalizerControlPath, mode === "joined" || mode === "remote" || mode.startsWith("cancel-finalizer") || mode.startsWith("late-rejoin") ? "wait" : mode === "active-failure" || mode === "remote-replay" ? "failure" : mode); await writeFakeCodex(fakeCodexPath); if (!installedPluginRoot) await bundleServer(serverBundlePath); const environment = { @@ -76,6 +79,9 @@ async function testDeepScanDetachedCompletion(mode) { PYTHON: pythonWrapperPath, REAL_PYTHON: process.env.PYTHON?.trim() || "python3", FAKE_WORKBENCH_FINALIZER_CONTROL: finalizerControlPath, FAKE_WORKBENCH_FINALIZER_LOG: finalizerLogPath, + FAKE_WORKBENCH_CANCEL_LOG: path.join(fixtureRoot, "cancel.jsonl"), + FAKE_WORKBENCH_COMMITTED_CONTROL: committedControlPath, + FAKE_WORKBENCH_COMMITTED_LOG: committedLogPath, FAKE_CODEX_SIGNAL_CHECKPOINT_CONTROL: path.join(fixtureRoot, "unused-signal-control"), }; const server = startServer(serverBundlePath, environment); @@ -105,8 +111,7 @@ async function testDeepScanDetachedCompletion(mode) { await waitFor(() => remote.stderrEvents().some((event) => event.event === "coordinator_joined"), "remote request to observe the existing owner"); } - if (mode !== "active-failure") server.notify("notifications/cancelled", { requestId: 2, reason: "detach original observer" }); - await delay(150); + if (mode !== "active-failure" && mode !== "cancel-after-seal" && !mode.startsWith("cancel-finalizer") && !mode.startsWith("late-rejoin")) server.notify("notifications/cancelled", { requestId: 2, reason: "detach original observer" }); const detached = await getDeepScan({ environment, scanId, threadId }); const originalPublicScan = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); assert.equal(detached.status, "running"); @@ -128,6 +133,74 @@ async function testDeepScanDetachedCompletion(mode) { assert.deepEqual(finished.usageOwner, detached.usageOwner); assert.equal(finished.workers.filter((row) => row.kind === "discovery" && row.status === "succeeded").length, 2); assert.equal(finished.workers.filter((row) => row.kind === "dedup" && row.status === "succeeded").length, 1); + const selectedBytes = await readFile(path.join(finished.scanDir, finished.finalizationInput.resultPath)); + assert.equal(createHash("sha256").update(selectedBytes).digest("hex"), finished.finalizationInput.resultSha256); + if (mode === "cancel-after-seal") { + await waitForJsonLines(committedLogPath, 1); + const beforeCancel = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + assert.equal(beforeCancel.scan.progress.status, "complete"); + const sealedBytes = await readFile(path.join(finished.scanDir, "scan-manifest.json")); + server.sendRequest(6, "tools/call", toolCall("cancel_codex_security_scan", { scanId }, threadId)); + await waitFor(() => server.stderrEvents().some((event) => event.event === "coordinator_cancel_requested"), + "cancellation after parent completion committed"); + await rm(committedControlPath, { force: true }); + const cancellation = await server.waitForResponse(6); + assert.equal(cancellation.result?.isError, true); + const afterCancel = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + assert.equal(afterCancel.scan.progress.status, "complete"); + assert.equal(afterCancel.scan.executionAttribution.completedAt, beforeCancel.scan.executionAttribution.completedAt); + assert.deepEqual(await readFile(path.join(finished.scanDir, "scan-manifest.json")), sealedBytes); + return; + } + if (mode.startsWith("cancel-finalizer")) { + await waitForJsonLines(finalizerLogPath, 1); + server.sendRequest(6, "tools/call", toolCall("cancel_codex_security_scan", { scanId }, threadId)); + await waitFor(() => server.stderrEvents().some((event) => event.event === "coordinator_cancel_requested"), + "owner cancellation before releasing parent completion"); + if (mode === "cancel-finalizer-failure") await writeFile(finalizerControlPath, "failure"); + else await rm(finalizerControlPath, { force: true }); + const canceled = await server.waitForResponse(6); + const ownerResponse = await server.waitForResponse(2); + const publicScan = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + const stopped = await getDeepScan({ environment, scanId, threadId }); + assert.equal(publicScan.scan.progress.status, "canceled", "cancellation observed before parent completion must remain canceled"); + assertNoError(canceled); + assertCanceled(ownerResponse, scanId); + assert.equal(stopped.status, "canceled"); + assert.deepEqual(await readFile(path.join(finished.scanDir, finished.finalizationInput.resultPath)), selectedBytes); + assert.deepEqual(stopped.finalizationInput, finished.finalizationInput); + assert.equal(publicScan.scan.continuationThreadId, originalPublicScan.scan.continuationThreadId); + return; + } + if (mode.startsWith("late-rejoin")) { + await waitForJsonLines(finalizerLogPath, 1); + remote = startServer(serverBundlePath, { + ...environment, + FAKE_WORKBENCH_FINALIZER_CONTROL: path.join(fixtureRoot, "remote-finalizer-control"), + }); + assertNoError(await remote.request(1, "initialize", { + protocolVersion: "2025-11-25", capabilities: {}, + clientInfo: { name: "late-remote-observer", version: "0.1.0" }, + })); + remote.sendRequest(2, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); + const observed = await remote.waitForResponse(2); + const during = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + if (mode === "late-rejoin-failure") await writeFile(finalizerControlPath, "failure"); + else await rm(finalizerControlPath); + const ownerResponse = await server.waitForResponse(2); + const invocations = await readJsonLines(finalizerLogPath); + assertNoError(observed); + assert.equal(observed.result.structuredContent.manifestPath, path.join(finished.scanDir, "scan-manifest.json")); + assert.deepEqual(await readFile(path.join(finished.scanDir, finished.finalizationInput.resultPath)), selectedBytes); + assert.equal(invocations.length, 1, "late remote observation must not invoke a competing parent finalizer"); + assert.equal(during.scan.progress.status, "running", "only the existing owner may finish pending parent completion"); + if (mode === "late-rejoin-failure") { + assert.equal(ownerResponse.result?.isError, true); + assert.equal((await runWorkbench(environment, ["get-scan", "--scan-id", scanId])).scan.progress.status, "running"); + return; + } + assertNoError(ownerResponse); + } if (mode === "joined") { await waitForJsonLines(finalizerLogPath, 1); // Rejoin while finish-deep-scan is committed but public sealing is blocked. @@ -152,7 +225,7 @@ async function testDeepScanDetachedCompletion(mode) { assert.equal((await readJsonLines(finalizerLogPath)).length, 1); await rm(finalizerControlPath); } - if (mode === "failure" || mode === "active-failure" || mode === "lost-response") { + if (mode === "failure" || mode === "active-failure" || mode === "remote-replay" || mode === "lost-response") { await waitFor(() => server.stderrEvents().some((event) => event.event === "coordinator_publication_pending"), "public finalization failure to remain pending"); if (mode === "active-failure") { @@ -171,7 +244,17 @@ async function testDeepScanDetachedCompletion(mode) { const beforeReplay = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); assert.equal(beforeReplay.scan.progress.status, mode !== "lost-response" ? "running" : "complete"); const manifestBeforeReplay = await readFile(path.join(finished.scanDir, "scan-manifest.json")); - const rejoined = await server.request(5, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); + let replayServer = server; + if (mode === "remote-replay") { + await server.stop(); + remote = startServer(serverBundlePath, environment); + replayServer = remote; + assertNoError(await remote.request(1, "initialize", { + protocolVersion: "2025-11-25", capabilities: {}, + clientInfo: { name: "reconstructed-finalizer", version: "0.1.0" }, + })); + } + const rejoined = await replayServer.request(5, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); assertNoError(rejoined); assert.equal(rejoined.result.structuredContent.manifestPath, path.join(finished.scanDir, "scan-manifest.json")); if (mode === "lost-response") { @@ -192,8 +275,8 @@ async function testDeepScanDetachedCompletion(mode) { assert.deepEqual(publicScan.scan.executionAttribution.owner, originalPublicScan.scan.executionAttribution.owner); assert.equal(publicScan.scan.executionAttribution.owner.threadId, threadId); assert.equal((await readJsonLines(startLogPath)).length, 3, "completion and replay launch no extra model workers"); - assert.equal((await readJsonLines(finalizerLogPath)).length, mode === "failure" || mode === "active-failure" || mode === "lost-response" ? 2 : 1); - assertProcessAlive(server.pid); + assert.equal((await readJsonLines(finalizerLogPath)).length, mode === "failure" || mode === "active-failure" || mode === "remote-replay" || mode === "lost-response" ? 2 : 1); + assertProcessAlive(mode === "remote-replay" ? remote.pid : server.pid); console.log("native selected completion passed", mode, scanId); } catch (error) { error.message += `\nMCP stderr:\n${server.stderrText()}`; @@ -1085,6 +1168,11 @@ async function writePythonWrapper(executablePath) { " if (finalizerMode === 'failure') { console.error('injected complete-scan failure'); process.exit(1); }", "}", "const result = spawnSync(process.env.REAL_PYTHON || 'python3', args, { stdio: 'inherit' });", + "const committedControl = process.env.FAKE_WORKBENCH_COMMITTED_CONTROL;", + "if (args[1] === 'complete-scan' && result.status === 0 && committedControl && existsSync(committedControl)) {", + " appendFileSync(process.env.FAKE_WORKBENCH_COMMITTED_LOG, JSON.stringify({ pid: process.pid, committed: true }) + '\\n');", + " while (existsSync(committedControl)) await new Promise((resolve) => setTimeout(resolve, 25));", + "}", "if (result.error) throw result.error;", "if (result.status === 0 && finalizerMode === 'lost-response') { console.error('injected lost completion response'); process.exit(1); }", "process.exit(result.status ?? 1);", diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index dcc0c75882..4f83cf5cbb 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -1,7 +1,7 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, expect, test } from "bun:test"; import type { ThreadEvent } from "@openai/codex-sdk"; import { @@ -13,6 +13,7 @@ import { runWorkbench, type WorkbenchCommandOptions, } from "../src/runtime.js"; +import { resumeSelectedDeepScan } from "../src/deep-scan-finalization.js"; import { TestClient } from "./support/api-client.js"; import { completedEvents, @@ -117,6 +118,7 @@ for (const boundary of ["registration", "stream-start"] as const) { const outcomes = [ "failed", "completed", + "completed-owner-suffix", "restart", "canceled-before-publication", "canceled-during-publication", @@ -239,6 +241,8 @@ for (const { let budgetTriggered = false; let cancellationReadLost = false; let lostCancellationDeepState: unknown; + const ownerSuffix = outcome === "completed-owner-suffix"; + let nativeOwnerStatus: unknown; let originalFinalizationInput: unknown; let selectedPath = ""; let selectedBytes: Buffer; @@ -256,11 +260,15 @@ for (const { "01", `rollout-${threadId}.jsonl`, ); - const recordBudgetUsage = () => + const recordBudgetUsage = ( + inputTokens = 1_250, + outputTokens = 30, + timestamp = new Date().toISOString(), + ) => appendFile( usagePath, JSON.stringify({ - timestamp: new Date().toISOString(), + timestamp, type: "turn_context", payload: { turn_id: "synthetic-scan-turn", @@ -269,15 +277,15 @@ for (const { }) + "\n" + JSON.stringify({ - timestamp: new Date().toISOString(), + timestamp, type: "event_msg", payload: { type: "token_count", info: { total_token_usage: { - input_tokens: 1_250, + input_tokens: inputTokens, cached_input_tokens: 200, - output_tokens: 30, + output_tokens: outputTokens, }, }, }, @@ -286,7 +294,7 @@ for (const { (unpricedUsage ? [ JSON.stringify({ - timestamp: new Date().toISOString(), + timestamp, type: "turn_context", payload: { turn_id: "synthetic-scan-turn", @@ -294,7 +302,7 @@ for (const { }, }), JSON.stringify({ - timestamp: new Date().toISOString(), + timestamp, type: "event_msg", payload: { type: "token_count", @@ -302,7 +310,7 @@ for (const { total_token_usage: { input_tokens: 1_350, cached_input_tokens: 200, - output_tokens: 30, + output_tokens: outputTokens, }, }, }, @@ -600,17 +608,50 @@ for (const { payload: { id: threadId, cwd: scanDir }, }) + "\n", ); + if (ownerSuffix) { + await recordBudgetUsage(); + await resumeSelectedDeepScan({ + scanId, + threadId, + pluginRoot: PLUGIN_ROOT, + runWorkbench: (args) => + runWorkbench(workbenchOptions, args), + signal: cancellation.signal, + }); + await rejoinSelectedScanThroughMcp(environment, scanId); + const saved = await runWorkbench(workbenchOptions, [ + "get-scan", + "--scan-id", + scanId, + ]); + const scan = saved["scan"] as { + progress: { status: string }; + executionAttribution: { completedAt: string | null }; + }; + nativeOwnerStatus = scan.progress.status; + const completedAt = scan.executionAttribution.completedAt; + await recordBudgetUsage( + 2_500, + 60, + new Date( + Math.max( + Date.now(), + completedAt ? Date.parse(completedAt) + 1 : 0, + ), + ).toISOString(), + ); + } if (outcome === "canceled-before-publication") cancellation.abort("Synthetic user cancellation"); - if (outcome === "completed") { + if (outcome === "completed" || ownerSuffix) { yield { type: "turn.completed", usage: { - input_tokens: 0, - cached_input_tokens: 0, + input_tokens: ownerSuffix ? 2_500 : 0, + cached_input_tokens: ownerSuffix ? 200 : 0, cache_write_input_tokens: 0, reasoning_output_tokens: 0, - output_tokens: 0, + output_tokens: ownerSuffix ? 60 : 0, }, }; } else { @@ -948,8 +989,14 @@ for (const { commands.filter((command) => command === "complete-scan"), ).toHaveLength(2); } - // The synthetic accepted workers have no native usage receipts. - expect(result.cost).toBeNull(); + if (ownerSuffix) { + expect(nativeOwnerStatus).toBe("running"); + expect(result.cost?.inputTokens).toBe(2_500); + expect(result.cost?.outputTokens).toBe(60); + } else { + // The synthetic accepted workers have no native usage receipts. + expect(result.cost).toBeNull(); + } expect(result.coverage.completeness).toBe("partial"); expect(result.findings.findings[0]?.remediation).toBe( "Validate the resolved destination before writing.", @@ -977,3 +1024,116 @@ for (const { }; test(name, runCase, 30_000); } + +async function rejoinSelectedScanThroughMcp( + environment: NodeJS.ProcessEnv, + scanId: string, +): Promise { + const child = spawn( + process.execPath, + [join(PLUGIN_ROOT, "mcp/server.mjs"), "--stdio"], + { + env: environment, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const exited = new Promise((resolve) => + child.once("exit", () => resolve()), + ); + const result = new Promise((resolve, reject) => { + let buffer = ""; + child.once("error", reject); + child.once("exit", () => + reject(new Error(`MCP closed before selected rejoin: ${stderr}`)), + ); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + try { + const response = JSON.parse(line) as { + id?: number; + error?: unknown; + result?: { + isError?: boolean; + structuredContent?: { manifestPath?: string }; + }; + }; + if (response.id === 1) { + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "start_codex_security_deep_scan", + arguments: { scanId }, + _meta: { + "openai/threadId": threadId, + "codex/sandbox-state-meta": { + permissionProfile: { + type: "managed", + file_system: { + type: "restricted", + entries: [ + { + path: { + type: "special", + value: { kind: "root" }, + }, + access: "read", + }, + ], + }, + network: "restricted", + }, + sandboxCwd: pathToFileURL(PLUGIN_ROOT).href, + }, + }, + }, + }) + "\n", + ); + } + if (response.id === 2) { + expect(response.error).toBeUndefined(); + expect(response.result?.isError).toBeUndefined(); + expect( + response.result?.structuredContent?.manifestPath, + ).toBeDefined(); + resolve(); + } + } catch (error) { + reject(error); + } + } + }); + }); + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "sdk-owner-completion", version: "0.1.0" }, + }, + }) + "\n", + ); + try { + await result; + } finally { + child.stdin.end(); + child.kill(); + await exited; + } +} From 621bff5b7d6fff5ca023c347b192127a6db8f6fd Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:36:13 +0000 Subject: [PATCH 170/251] fix(deep): recover selected parent completion through the existing lease --- plugins/codex-security/mcp-app/server.ts | 12 ++++++--- .../mcp-app/src/deep-scan/store.ts | 26 ++++++++++++++----- .../scripts/deep_scan_workbench.py | 18 +++++++++++-- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 2f3cf87a4f..a493ca0aa3 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -757,7 +757,9 @@ export function createCodexSecurityServer(): McpServer { const completingLocally = begun.run.status === "succeeded" && begun.run.finalizationInput && deepScanCoordinators.get(begun.run.scanId); - if (immediate && !completingLocally) return { begun, immediate, sdkOwned }; + const recoverableSelection = begun.run.status === "succeeded" + && begun.run.finalizationInput && !sdkOwned; + if (immediate && !completingLocally && !recoverableSelection) return { begun, immediate, sdkOwned }; const started = await startOrJoinDeepScanCoordinator({ begin: begun, registry: deepScanCoordinators, @@ -822,6 +824,11 @@ export function createCodexSecurityServer(): McpServer { ], undefined, false, false, signal); } catch (error) { + if (!signal.aborted) { + await deepScanStore.releaseCoordinator(run.scanId).catch((releaseError) => { + logDeepScanEvent({ event: "coordinator_release_failed", scanId: run.scanId, reason: boundedErrorData(releaseError).message }); + }); + } throw new Error(deepScanInvocationFailureMessage(error), { cause: error }); } }, @@ -840,8 +847,7 @@ export function createCodexSecurityServer(): McpServer { invocationFailure: toolErrorResult(deepScanInvocationFailureMessage(error)) })); if ("invocationFailure" in preparation) return preparation.invocationFailure; - // A remote terminal result is aggregate readiness, not ownership of parent completion. - // Explicit completion can replay the selected result after an owner is lost. + // SDK completion belongs to the host; native selected results use the existing lease. if (preparation.immediate) return preparation.immediate; const { begun, coordinator, joined } = preparation; if (joined) { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index 77c723be42..799c1fa080 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -164,7 +164,7 @@ export class WorkbenchDeepScanStore implements DeepScanStore { ])); const generation = this.coordinatorLeases.get(scanId)?.run.coordinatorGeneration; if ( - run.status !== "running" + (run.status !== "running" && !(run.status === "succeeded" && run.finalizationInput)) || (generation !== undefined && run.coordinatorGeneration !== undefined && run.coordinatorGeneration > generation) @@ -211,13 +211,27 @@ export class WorkbenchDeepScanStore implements DeepScanStore { throw new Error("Deep Scan coordinator lease belongs to another continuation."); } const updatedAt = new Date().toISOString(); - await writeJsonAtomic(join( - lease.run.scanDir, + await this.writeCoordinatorHeartbeat(lease.run, updatedAt); + return { ...lease.run, updatedAt }; + } + + async releaseCoordinator(scanId: string): Promise { + const lease = this.coordinatorLeases.get(scanId); + if (!lease) return; + this.coordinatorLeases.delete(scanId); + await this.writeCoordinatorHeartbeat(lease.run, new Date().toISOString(), true); + } + + private writeCoordinatorHeartbeat(run: DeepScanRunState, updatedAt: string, released = false): Promise { + // Serialize with pending heartbeats so a late write cannot renew a released lease. + const operation = this.writeTail.then(() => writeJsonAtomic(join( + run.scanDir, "artifacts", "deep_discovery", - `coordinator-heartbeat-${lease.run.coordinatorGeneration}.json` - ), { coordinatorGeneration: lease.run.coordinatorGeneration, updatedAt }); - return { ...lease.run, updatedAt }; + `coordinator-heartbeat-${run.coordinatorGeneration}.json` + ), { coordinatorGeneration: run.coordinatorGeneration, updatedAt, ...(released ? { released: true } : {}) })); + this.writeTail = operation.then(() => undefined, () => undefined); + return operation; } async cancel(scanId: string, threadId: string): Promise { diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index dcbd7a7b24..3b58d68951 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -1170,6 +1170,8 @@ def coordinator_lease_is_live( try: heartbeat = json.loads(heartbeat_path.read_text(encoding="utf-8")) if heartbeat["coordinatorGeneration"] == run["coordinator_generation"]: + if heartbeat.get("released") is True: + return False heartbeat_time = max(heartbeat_time, _parse_timestamp(heartbeat["updatedAt"])) except (OSError, KeyError, TypeError, ValueError): pass @@ -1209,7 +1211,19 @@ def claim_deep_scan_coordinator_locked( args.claim_token, error_message="Deep Scan orchestration is owned by another continuation.", ) - run, _ = require_running_deep_scan(connection, scan_id) + run = require_deep_scan_run(connection, scan_id) + require_supported_deep_scan(run) + selected_parent = run["status"] == "succeeded" and deep_scan_finalization_input(run) is not None + if selected_parent and scan["status"] == "complete": + connection.commit() + return { + **deep_scan_result(connection, scan_id), + "coordinatorDisposition": "observing", + } + if not selected_parent: + run, _ = require_running_deep_scan(connection, scan_id) + elif run["cancel_requested"] or scan["status"] != "running" or scan["canceled_at"] is not None: + raise SystemExit("Only a running scan can complete its selected Deep Scan result.") timestamp = now() if args.coordinator_generation is not None: require_current_coordinator(run, args) @@ -1240,7 +1254,7 @@ def claim_deep_scan_coordinator_locked( """ UPDATE deep_scan_runs SET coordinator_generation = coordinator_generation + ?, updated_at = ? - WHERE scan_id = ? AND status = 'running' + WHERE scan_id = ? AND status IN ('running', 'succeeded') """, (int(args.coordinator_generation != run["coordinator_generation"]), timestamp, scan_id), ) From d8e25f8f8e18b9574ea55de363bcd5a3b1aea6d1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:38:43 +0000 Subject: [PATCH 171/251] fix(deep): defer SDK completion tools and keep heartbeats independent --- plugins/codex-security/mcp-app/server.ts | 9 +++++++++ .../mcp-app/src/deep-scan/store.ts | 18 ++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index a493ca0aa3..0f09b435dc 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -1175,6 +1175,15 @@ export function createCodexSecurityServer(): McpServer { _meta: modelActionMeta }, async ({ scanId, handoffClaimToken }) => { try { + const context = await runWorkbench(["get-scan", "--scan-id", scanId]); + const scan = isJsonObject(context.scan) ? context.scan : undefined; + const progress = isJsonObject(scan?.progress) ? scan.progress : undefined; + if (scan?.mode === "deep" && context.recipe != null && progress?.status === "running") { + // A tool call is still inside the owner turn; its final usage has not arrived. + return scanActionResult(context, + "The SDK completes this Deep Scan after the scan turn finishes. Continue any remaining scan work, then end the turn without calling completion again. The SDK will account for the turn, enforce its budget, and seal and publish the scan." + ); + } const result = await runWorkbench([ "complete-scan", "--scan-id", diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index 799c1fa080..106aa4e324 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -108,6 +108,7 @@ export class WorkbenchDeepScanStore implements DeepScanStore { private readonly coordinatorLeases = new Map; }>(); constructor(private readonly runWorkbench: WorkbenchRunner) {} @@ -211,7 +212,7 @@ export class WorkbenchDeepScanStore implements DeepScanStore { throw new Error("Deep Scan coordinator lease belongs to another continuation."); } const updatedAt = new Date().toISOString(); - await this.writeCoordinatorHeartbeat(lease.run, updatedAt); + await this.writeCoordinatorHeartbeat(lease, updatedAt); return { ...lease.run, updatedAt }; } @@ -219,18 +220,23 @@ export class WorkbenchDeepScanStore implements DeepScanStore { const lease = this.coordinatorLeases.get(scanId); if (!lease) return; this.coordinatorLeases.delete(scanId); - await this.writeCoordinatorHeartbeat(lease.run, new Date().toISOString(), true); + await this.writeCoordinatorHeartbeat(lease, new Date().toISOString(), true); } - private writeCoordinatorHeartbeat(run: DeepScanRunState, updatedAt: string, released = false): Promise { - // Serialize with pending heartbeats so a late write cannot renew a released lease. - const operation = this.writeTail.then(() => writeJsonAtomic(join( + private writeCoordinatorHeartbeat( + lease: { run: DeepScanRunState; heartbeatWrite?: Promise }, + updatedAt: string, + released = false + ): Promise { + // Heartbeats bypass SQLite writes; only writes to this lease file must settle in order. + const { run } = lease; + const operation = (lease.heartbeatWrite ?? Promise.resolve()).then(() => writeJsonAtomic(join( run.scanDir, "artifacts", "deep_discovery", `coordinator-heartbeat-${run.coordinatorGeneration}.json` ), { coordinatorGeneration: run.coordinatorGeneration, updatedAt, ...(released ? { released: true } : {}) })); - this.writeTail = operation.then(() => undefined, () => undefined); + lease.heartbeatWrite = operation.then(() => undefined, () => undefined); return operation; } From 978ac1d296d8179dc0b21aac049d419a3a4750d8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:39:52 +0000 Subject: [PATCH 172/251] fix(deep): preserve completion reply redaction and owner guidance --- plugins/codex-security/mcp-app/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 0f09b435dc..257b9ba317 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -696,7 +696,7 @@ export function createCodexSecurityServer(): McpServer { server.registerTool("start_codex_security_deep_scan", { title: "Start or Join Codex Security Deep Scan", - description: "Run or rejoin independent Standard security scans and semantically merge their validated findings. Pass scanId and its handoffClaimToken to resume, or targetPath to start headlessly. The call blocks until the aggregate draft is ready, fails, or is canceled. On success, manifestPath identifies the canonical parent scan-manifest.json; call complete_codex_security_scan once.", + description: "Run or rejoin independent Standard security scans and semantically merge their validated findings. Pass scanId and its handoffClaimToken to resume, or targetPath to start headlessly. The call blocks until the aggregate draft is ready, fails, or is canceled. On success, manifestPath identifies the canonical parent scan-manifest.json; native scans can call complete_codex_security_scan once. SDK-managed scans leave completion to the SDK after the scan turn ends.", inputSchema: startDeepScanSchema, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _meta: modelActionMeta @@ -1169,7 +1169,7 @@ export function createCodexSecurityServer(): McpServer { server.registerTool("complete_codex_security_scan", { title: "Complete Codex Security Scan", - description: "Finalization only: validate and seal already-authored scan-manifest.json, findings.json, and coverage.json, generate report.md, index findings, and mark the scan complete. For an app-backed running scan, scan-manifest.json is an unsealed draft and must omit scan.sealedAt and scan.artifacts; this tool supplies the exact workbench timestamps, seal, artifact digests, and derived finding identities. Call only after those canonical files exist; this tool does not create missing artifacts or run skipped phases. If it fails, surface the exact error and stop the current response without retrying completion or returning a final, no-findings, structured, or benchmark response.", + description: "Finalization only: validate and seal already-authored scan-manifest.json, findings.json, and coverage.json, generate report.md, index findings, and mark the scan complete. For an app-backed running scan, scan-manifest.json is an unsealed draft and must omit scan.sealedAt and scan.artifacts; this tool supplies the exact workbench timestamps, seal, artifact digests, and derived finding identities. Call only after those canonical files exist; this tool does not create missing artifacts or run skipped phases. SDK-managed Deep scans leave sealing to the SDK after the scan turn ends. If it fails, surface the exact error and stop the current response without retrying completion or returning a final, no-findings, structured, or benchmark response.", inputSchema: completeScanSchema, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _meta: modelActionMeta @@ -1180,7 +1180,7 @@ export function createCodexSecurityServer(): McpServer { const progress = isJsonObject(scan?.progress) ? scan.progress : undefined; if (scan?.mode === "deep" && context.recipe != null && progress?.status === "running") { // A tool call is still inside the owner turn; its final usage has not arrived. - return scanActionResult(context, + return scanActionResult(redactHandoffClaimToken(context), "The SDK completes this Deep Scan after the scan turn finishes. Continue any remaining scan work, then end the turn without calling completion again. The SDK will account for the turn, enforce its budget, and seal and publish the scan." ); } From e7470cee872c248a9ebf11bdcec69b14562d28e6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:41:32 +0000 Subject: [PATCH 173/251] style(deep): wrap selected parent lease conditions --- plugins/codex-security/scripts/deep_scan_workbench.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 3b58d68951..3ab4d31c66 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -1213,7 +1213,9 @@ def claim_deep_scan_coordinator_locked( ) run = require_deep_scan_run(connection, scan_id) require_supported_deep_scan(run) - selected_parent = run["status"] == "succeeded" and deep_scan_finalization_input(run) is not None + selected_parent = ( + run["status"] == "succeeded" and deep_scan_finalization_input(run) is not None + ) if selected_parent and scan["status"] == "complete": connection.commit() return { @@ -1222,7 +1224,9 @@ def claim_deep_scan_coordinator_locked( } if not selected_parent: run, _ = require_running_deep_scan(connection, scan_id) - elif run["cancel_requested"] or scan["status"] != "running" or scan["canceled_at"] is not None: + elif ( + run["cancel_requested"] or scan["status"] != "running" or scan["canceled_at"] is not None + ): raise SystemExit("Only a running scan can complete its selected Deep Scan result.") timestamp = now() if args.coordinator_generation is not None: From 78f144fba60865675f2d365748b82e4964c31239 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:40:28 +0000 Subject: [PATCH 174/251] test: cover explicit MCP completion during SDK owner turn --- .../tests-ts/deep-finalization.test.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 4f83cf5cbb..2c83534de0 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -119,6 +119,7 @@ const outcomes = [ "failed", "completed", "completed-owner-suffix", + "completed-owner-explicit", "restart", "canceled-before-publication", "canceled-during-publication", @@ -241,7 +242,9 @@ for (const { let budgetTriggered = false; let cancellationReadLost = false; let lostCancellationDeepState: unknown; - const ownerSuffix = outcome === "completed-owner-suffix"; + const ownerSuffix = + outcome === "completed-owner-suffix" || + outcome === "completed-owner-explicit"; let nativeOwnerStatus: unknown; let originalFinalizationInput: unknown; let selectedPath = ""; @@ -618,7 +621,11 @@ for (const { runWorkbench(workbenchOptions, args), signal: cancellation.signal, }); - await rejoinSelectedScanThroughMcp(environment, scanId); + await rejoinSelectedScanThroughMcp( + environment, + scanId, + outcome === "completed-owner-explicit", + ); const saved = await runWorkbench(workbenchOptions, [ "get-scan", "--scan-id", @@ -1028,6 +1035,7 @@ for (const { async function rejoinSelectedScanThroughMcp( environment: NodeJS.ProcessEnv, scanId: string, + explicitCompletion = false, ): Promise { const child = spawn( process.execPath, @@ -1075,7 +1083,9 @@ async function rejoinSelectedScanThroughMcp( id: 2, method: "tools/call", params: { - name: "start_codex_security_deep_scan", + name: explicitCompletion + ? "complete_codex_security_scan" + : "start_codex_security_deep_scan", arguments: { scanId }, _meta: { "openai/threadId": threadId, @@ -1106,9 +1116,10 @@ async function rejoinSelectedScanThroughMcp( if (response.id === 2) { expect(response.error).toBeUndefined(); expect(response.result?.isError).toBeUndefined(); - expect( - response.result?.structuredContent?.manifestPath, - ).toBeDefined(); + if (!explicitCompletion) + expect( + response.result?.structuredContent?.manifestPath, + ).toBeDefined(); resolve(); } } catch (error) { From c28c4578cfd1ad76457d440d6424db4bd0094491 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:57:18 +0000 Subject: [PATCH 175/251] style(deep): format selected parent cancellation condition --- plugins/codex-security/scripts/deep_scan_workbench.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 3ab4d31c66..5b4304cddf 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -1225,7 +1225,9 @@ def claim_deep_scan_coordinator_locked( if not selected_parent: run, _ = require_running_deep_scan(connection, scan_id) elif ( - run["cancel_requested"] or scan["status"] != "running" or scan["canceled_at"] is not None + run["cancel_requested"] + or scan["status"] != "running" + or scan["canceled_at"] is not None ): raise SystemExit("Only a running scan can complete its selected Deep Scan result.") timestamp = now() From 18a7dc9c0bb068fc9f5713c3c95b0190ad1cc290 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 18:14:36 +0000 Subject: [PATCH 176/251] fix(deep): validate sealed artifacts during completed replay --- .../codex-security/scripts/workbench_db.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 4663695ff2..38be4a228e 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1353,6 +1353,21 @@ def completed_scan_context( return context +def validate_completed_scan_artifacts(scan: sqlite3.Row) -> None: + scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) + require_recorded_manifest_digest(scan, scan_dir) + verify_manifest_binding(scan, read_json_object(scan_dir / ARTIFACTS["manifest"])) + try: + manifest = _prepare_scan_finalization( + scan_dir, + expected_coverage_mode=expected_coverage_mode(scan), + )[2] + except ContractError as exc: + raise SystemExit(str(exc)) from exc + verify_manifest_binding(scan, manifest) + published_manifest_digest(scan_dir, manifest) + + def complete_scan_locked( connection: sqlite3.Connection, scan_id: str, @@ -3388,6 +3403,13 @@ def main(*, select_finalization: bool = False, with_execution_settings: bool = F result = start_headless_standard_scan(connection, args) elif args.command == "begin-deep-scan": result = deep_scan.begin_deep_scan(connection, args) + if ( + result["deepScan"]["status"] == "succeeded" + and result["deepScan"]["finalizationInput"] is not None + ): + scan = require_scan(connection, result["deepScan"]["scanId"]) + if scan["status"] == "complete": + validate_completed_scan_artifacts(scan) elif args.command == "get-deep-scan": result = deep_scan.get_deep_scan(connection, args) elif args.command == "claim-deep-scan-coordinator": From 88738d9345e00183d533b852c7f8c5594aecf5a7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 17:56:53 +0000 Subject: [PATCH 177/251] test: preserve completed receipts on lost-response replay --- .../mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 3a224af8d2..4aac551089 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -259,6 +259,9 @@ async function testDeepScanDetachedCompletion(mode) { assert.equal(rejoined.result.structuredContent.manifestPath, path.join(finished.scanDir, "scan-manifest.json")); if (mode === "lost-response") { assert.deepEqual(await readFile(path.join(finished.scanDir, "scan-manifest.json")), manifestBeforeReplay); + const afterReplay = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + assert.equal(afterReplay.scan.progress.status, "complete"); + assert.equal(afterReplay.scan.executionAttribution.completedAt, beforeReplay.scan.executionAttribution.completedAt); } assert.deepEqual((await getDeepScan({ environment, scanId, threadId })).finalizationInput, finished.finalizationInput); } @@ -275,7 +278,7 @@ async function testDeepScanDetachedCompletion(mode) { assert.deepEqual(publicScan.scan.executionAttribution.owner, originalPublicScan.scan.executionAttribution.owner); assert.equal(publicScan.scan.executionAttribution.owner.threadId, threadId); assert.equal((await readJsonLines(startLogPath)).length, 3, "completion and replay launch no extra model workers"); - assert.equal((await readJsonLines(finalizerLogPath)).length, mode === "failure" || mode === "active-failure" || mode === "remote-replay" || mode === "lost-response" ? 2 : 1); + assert.equal((await readJsonLines(finalizerLogPath)).length, mode === "failure" || mode === "active-failure" || mode === "remote-replay" ? 2 : 1); assertProcessAlive(mode === "remote-replay" ? remote.pid : server.pid); console.log("native selected completion passed", mode, scanId); } catch (error) { From ca53a81d673ffd944115bfe3598d4f5efbfee463 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 14 Sep 2026 18:24:54 +0000 Subject: [PATCH 178/251] test(deep): preserve sealed replay integrity and SDK completion prompts --- .../tests/test_deep_scan_stdio_lifecycle.mjs | 29 +++++++++++++++---- sdk/typescript/tests-ts/api.test.ts | 6 ++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs index 4aac551089..69880a523d 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs @@ -33,7 +33,7 @@ const parentSandboxState = { if (process.platform === "win32") { console.log("deep scan stdio lifecycle test skipped on Windows (POSIX fake Codex executable)"); } else { - for (const mode of ["cancel-after-seal", "cancel-finalizer", "cancel-finalizer-failure", "late-rejoin", "late-rejoin-failure", "detached", "joined", "remote", "failure", "active-failure", "remote-replay", "lost-response"]) { + for (const mode of ["cancel-after-seal", "cancel-finalizer", "cancel-finalizer-failure", "late-rejoin", "late-rejoin-failure", "detached", "joined", "remote", "failure", "active-failure", "remote-replay", "lost-response", "lost-response-corrupt", "lost-response-remove"]) { await testDeepScanDetachedCompletion(mode); } await testDeepScanStdioLifecycle(); @@ -66,7 +66,7 @@ async function testDeepScanDetachedCompletion(mode) { await writeFile(controlPath, "wait-for-completion"); await writePythonWrapper(pythonWrapperPath); if (mode === "cancel-after-seal") await writeFile(committedControlPath, "wait"); - if (mode !== "detached") await writeFile(finalizerControlPath, mode === "joined" || mode === "remote" || mode.startsWith("cancel-finalizer") || mode.startsWith("late-rejoin") ? "wait" : mode === "active-failure" || mode === "remote-replay" ? "failure" : mode); + if (mode !== "detached") await writeFile(finalizerControlPath, mode === "joined" || mode === "remote" || mode.startsWith("cancel-finalizer") || mode.startsWith("late-rejoin") ? "wait" : mode === "active-failure" || mode === "remote-replay" ? "failure" : mode.startsWith("lost-response") ? "lost-response" : mode); await writeFakeCodex(fakeCodexPath); if (!installedPluginRoot) await bundleServer(serverBundlePath); const environment = { @@ -225,7 +225,7 @@ async function testDeepScanDetachedCompletion(mode) { assert.equal((await readJsonLines(finalizerLogPath)).length, 1); await rm(finalizerControlPath); } - if (mode === "failure" || mode === "active-failure" || mode === "remote-replay" || mode === "lost-response") { + if (mode === "failure" || mode === "active-failure" || mode === "remote-replay" || mode.startsWith("lost-response")) { await waitFor(() => server.stderrEvents().some((event) => event.event === "coordinator_publication_pending"), "public finalization failure to remain pending"); if (mode === "active-failure") { @@ -242,7 +242,7 @@ async function testDeepScanDetachedCompletion(mode) { assert.deepEqual(pending.finalizationInput, finished.finalizationInput); assert.equal((await readJsonLines(finalizerLogPath)).length, 1, "the owner does not add a retry layer"); const beforeReplay = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); - assert.equal(beforeReplay.scan.progress.status, mode !== "lost-response" ? "running" : "complete"); + assert.equal(beforeReplay.scan.progress.status, !mode.startsWith("lost-response") ? "running" : "complete"); const manifestBeforeReplay = await readFile(path.join(finished.scanDir, "scan-manifest.json")); let replayServer = server; if (mode === "remote-replay") { @@ -254,10 +254,29 @@ async function testDeepScanDetachedCompletion(mode) { clientInfo: { name: "reconstructed-finalizer", version: "0.1.0" }, })); } + const damagedArtifact = path.join(finished.scanDir, "findings.json"); + if (mode === "lost-response-corrupt") { + await writeFile(damagedArtifact, Buffer.concat([await readFile(damagedArtifact), Buffer.from(" ")])); + } else if (mode === "lost-response-remove") { + await rm(damagedArtifact); + } const rejoined = await replayServer.request(5, "tools/call", toolCall("start_codex_security_deep_scan", { scanId }, threadId)); + if (mode === "lost-response-corrupt" || mode === "lost-response-remove") { + assert.equal(rejoined.result?.isError, true, "completion replay must reject altered sealed artifacts"); + assert.match(rejoined.result.content.map((item) => item.text).join(" "), + /sealed artifact changed or is missing|findings\.json: expected a file/); + assert.deepEqual(await readFile(path.join(finished.scanDir, "scan-manifest.json")), manifestBeforeReplay); + assert.deepEqual(await readFile(path.join(finished.scanDir, finished.finalizationInput.resultPath)), selectedBytes); + assert.equal((await readJsonLines(startLogPath)).length, 3, "rejected replay launches no workers"); + const afterReplay = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); + assert.equal(afterReplay.scan.progress.status, "complete"); + assert.equal(afterReplay.scan.executionAttribution.completedAt, beforeReplay.scan.executionAttribution.completedAt); + console.log("native selected completion integrity passed", mode, scanId); + return; + } assertNoError(rejoined); assert.equal(rejoined.result.structuredContent.manifestPath, path.join(finished.scanDir, "scan-manifest.json")); - if (mode === "lost-response") { + if (mode.startsWith("lost-response")) { assert.deepEqual(await readFile(path.join(finished.scanDir, "scan-manifest.json")), manifestBeforeReplay); const afterReplay = await runWorkbench(environment, ["get-scan", "--scan-id", scanId]); assert.equal(afterReplay.scan.progress.status, "complete"); diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 3c5c169bda..1d96a821b2 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -6522,8 +6522,8 @@ describe("CodexSecurity orchestration", () => { ); expect(prompt).toContain("$codex-security:deep-security-scan"); expect(prompt).not.toContain("record_codex_security_scan_draft"); - expect(prompt).toContain("complete_codex_security_scan"); - expect(prompt).not.toContain("do not finalize or seal them"); + expect(prompt).not.toContain("complete_codex_security_scan"); + expect(prompt).toContain("Leave finalization to the SDK after this turn"); expect(prompt).toContain( 'start_codex_security_deep_scan with {"scanId":"scan_example_001"}', ); @@ -6534,7 +6534,7 @@ describe("CodexSecurity orchestration", () => { await expect( client.run(repository, { mode: "deep", maxCostUsd: 1 }), ).rejects.toThrow("prompt captured"); - expect(prompt).toContain("do not finalize or seal them"); + expect(prompt).toContain("Leave finalization to the SDK after this turn"); expect(prompt).not.toContain("complete_codex_security_scan"); await expect( From 85a3ccece32a77b1f9e582f962c057faf46488d3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 6 Oct 2026 17:34:48 +0000 Subject: [PATCH 179/251] Preserve Deep Scan worker settings and owning-turn usage --- plugins/codex-security/mcp-app/server.ts | 8 +- .../mcp-app/src/deep-scan/coordinator.ts | 6 +- .../mcp-app/src/deep-scan/executor.ts | 18 +- .../src/deep-scan/recovery-settings.ts | 15 +- .../mcp-app/src/deep-scan/store.ts | 1 + .../mcp-app/src/deep-scan/types.ts | 1 + .../mcp-app/src/deep-scan/worker-runner.ts | 2 +- .../mcp-app/tests/test_deep_scan_executor.ts | 52 +++-- .../tests/test_deep_scan_stdio_lifecycle.ts | 209 +++++++++++++++--- .../scripts/deep_scan_workbench.py | 2 + .../tests/test_budget_coverage_provenance.py | 2 + .../tests/test_deep_scan_compatibility.py | 5 + .../tests/test_deep_scan_persistence.py | 19 +- .../test_deep_scan_publication_replay.py | 12 +- .../test_selected_publication_authority.py | 26 ++- .../codex-security/tests/test_workbench_db.py | 2 +- sdk/typescript/src/cost.ts | 76 ++++++- sdk/typescript/tests-ts/build-plugin.test.ts | 10 +- sdk/typescript/tests-ts/cost.test.ts | 110 +++++++++ 19 files changed, 492 insertions(+), 84 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 37a0463ac0..e8abf293e4 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -1160,7 +1160,8 @@ export function createCodexSecurityServer(): McpServer { const recoverableSelection = begun.status === "succeeded" && begun.finalizationInput && - !sdkOwned; + !sdkOwned && + begun.ownerThreadId === threadId; if (immediate && !completingLocally && !recoverableSelection) return { begun, immediate, sdkOwned }; const started = await startOrJoinDeepScanCoordinator({ @@ -1246,8 +1247,9 @@ export function createCodexSecurityServer(): McpServer { ); }, onFinalized: async (run, signal) => { - // The SDK stops its usage tracker and enforces the budget after this turn. - if (sdkOwned) return; + // Active owners finish through the completion tool after this call; + // detached native scans still publish without another observer. + if (sdkOwned || !abortSignalFromExtra(extra)?.aborted) return; try { await runWorkbench( [ diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 33ef308fa9..b2b3edff11 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -1025,7 +1025,7 @@ export class DeepScanCoordinator { ); } return { - reason: stopReason ?? "capped", + reason: stopReason, omittedWorkerIds: unique(omittedWorkerIds), result: latestResult, resultPath: previousReducerResultPath, @@ -1349,7 +1349,3 @@ function errorKind(error: unknown): string { function unique(values: string[]): string[] { return [...new Set(values)]; } -function removeValue(values: string[], value: string): void { - for (let index = values.length - 1; index >= 0; index--) - if (values[index] === value) values.splice(index, 1); -} diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index e408b6b0e5..f2f5bc9f42 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -47,6 +47,7 @@ export interface CodexSdkWorkerModelSettings { reasoningEffort?: string; artifactContext?: CodexSdkWorkerArtifactContext; parentSandbox?: DeepWorkerParentSandbox; + runtimeSettings?: CodexSdkWorkerRuntimeSettings; } /** The coordinator supplies scan identity; worker tools never choose paths. */ @@ -59,7 +60,7 @@ export interface CodexSdkWorkerArtifactContext { pythonCommand?: string; } -interface CodexSdkWorkerRuntimeSettings { +export interface CodexSdkWorkerRuntimeSettings { reasoningSummary?: string; serviceTier?: string; cyberAccessProgram?: CyberAccessProgram; @@ -89,8 +90,10 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { const resolved = this.modelSettings.codexOptions; const originalCwd = process.cwd(); const childEnv = await snapshotWorkerEnvironment(resolved?.env); - const runtimeSettings = await (this.runtimeSettings ??= - workerRuntimeSettings(childEnv)); + const runtimeSettings = await (this.runtimeSettings ??= this.modelSettings + .runtimeSettings + ? Promise.resolve(this.modelSettings.runtimeSettings) + : workerRuntimeSettings(childEnv)); if (resolved?.apiKey !== undefined) childEnv.CODEX_API_KEY = resolved.apiKey; // Snapshot per-scan selections once; a reconstructed owner can supply them. @@ -590,7 +593,14 @@ async function workerRuntimeSettings( process.platform, ); if (!configPath) return {}; - const config = parseToml(await fs.readFile(configPath, "utf8")); + return workerRuntimeSettingsFromConfig( + parseToml(await fs.readFile(configPath, "utf8")), + ); +} + +export function workerRuntimeSettingsFromConfig( + config: Record, +): CodexSdkWorkerRuntimeSettings { const profiles = config.profiles; const profile = typeof config.profile === "string" && isRecord(profiles) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 1afd9b309f..6bb5b02166 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -9,7 +9,11 @@ import { type JsonObject, } from "../../../../../sdk/typescript/src/config.js"; import { readScanLogs } from "../../../../../sdk/typescript/src/scan-logs.js"; -import { resolveCodexPath } from "./executor.js"; +import { + resolveCodexPath, + workerRuntimeSettingsFromConfig, + type CodexSdkWorkerRuntimeSettings, +} from "./executor.js"; import type { DeepWorkerParentSandbox } from "./parent-sandbox.js"; import type { DeepScanRunState } from "./types.js"; @@ -26,6 +30,7 @@ export interface DeepScanExecutionSettings { nativeServiceTierAbsent?: true; providerConfig?: JsonObject; parentSandbox?: DeepWorkerParentSandbox; + runtimeSettings?: CodexSdkWorkerRuntimeSettings; } export interface DeepScanLegacySettingsContext { @@ -101,6 +106,7 @@ export async function captureDeepScanExecutionSettings( ? { nativeServiceTierAbsent: true as const } : {}), providerConfig: selected.model_providers as JsonObject | undefined, + runtimeSettings: workerRuntimeSettingsFromConfig(config), parentSandbox, }); } @@ -334,6 +340,7 @@ export function restoredDeepScanWorkerSettings( model?: string; reasoningEffort?: string; parentSandbox: DeepWorkerParentSandbox; + runtimeSettings?: CodexSdkWorkerRuntimeSettings; } { const originalSandbox = settings.parentSandbox; const depths = [ @@ -352,6 +359,9 @@ export function restoredDeepScanWorkerSettings( return { model: settings.model, reasoningEffort: settings.reasoningEffort, + ...(settings.runtimeSettings === undefined + ? {} + : { runtimeSettings: settings.runtimeSettings }), parentSandbox: { filesystemDenies: [ ...new Set([ @@ -432,6 +442,9 @@ function executionSettings( ? { nativeServiceTierAbsent: true } : {}), ...(provider === undefined ? {} : { providerConfig: provider }), + ...(value.runtimeSettings === undefined + ? {} + : { runtimeSettings: structuredClone(value.runtimeSettings) }), ...(value.parentSandbox === undefined ? {} : { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index 38f25ab704..6fd18bb73a 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -807,6 +807,7 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { }; return { scanId: requiredString(value.scanId, "deepScan.scanId"), + ownerThreadId: optionalString(value.ownerThreadId), schemaVersion: optionalPositiveInteger(value.schemaVersion), workflowVersion: optionalString(value.workflowVersion), finalizationInput: parseFinalizationInput(value.finalizationInput), diff --git a/plugins/codex-security/mcp-app/src/deep-scan/types.ts b/plugins/codex-security/mcp-app/src/deep-scan/types.ts index f5c9844904..512fba2231 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/types.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/types.ts @@ -42,6 +42,7 @@ export interface DeepScanFinalizationInput { export interface DeepScanRunState { scanId: string; + ownerThreadId?: string; startDisposition?: "created" | "joined"; schemaVersion?: number; workflowVersion?: string; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts index 8f12eaf3d7..583e59b46e 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/worker-runner.ts @@ -4,7 +4,7 @@ import { runAcceptedAudit, } from "../../../../../sdk/typescript/src/accepted-audit.js"; import { promises as fs } from "node:fs"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { readDeepReductionSources } from "../artifact-deep-reducer.js"; import { readDiscoveryAuditDraft, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index bae90cf8bd..57553a86ec 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -99,6 +99,7 @@ try { await testOpenAiCredentialsReachWorker(); await testWorkerRuntimeSettings(); await testWorkerCyberAccessSettings(); + await testIsolatedReconstructedWorkers(); if (process.platform !== "win32") { await testMissingParentSandboxFailsBeforeWorkerLaunch(); await testDisallowedWorkerProfileFailsBeforeWorkerLaunch(); @@ -109,6 +110,8 @@ try { await testBedrockCredentialsReachWorker(); await testArtifactServerUsesExtendedStartupTimeout(); await testReducerCoveragePersistenceBinding(); + await testWorkerProviderSelection(); + await testNullUsageCompletion(); await testZeroSubagentsPreservesHostRestrictions(); await testSdkResumesExistingThread(); await testRetryNotificationDoesNotInterruptTurn(); @@ -2210,7 +2213,11 @@ async function runFixtureWorker( } async function fakeCodexFixture( - preflightProfile = emptyWorkerPermissionProfile, + preflightProfile: { + extends: string; + filesystem: Record; + network: { enabled: boolean }; + } = emptyWorkerPermissionProfile, preflightAllowed = true, accountResult: { account: { type: string } | null; @@ -2233,7 +2240,7 @@ const preflightAllowed = ${JSON.stringify(preflightAllowed)}; const accountResult = ${JSON.stringify(accountResult)}; const preflightMarkerPath = process.env.FAKE_CODEX_PREFLIGHT_MARKER ?? ${JSON.stringify(preflightMarkerPath)}; if (process.argv.includes('app-server')) { - const preflight = { cwd: process.cwd(), codexHome: process.env.CODEX_HOME, runnerTrackingId: process.env.RUNNER_TRACKING_ID, libraryPath: process.env.LD_LIBRARY_PATH, gitEnvironment: Object.fromEntries(['PATH', 'CODEX_SECURITY_GIT', 'GIT_SSH_COMMAND', 'GIT_CONFIG_GLOBAL'].map(name => [name, process.env[name]])), requests: [] }; + const preflight = { argv: process.argv.slice(2), cwd: process.cwd(), codexHome: process.env.CODEX_HOME, runnerTrackingId: process.env.RUNNER_TRACKING_ID, libraryPath: process.env.LD_LIBRARY_PATH, gitEnvironment: Object.fromEntries(['PATH', 'CODEX_SECURITY_GIT', 'GIT_SSH_COMMAND', 'GIT_CONFIG_GLOBAL'].map(name => [name, process.env[name]])), requests: [] }; writeFileSync(preflightMarkerPath, JSON.stringify(preflight)); let buffer = ''; process.stdin.setEncoding('utf8'); @@ -2444,18 +2451,15 @@ async function testIsolatedReconstructedWorkers() { }; const currentParentSandbox = name === "first" ? uncappedSandbox : trustedParentSandboxWithDenials; - const expectedProfile = { - ...structuredClone(deniedWorkerPermissionProfile), + const fixture = await fakeCodexFixture({ + ...deniedWorkerPermissionProfile, filesystem: { ":root": "read", - ":minimal": "read", - ":cwd": "write", "/repo/.env": "deny", "/repo/**/*.pem": "deny", "/repo/**/.secret": "deny", }, - }; - const fixture = await fakeCodexFixture(expectedProfile); + }); const codexHome = path.join(fixture.root, "home"); const configPath = path.join(fixture.root, "scan config.toml"); const promptPath = path.join(fixture.root, "prompt.md"); @@ -2483,7 +2487,8 @@ async function testIsolatedReconstructedWorkers() { .join("") + (name === "second" ? '[model_providers.amazon-bedrock.aws]\nregion = "us-west-2"\nprofile = "fixture-profile"\n' - : ""), + : "") + + `[codex_security]\ncyber_access_program = "${name === "first" ? "daybreak_blue" : "standard"}"\n[features]\napi_key_cyber_access_programs = ${name === "first"}\napi_key_model_discovery = ${name !== "first"}\n`, ); const providerKeys = name === "first" @@ -2865,7 +2870,10 @@ async function testIsolatedReconstructedWorkers() { ); assert.equal(preflight.codexHome, child.codexHome); assert.equal(child.scanValue, `${scan.name}-${phase}`); - assert.equal(child.configPath, scan.configPath); + assert.equal( + child.configPath, + scan.runtimeEnvironment.CODEX_SECURITY_CONFIG_PATH, + ); assert.deepEqual(child.openaiAuthentication, { CODEX_API_KEY: `synthetic-${scan.name}-${phase}`, }); @@ -2879,6 +2887,15 @@ async function testIsolatedReconstructedWorkers() { ), ); assertFlagPair(child.argv, "--model", scan.settings.model); + assertFlagPair( + child.argv, + "--cyber-access-program", + scan.name === "first" ? "daybreak_blue" : "standard", + ); + assertConfigOverrides(child.argv, { + "features.api_key_cyber_access_programs": scan.name === "first", + "features.api_key_model_discovery": scan.name !== "first", + }); for (const key of [ "model_provider", "model_reasoning_summary", @@ -2916,7 +2933,10 @@ async function testIsolatedReconstructedWorkers() { const providers = parseToml(provider.join("\n")) .model_providers as Record>; if (scan.expectedProvider) - assert.deepEqual(providers, scan.expectedProvider); + assert.deepEqual( + JSON.parse(JSON.stringify(providers)), + scan.expectedProvider, + ); else assert.deepEqual(Object.keys(providers.openrouter).sort(), [ "base_url", @@ -2963,10 +2983,16 @@ async function testIsolatedReconstructedWorkers() { } if (phase === "fresh") { for (const scan of scans) { + const replacementConfig = path.join( + scan.fixture.root, + "replacement config.toml", + ); await writeFile( - scan.configPath, - 'model_provider = "changed-provider"\nmodel_reasoning_summary = "detailed"\n', + replacementConfig, + 'model_provider = "changed-provider"\nmodel_reasoning_summary = "detailed"\n[codex_security]\ncyber_access_program = "daybreak_red"\n[features]\napi_key_cyber_access_programs = false\napi_key_model_discovery = false\n', ); + scan.runtimeEnvironment.CODEX_SECURITY_CONFIG_PATH = + replacementConfig; } } } diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts index 34ccc7cd4f..4b0c4cbff6 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts @@ -7,6 +7,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { + appendFile, chmod, mkdir, readFile, @@ -48,6 +49,9 @@ if (process.platform === "win32") { "failure", "active-failure", "remote-replay", + "snapshot-observer", + "native-owner-usage", + "native-detached-usage", "lost-response", "lost-response-corrupt", "lost-response-remove", @@ -80,6 +84,14 @@ async function testDeepScanDetachedCompletion(mode: string) { : `.deep-scan-detached-${randomUUID()}.cjs`, ); const threadId = "deep-scan-detached-result-conversation"; + const usageMode = + mode === "native-owner-usage" || mode === "native-detached-usage"; + const explicitCompletion = + mode === "active-failure" || + mode === "cancel-after-seal" || + mode.startsWith("cancel-finalizer") || + mode.startsWith("late-rejoin"); + const ownerRolloutPath = path.join(codexHome, "owner-rollout.jsonl"); for (const directory of [ targetPath, stateDir, @@ -97,7 +109,7 @@ async function testDeepScanDetachedCompletion(mode: string) { await writePythonWrapper(pythonWrapperPath); if (mode === "cancel-after-seal") await writeFile(committedControlPath, "wait"); - if (mode !== "detached") + if (mode !== "detached" && !usageMode) await writeFile( finalizerControlPath, mode === "joined" || @@ -105,7 +117,9 @@ async function testDeepScanDetachedCompletion(mode: string) { mode.startsWith("cancel-finalizer") || mode.startsWith("late-rejoin") ? "wait" - : mode === "active-failure" || mode === "remote-replay" + : mode === "active-failure" || + mode === "remote-replay" || + mode === "snapshot-observer" ? "failure" : mode.startsWith("lost-response") ? "lost-response" @@ -137,6 +151,36 @@ async function testDeepScanDetachedCompletion(mode: string) { "unused-signal-control", ), }; + if (usageMode) { + environment.CODEX_SQLITE_HOME = codexHome; + environment.CODEX_STATE_DB = ""; + await writeFile( + ownerRolloutPath, + [ + { type: "session_meta", payload: { id: threadId, source: "cli" } }, + { + timestamp: new Date().toISOString(), + type: "turn_context", + payload: { turn_id: "native-owner-turn", model: "gpt-5.6-sol" }, + }, + ] + .map((record) => JSON.stringify(record) + "\n") + .join(""), + ); + await execFileAsync(environment.REAL_PYTHON!, [ + "-c", + ` +import sqlite3, sys +with sqlite3.connect(sys.argv[1]) as connection: + connection.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL)") + connection.execute("CREATE TABLE thread_spawn_edges (parent_thread_id TEXT NOT NULL, child_thread_id TEXT NOT NULL)") + connection.execute("INSERT INTO threads VALUES (?, ?)", (sys.argv[2], sys.argv[3])) +`, + path.join(codexHome, "state_5.sqlite"), + threadId, + ownerRolloutPath, + ]); + } const server = startServer(serverBundlePath, environment); let remote: ReturnType | undefined; try { @@ -159,6 +203,7 @@ async function testDeepScanDetachedCompletion(mode: string) { const scanId = await waitForScanId({ server, requestId: 2 }); await waitForDeepScanWorker({ environment, scanId, threadId }); const [worker] = await waitForJsonLines(startLogPath, 1); + if (usageMode) await appendOwnerUsage(10); if (mode === "joined") { server.sendRequest( 3, @@ -197,6 +242,7 @@ async function testDeepScanDetachedCompletion(mode: string) { } if ( mode !== "active-failure" && + mode !== "native-owner-usage" && mode !== "cancel-after-seal" && !mode.startsWith("cancel-finalizer") && !mode.startsWith("late-rejoin") @@ -252,6 +298,54 @@ async function testDeepScanDetachedCompletion(mode: string) { createHash("sha256").update(selectedBytes).digest("hex"), finished.finalizationInput.resultSha256, ); + if (usageMode) { + if (mode === "native-owner-usage") { + assertNoError(await server.waitForResponse(2)); + } else { + await waitFor( + async () => + (await runWorkbench(environment, ["get-scan", "--scan-id", scanId])) + .scan.progress.status === "complete", + "detached native publication", + ); + await appendFile( + ownerRolloutPath, + JSON.stringify({ + timestamp: new Date().toISOString(), + type: "turn_context", + payload: { turn_id: "unrelated-later-turn", model: "gpt-5.6-sol" }, + }) + "\n", + ); + } + await appendOwnerUsage(1010); + const completed = await server.request( + 60, + "tools/call", + toolCall("complete_codex_security_scan", { scanId }, threadId), + ); + assertNoError(completed); + const afterOwner = await runWorkbench(environment, [ + "get-scan", + "--scan-id", + scanId, + ]); + assert.equal(afterOwner.scan.progress.status, "complete"); + assert.equal( + afterOwner.scan.usage.inputTokens, + mode === "native-owner-usage" ? 1010 : 10, + "completion accounts for the owning continuation, excluding later conversation work", + ); + console.log("native owning continuation usage passed", mode, scanId); + return; + } + if (explicitCompletion) { + assertNoError(await server.waitForResponse(2)); + server.sendRequest( + 60, + "tools/call", + toolCall("complete_codex_security_scan", { scanId }, threadId), + ); + } if (mode === "cancel-after-seal") { await waitForJsonLines(committedLogPath, 1); const beforeCancel = await runWorkbench(environment, [ @@ -268,15 +362,9 @@ async function testDeepScanDetachedCompletion(mode: string) { "tools/call", toolCall("cancel_codex_security_scan", { scanId }, threadId), ); - await waitFor( - () => - server - .stderrEvents() - .some((event) => event.event === "coordinator_cancel_requested"), - "cancellation after parent completion committed", - ); - await rm(committedControlPath, { force: true }); const cancellation = await server.waitForResponse(6); + await rm(committedControlPath, { force: true }); + assertNoError(await server.waitForResponse(60)); assert.equal(cancellation.result?.isError, true); const afterCancel = await runWorkbench(environment, [ "get-scan", @@ -301,18 +389,11 @@ async function testDeepScanDetachedCompletion(mode: string) { "tools/call", toolCall("cancel_codex_security_scan", { scanId }, threadId), ); - await waitFor( - () => - server - .stderrEvents() - .some((event) => event.event === "coordinator_cancel_requested"), - "owner cancellation before releasing parent completion", - ); + const canceled = await server.waitForResponse(6); if (mode === "cancel-finalizer-failure") await writeFile(finalizerControlPath, "failure"); else await rm(finalizerControlPath, { force: true }); - const canceled = await server.waitForResponse(6); - const ownerResponse = await server.waitForResponse(2); + const ownerResponse = await server.waitForResponse(60); const publicScan = await runWorkbench(environment, [ "get-scan", "--scan-id", @@ -325,7 +406,13 @@ async function testDeepScanDetachedCompletion(mode: string) { "cancellation observed before parent completion must remain canceled", ); assertNoError(canceled); - assertCanceled(ownerResponse, scanId, finished.scanDir); + assert.equal(ownerResponse.result?.isError, true); + assert.match( + ownerResponse.result.content + .map((item: { text: string }) => item.text) + .join(" "), + /Only a running scan can be completed|injected complete-scan failure/, + ); assert.equal(stopped.status, "canceled"); assert.deepEqual( await readFile( @@ -370,7 +457,7 @@ async function testDeepScanDetachedCompletion(mode: string) { if (mode === "late-rejoin-failure") await writeFile(finalizerControlPath, "failure"); else await rm(finalizerControlPath); - const ownerResponse = await server.waitForResponse(2); + const ownerResponse = await server.waitForResponse(60); const invocations = await readJsonLines(finalizerLogPath); assertNoError(observed); assert.equal( @@ -449,26 +536,27 @@ async function testDeepScanDetachedCompletion(mode: string) { mode === "failure" || mode === "active-failure" || mode === "remote-replay" || + mode === "snapshot-observer" || mode.startsWith("lost-response") ) { - await waitFor( - () => - server - .stderrEvents() - .some((event) => event.event === "coordinator_publication_pending"), - "public finalization failure to remain pending", - ); + if (mode !== "active-failure") + await waitFor( + () => + server + .stderrEvents() + .some( + (event) => event.event === "coordinator_publication_pending", + ), + "public finalization failure to remain pending", + ); if (mode === "active-failure") { - const failed = await server.waitForResponse(2); + const failed = await server.waitForResponse(60); assert.equal(failed.result?.isError, true); const message = failed.result.content .map((item: { text: string }) => item.text) .join(" "); assert.match(message, /injected complete-scan failure/); - assert.match( - message, - /Do not call start_codex_security_deep_scan again in this response/, - ); + assert.match(message, /Do not retry completion/); assert.match(message, /no final|Do not.*final/i); } const pending = await getDeepScan({ environment, scanId, threadId }); @@ -492,6 +580,28 @@ async function testDeepScanDetachedCompletion(mode: string) { const manifestBeforeReplay = await readFile( path.join(finished.scanDir, "scan-manifest.json"), ); + if (mode === "snapshot-observer") { + const observed = await server.request( + 50, + "tools/call", + toolCall( + "start_codex_security_deep_scan", + { targetPath, scope: "." }, + "independent-snapshot-observer", + ), + ); + assertNoError(observed); + assert.equal(observed.result.structuredContent.scanId, scanId); + assert.equal( + observed.result.structuredContent.manifestPath, + path.join(finished.scanDir, "scan-manifest.json"), + ); + assert.equal( + (await readJsonLines(finalizerLogPath)).length, + 1, + "a snapshot observer must not acquire publication ownership", + ); + } let replayServer = server; if (mode === "remote-replay") { await server.stop(); @@ -564,6 +674,14 @@ async function testDeepScanDetachedCompletion(mode: string) { return; } assertNoError(rejoined); + if (!mode.startsWith("lost-response")) + assertNoError( + await replayServer.request( + 61, + "tools/call", + toolCall("complete_codex_security_scan", { scanId }, threadId), + ), + ); assert.equal( rejoined.result.structuredContent.manifestPath, path.join(finished.scanDir, "scan-manifest.json"), @@ -623,7 +741,8 @@ async function testDeepScanDetachedCompletion(mode: string) { (await readJsonLines(finalizerLogPath)).length, mode === "failure" || mode === "active-failure" || - mode === "remote-replay" + mode === "remote-replay" || + mode === "snapshot-observer" ? 2 : 1, ); @@ -638,6 +757,28 @@ async function testDeepScanDetachedCompletion(mode: string) { if (!installedPluginRoot) await rm(serverBundlePath, { force: true }); await rm(fixtureRoot, { recursive: true, force: true }); } + async function appendOwnerUsage(inputTokens: number) { + await appendFile( + ownerRolloutPath, + JSON.stringify({ + timestamp: new Date().toISOString(), + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: 1, + reasoning_output_tokens: 0, + total_tokens: inputTokens + 1, + }, + }, + }, + }) + "\n", + ); + } } async function testDeepScanStdioLifecycle() { diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 2938e6774e..a0db52f36d 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -437,6 +437,8 @@ def _deep_scan_state(connection: sqlite3.Connection, scan_id: str) -> dict[str, ) return { "scanId": run["scan_id"], + "ownerThreadId": scan["deep_scan_owner_thread_id"] + or dependencies().require_workspace(connection, scan["workspace_id"])["thread_id"], "targetPath": scan["target_path"], "scope": scan["scope"], "model": scan["model"], diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py index 4ce4607264..458f873a45 100644 --- a/plugins/codex-security/tests/test_budget_coverage_provenance.py +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -173,6 +173,8 @@ def interrupt_before_seal(*args): workbench_db.backup(connection) with monkeypatch.context() as patch: patch.setattr(saved, "retain_unmerged_budget_coverage", legacy_projection) + # The legacy writer predates the selected-publication digest. + patch.setattr(saved, "record_selected_publication", lambda *args: None) patch.setitem(budget.__globals__, "complete_scan_locked", interrupt_before_seal) with pytest.raises(RuntimeError, match="budget draft committed"): budget(connection, args) diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py index 96b01a874b..57f1a80901 100644 --- a/plugins/codex-security/tests/test_deep_scan_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_compatibility.py @@ -100,6 +100,11 @@ def test_missing_or_unsupported_settings_reject_before_takeover( } ).encode() settings_path.write_bytes(saved) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET execution_settings_json = ? WHERE scan_id = ?", + (saved.decode(), run["scanId"]), + ) before = snapshot(state) result = claim_requiring_original_settings(state, run["scanId"]) assert snapshot(state) == before, ( diff --git a/plugins/codex-security/tests/test_deep_scan_persistence.py b/plugins/codex-security/tests/test_deep_scan_persistence.py index 0f2f0e67e9..22f81786d4 100644 --- a/plugins/codex-security/tests/test_deep_scan_persistence.py +++ b/plugins/codex-security/tests/test_deep_scan_persistence.py @@ -6,6 +6,7 @@ import sys import uuid from pathlib import Path +from types import SimpleNamespace import pytest from test_workbench_deep_scan import ( @@ -35,7 +36,14 @@ def test_state_snapshot_does_not_mix_concurrent_acceptance( ) database = state_dir / "workbench.sqlite3" deep_scan = sys.modules["deep_scan_workbench"] - monkeypatch.setattr(deep_scan, "require_scan", workbench_api["require_scan"]) + monkeypatch.setattr( + deep_scan, + "_dependencies", + SimpleNamespace( + **workbench_api, + preserve_stopped_results=workbench_api["preserve_stopped_results_after_transition"], + ), + ) original = deep_scan.require_deep_scan_run def accept_after_read(connection, requested_scan_id): @@ -68,7 +76,14 @@ def test_state_snapshot_preserves_its_callers_transaction( initial = begin_target_scan(tmp_path / "state", tmp_path / "codex", target, tmp_path / "scans") scan_id = initial["deepScan"]["scanId"] deep_scan = sys.modules["deep_scan_workbench"] - monkeypatch.setattr(deep_scan, "require_scan", workbench_api["require_scan"]) + monkeypatch.setattr( + deep_scan, + "_dependencies", + SimpleNamespace( + **workbench_api, + preserve_stopped_results=workbench_api["preserve_stopped_results_after_transition"], + ), + ) with sqlite3.connect(tmp_path / "state" / "workbench.sqlite3") as connection: connection.row_factory = sqlite3.Row connection.execute("BEGIN IMMEDIATE") diff --git a/plugins/codex-security/tests/test_deep_scan_publication_replay.py b/plugins/codex-security/tests/test_deep_scan_publication_replay.py index 461f3c63ae..cfa040b38d 100644 --- a/plugins/codex-security/tests/test_deep_scan_publication_replay.py +++ b/plugins/codex-security/tests/test_deep_scan_publication_replay.py @@ -144,7 +144,17 @@ def test_publication_crash_replays_selected_input_without_stale_overwrite( )["scan"] assert completed["progress"]["status"] == "complete" assert completed["findingCount"] == 1 - assert dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) == run_before + run_after = dict(connection.execute("SELECT * FROM deep_scan_runs").fetchone()) + if run_before["finalization_input_json"] is not None: + before_selection = json.loads(run_before["finalization_input_json"]) + after_selection = json.loads(run_after["finalization_input_json"]) + digest = after_selection.pop("publicationSha256") + assert len(digest) == 64 and all(char in "0123456789abcdef" for char in digest) + if "publicationSha256" in before_selection: + assert digest == before_selection.pop("publicationSha256") + assert after_selection == before_selection + run_after["finalization_input_json"] = run_before["finalization_input_json"] + assert run_after == run_before assert [ dict(row) for row in connection.execute("SELECT * FROM deep_scan_workers") ] == workers_before diff --git a/plugins/codex-security/tests/test_selected_publication_authority.py b/plugins/codex-security/tests/test_selected_publication_authority.py index 8818450deb..c87d7304ac 100644 --- a/plugins/codex-security/tests/test_selected_publication_authority.py +++ b/plugins/codex-security/tests/test_selected_publication_authority.py @@ -74,12 +74,22 @@ def test_publication_uses_committed_finalization_selection( ) assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} == before assert accepted.read_bytes() == contents - assert ( - json.loads( - workbench_db.execute( - "SELECT finalization_input_json FROM deep_scan_runs WHERE scan_id = ?", - (scan.scan_id,), - ).fetchone()[0] - ) - == selection + current = json.loads( + workbench_db.execute( + "SELECT finalization_input_json FROM deep_scan_runs WHERE scan_id = ?", + (scan.scan_id,), + ).fetchone()[0] ) + if publication == "selected": + digest = current.pop("publicationSha256") + assert len(digest) == 64 and all(char in "0123456789abcdef" for char in digest) + current_scan = workbench_api["require_scan"](workbench_db, scan.scan_id) + db = workbench_api["_WORKBENCH_DB_CONTEXT"] + saved = workbench_api["saved_results"] + prepared = saved._prepare_scan_finalization( + scan.scan_dir, + expected_coverage_mode=db.expected_coverage_mode(current_scan), + completion_binding=db.workbench_completion_binding(current_scan, db.now()), + ) + saved.require_selected_publication(db, workbench_db, current_scan, prepared) + assert current == selection diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 1dbe574475..af37f19e08 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -1226,7 +1226,7 @@ def test_workbench_persists_progress_and_indexes_completed_findings(tmp_path: Pa ) } assert tables == EXPECTED_TABLES - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (46,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (47,) assert connection.execute("SELECT COUNT(*) FROM findings").fetchone() == (1,) assert connection.execute("SELECT COUNT(*) FROM finding_locations").fetchone() == (1,) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 1f03be98ca..bf2f7dcc7f 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -58,6 +58,7 @@ interface SessionUsage { counterUsage: ScanTokenUsage | null; model: string | null; modelUsage: Map; + counterModelUsage: Map; currentTurnId: string | null; previousUsage: ScanTokenUsage | null; responseIds: Set; @@ -117,6 +118,7 @@ function createSessionUsage(): SessionUsage { counterUsage: null, model: null, modelUsage: new Map(), + counterModelUsage: new Map(), currentTurnId: null, previousUsage: null, responseIds: new Set(), @@ -371,6 +373,7 @@ export class ScanCostTracker { } } const usageSessions = new Map(); + const counterSessions = new Map(); for (const [path, tracked] of this.#sessions) { const threadId = tracked.threadId; if (threadId === null || !included.has(threadId)) continue; @@ -442,6 +445,12 @@ export class ScanCostTracker { ) { usages.set(threadId, session.counterUsage); } + if ( + session.counterUsage && + session.counterUsage.total_tokens > + (counterSessions.get(threadId)?.counterUsage?.total_tokens ?? -1) + ) + counterSessions.set(threadId, session); const receipt = usages.get(threadId); if ( session.usage !== null && @@ -478,6 +487,7 @@ export class ScanCostTracker { return; } const modelUsage = new Map(); + const liveModelUsage = new Map(); let observedModel = false; for (const [threadId, value] of usages) { if (value === null) continue; @@ -503,6 +513,27 @@ export class ScanCostTracker { addTokenUsage(modelUsage.get(model) ?? null, remainder), ); } + const counter = counterSessions.get(threadId); + const live = + (counter?.counterUsage?.total_tokens ?? -1) > + (session?.usage?.total_tokens ?? -1); + const liveTokens = live ? counter?.counterUsage : session?.usage; + const liveModels = live + ? counter?.counterModelUsage + : session?.modelUsage; + for (const [model, tokens] of liveModels ?? []) + liveModelUsage.set( + model, + addTokenUsage(liveModelUsage.get(model) ?? null, tokens), + ); + const liveRemainder = liveTokens + ? subtractTokenUsage(value, liveTokens) + : value; + if (liveRemainder !== null && liveRemainder.total_tokens > 0) + liveModelUsage.set( + null, + addTokenUsage(liveModelUsage.get(null) ?? null, liveRemainder), + ); } const reconciled = observedModel || this.#attribution !== null @@ -519,7 +550,13 @@ export class ScanCostTracker { : reconciled; const cost = estimateScanCost(this.#options.model, measured); this.#snapshot = { usage: measured, cost }; - this.#reportCost(cost, measured); + this.#reportCost(cost, measured, { + ...usage, + modelUsage: [...liveModelUsage].map(([model, tokens]) => ({ + model, + ...tokens, + })), + }); } #reportWorkerProgress(session: SessionUsage): void { @@ -562,10 +599,25 @@ export class ScanCostTracker { } } - #reportCost(cost: ScanCost | null, usage: unknown): void { + #reportCost( + cost: ScanCost | null, + usage: unknown, + liveUsage?: unknown, + ): void { if (cost === null) { if (this.#options.onCostLowerBound === undefined) return; - const lowerBound = estimateScanCostLowerBound(this.#options.model, usage); + const receiptCost = estimateScanCostLowerBound( + this.#options.model, + usage, + ); + const liveCost = estimateScanCostLowerBound( + this.#options.model, + liveUsage, + ); + const lowerBound = + liveCost && liveCost.estimatedUsd > (receiptCost?.estimatedUsd ?? -1) + ? liveCost + : receiptCost; if (lowerBound === null) return; const signature = JSON.stringify(lowerBound); if (signature === this.#lastCostLowerBound) return; @@ -951,6 +1003,7 @@ function readSessionEvent( ? usage : subtractTokenUsage(usage, session.inheritedUsage); if (ownUsage !== null) { + const hadPreviousUsage = session.previousUsage !== null; const delta = session.previousUsage === null ? ownUsage @@ -973,11 +1026,20 @@ function readSessionEvent( ) ) return; - if (delta !== null && !session.responseUsageObserved) { - session.modelUsage.set( - session.model, - addTokenUsage(session.modelUsage.get(session.model) ?? null, delta), + if (delta !== null) { + const model = + !hadPreviousUsage && session.responseUsageObserved + ? null + : session.model; + session.counterModelUsage.set( + model, + addTokenUsage(session.counterModelUsage.get(model) ?? null, delta), ); + if (!session.responseUsageObserved) + session.modelUsage.set( + session.model, + addTokenUsage(session.modelUsage.get(session.model) ?? null, delta), + ); } session.counterUsage = attribution && delta !== null diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 42a8f37c11..b36b76d503 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -10,10 +10,8 @@ import { symlink, writeFile, } from "node:fs/promises"; -import { delimiter, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { basename, delimiter, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { brotliDecompressSync } from "node:zlib"; import { promisify } from "node:util"; import { afterEach, describe, expect, test } from "bun:test"; @@ -346,9 +344,13 @@ describe("bundled plugin build", () => { const sdk = join(root, "sdk", "typescript"); await mkdir(sdk, { recursive: true }); for (const name of ["src", "package.json", "tsconfig.json"]) { - await cp(new URL(`../${name}`, import.meta.url), join(sdk, name), { recursive: true }); + await cp(new URL(`../${name}`, import.meta.url), join(sdk, name), { + recursive: true, + }); } - await expect(stat(join(sdk, "node_modules"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(join(sdk, "node_modules"))).rejects.toMatchObject({ + code: "ENOENT", + }); await cp(join(plugin, "mcp-app"), join(source, "mcp-app"), { recursive: true, filter: (path) => diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index e41c756002..173540148b 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -2156,6 +2156,116 @@ describe("recorded Deep worker homes", () => { expect(estimateScanCostLowerBound("gpt-5.6-sol", invalid)).toBeNull(); }); + test.each(["gpt-5.6-sol", "synthetic-unpriced-model"])( + "prices live worker counters after an earlier response receipt with model %s", + async (currentModel) => { + const home = await codexHome(); + const at = "2026-09-01T00:00:02Z"; + await writeSession(home, "owner", {}); + const worker = await writeSession(home, "worker", {}); + const receipt = (id: string, model: string, input: number) => ({ + type: "token_usage_record", + timestamp: at, + payload: { + thread_id: "worker", + turn_id: "worker-turn", + response_id: id, + model, + usage: { input_tokens: input, output_tokens: 0 }, + }, + }); + await appendFile( + worker, + jsonLines([ + { + type: "turn_context", + timestamp: at, + payload: { turn_id: "worker-turn", model: "gpt-5.6-sol" }, + }, + { + type: "event_msg", + timestamp: at, + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 100, + output_tokens: 0, + }, + }, + }, + }, + receipt("earlier", "gpt-5.6-sol", 100), + ]) + "\n", + ); + const costUpdates: Readonly[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + maxCostUsd: 0.002, + onCost: (cost) => costUpdates.push(cost), + onCostLowerBound: (cost) => costUpdates.push(cost), + }); + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: ["worker"], + owner: { threadId: "owner", turnId: "turn", startedAt: at }, + startedAt: at, + completedAt: null, + })); + tracker.start("owner"); + try { + await tracker.refresh(); + expect(costUpdates.at(-1)?.estimatedUsd).toBeCloseTo(0.0004, 10); + await appendFile( + worker, + jsonLines([ + { + type: "turn_context", + timestamp: at, + payload: { turn_id: "worker-turn", model: currentModel }, + }, + { + type: "event_msg", + timestamp: at, + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 1_100, + output_tokens: 0, + }, + }, + }, + }, + ]) + "\n", + ); + const running = await tracker.refresh(); + expect(tokenUsage(running.usage)?.input_tokens).toBe(1_100); + expect(running.cost).toBeNull(); + expect(costUpdates.at(-1)?.estimatedUsd).toBeCloseTo( + currentModel === "gpt-5.6-sol" ? 0.0044 : 0.0004, + 10, + ); + if (currentModel === "gpt-5.6-sol") + expect(costUpdates.at(-1)!.estimatedUsd).toBeGreaterThan(0.002); + const completedReceipt = receipt("current", currentModel, 1_000); + await appendFile( + worker, + jsonLines([completedReceipt, completedReceipt]) + "\n", + ); + const completed = await tracker.refresh(); + expect(tokenUsage(completed.usage)?.input_tokens).toBe(1_100); + expect(costUpdates.at(-1)?.estimatedUsd).toBeCloseTo( + currentModel === "gpt-5.6-sol" ? 0.0044 : 0.0004, + 10, + ); + } finally { + await tracker.stop(); + } + }, + ); + test.each([null, "synthetic-unpriced-model"])( "reports an internal priced lower bound with model %p without inventing a total", async (unknownModel) => { From 3e248291b08035a17dfb20c167c03b4cfda19e0b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 6 Oct 2026 18:01:53 +0000 Subject: [PATCH 180/251] test(deep): capture publication binding before cancellation --- sdk/typescript/tests-ts/deep-finalization.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 2c83534de0..b819236f49 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -448,6 +448,21 @@ for (const { (outcome === "canceled-during-publication" || outcome === "canceled-during-resumed-publication") ) { + const published = await runWorkbench(options, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + threadId, + ]); + const bound = (published["deepScan"] as Record)[ + "finalizationInput" + ]; + expect(bound).toMatchObject(originalFinalizationInput); + expect(bound).toMatchObject({ + publicationSha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + }); + originalFinalizationInput = bound; if (initialResumeUsage) { expect(originalResumeSignal?.reason).toBeInstanceOf( ScanCostLimitExceededError, From 0737fdac0eb07ed080c9e26d1ff7c3de663e8fc9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 6 Oct 2026 18:25:17 +0000 Subject: [PATCH 181/251] test: type the preserved finalization selection assertion --- sdk/typescript/tests-ts/deep-finalization.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index b819236f49..9588a3a5f1 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -458,7 +458,9 @@ for (const { const bound = (published["deepScan"] as Record)[ "finalizationInput" ]; - expect(bound).toMatchObject(originalFinalizationInput); + expect(bound).toMatchObject( + originalFinalizationInput as Record, + ); expect(bound).toMatchObject({ publicationSha256: expect.stringMatching(/^[a-f0-9]{64}$/u), }); From a694901849929d773209814dada9ee3d4c8a8d3a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 6 Oct 2026 22:33:39 +0000 Subject: [PATCH 182/251] fix(deep): preserve worker settings and accepted accounting --- .../scripts/deep_scan_workbench.py | 8 +++ .../scripts/workbench_saved_results.py | 18 +++-- .../scripts/workbench_scan_usage.py | 3 +- .../test_budget_selection_publication.py | 67 +++++++++++++++++++ .../tests/test_workbench_scan_usage.py | 43 ++++++++++++ sdk/typescript/src/api.ts | 11 ++- sdk/typescript/src/cost.ts | 2 + sdk/typescript/tests-ts/api.test.ts | 26 +++++-- sdk/typescript/tests-ts/cost.test.ts | 8 +++ 9 files changed, 172 insertions(+), 14 deletions(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index a0db52f36d..039ce2a680 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -26,6 +26,7 @@ from workbench.storage import create_private_directory from workbench_saved_results import ( _restore_published_outputs, + _selected_publication_digest_for_documents, _snapshot_published_outputs, _worker_checkpoint_head, ) @@ -2474,6 +2475,13 @@ def prepare_budget_exhausted_deep_scan( ): raise SystemExit("Budget completion requires the accepted complete reducer.") write_draft(accepted, unmerged) + documents = tuple( + _read_scan_local_json(scan_dir, name, name) + for name in ("scan-manifest.json", "findings.json", "coverage.json") + ) + selection["publicationSha256"] = _selected_publication_digest_for_documents( + dependencies(), scan, documents + ) timestamp = dependencies().now() cancel_active_workers(connection, scan["id"], timestamp) connection.execute( diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 7cc136dc34..d909994232 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -2967,19 +2967,23 @@ def _require_selected_result(scan: Any, selection: dict[str, Any]) -> None: ) -def record_selected_publication(db: Any, connection: Any, scan: Any, documents: Any) -> None: - run = db.deep_scan.require_deep_scan_run(connection, scan["id"]) - selection = db.deep_scan.deep_scan_finalization_input(run) - if selection is None: - return - _require_selected_result(scan, selection) +def _selected_publication_digest_for_documents(db: Any, scan: Any, documents: Any) -> str: prepared = _prepare_scan_finalization( Path(scan["scan_dir"]), expected_coverage_mode=db.expected_coverage_mode(scan), completion_binding=db.workbench_completion_binding(scan, db.now(), documents[0]), draft_documents=documents, ) - selection["publicationSha256"] = _selected_publication_digest(prepared) + return _selected_publication_digest(prepared) + + +def record_selected_publication(db: Any, connection: Any, scan: Any, documents: Any) -> None: + run = db.deep_scan.require_deep_scan_run(connection, scan["id"]) + selection = db.deep_scan.deep_scan_finalization_input(run) + if selection is None: + return + _require_selected_result(scan, selection) + selection["publicationSha256"] = _selected_publication_digest_for_documents(db, scan, documents) with connection: connection.execute( "UPDATE deep_scan_runs SET finalization_input_json = ? WHERE scan_id = ?", diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 0175074775..779bbb7717 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -861,7 +861,8 @@ def _read_rollout_usage( if counter_total["totalTokens"] > total["totalTokens"]: remainder = {key: max(0, value - total[key]) for key, value in counter_total.items()} - total = dict(counter_total) + remainder["totalTokens"] = remainder["inputTokens"] + remainder["outputTokens"] + _add_token_usage(total, remainder) _add_token_usage(local_models.setdefault(None, _empty_token_usage()), remainder) if response_usage_observed: warnings.discard("token_counter_regressed") diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index 72355ad3e1..10150bc284 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -821,3 +821,70 @@ def test_budget_resume_keeps_explicit_stop_and_unselected_guards( }.items() }, ) + + +@pytest.mark.parametrize("changed", [False, True], ids=["unchanged", "changed-publication"]) +def test_budget_selection_binds_publication_in_its_commit( + workbench_api, workbench_db, publication_scan, tmp_path, changed +): + scan = publication_scan() + accept_reducer(workbench_db, scan) + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', phase = 'discovery', " + "workflow_version = 'deep-security-scan/v2', finalization_input_json = NULL" + ) + database = tmp_path / "budget-commit.sqlite3" + with sqlite3.connect(database) as connection: + workbench_db.backup(connection) + child_program = """ +import json, os, runpy, sqlite3, sys +from argparse import Namespace +from types import SimpleNamespace +api = runpy.run_path(sys.argv[1], run_name="budget_publication_commit_loss") +api["deep_scan"].configure(SimpleNamespace(**{**api, "preserve_stopped_results": api["preserve_stopped_results_after_transition"]})) +class CrashConnection(sqlite3.Connection): + def commit(self): + super().commit() + os._exit(73) +connection = sqlite3.connect(sys.argv[2], factory=CrashConnection) +connection.row_factory = sqlite3.Row +connection.execute("PRAGMA foreign_keys = ON") +api["complete_budget_exhausted_scan"](connection, Namespace(scan_id=sys.argv[3], cost_json=sys.argv[4], message=None)) +raise AssertionError("budget selection did not commit") +""" + child = subprocess.run( + [ + sys.executable, + "-c", + child_program, + workbench_api["__file__"], + str(database), + scan.scan_id, + json.dumps(BUDGET_COST), + ], + capture_output=True, + text=True, + ) + assert child.returncode == 73, child.stderr + findings_path = scan.scan_dir / "findings.json" + findings = json.loads(findings_path.read_text()) + assert findings["findings"] + if changed: + findings["findings"][0]["title"] = "Synthetic substituted publication finding" + findings_path.write_text(json.dumps(findings)) + with sqlite3.connect(database) as connection: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + args = Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None) + if changed: + with pytest.raises(SystemExit, match="selected Deep Scan publication"): + workbench_api["complete_budget_exhausted_scan"](connection, args) + assert connection.execute("SELECT status FROM scans").fetchone()[0] == "running" + else: + completed = workbench_api["complete_budget_exhausted_scan"](connection, args) + assert completed["scan"]["scanId"] == scan.scan_id + assert connection.execute("SELECT status FROM scans").fetchone()[0] == "complete" diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 64ff4a5130..3e11125c32 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -1272,3 +1272,46 @@ def usage(value): assert total == _counts(110, 0, 0) assert warnings == set() assert models == {"gpt-5.6-sol": _counts(110, 0, 0)} + + +@pytest.mark.parametrize( + "receipt,counter,expected", + [ + ((150, 15), (100, 200), (150, 200)), + ((100, 50), (200, 10), (200, 50)), + ((100, 10), (100, 10), (100, 10)), + ], + ids=["counter-output", "counter-input", "equal-control"], +) +def test_completion_retains_receipt_and_counter_categories(tmp_path, receipt, counter, expected): + fixture = _start_scan(tmp_path) + counted = fixture.started_at + timedelta(microseconds=1) + response = _event( + counted, + "token_usage_record", + { + "response_id": "category-response", + "thread_id": "scan-parent", + "model": "gpt-5.6-sol", + "usage": { + "input_tokens": receipt[0], + "output_tokens": receipt[1], + "total_tokens": sum(receipt), + }, + "thread_token_usage": { + "input_tokens": counter[0], + "output_tokens": counter[1], + "total_tokens": sum(counter), + }, + }, + ) + parent = _rollout(tmp_path, "scan-parent", [response, _token_event(counted, *counter)]) + _state_graph(fixture.environment, {"scan-parent": parent}, []) + usage = _complete_scan(fixture)["scan"]["usage"] + assert (usage["inputTokens"], usage["outputTokens"], usage["totalTokens"]) == ( + *expected, + sum(expected), + ) + for category in ("inputTokens", "outputTokens", "totalTokens"): + assert sum(row[category] for row in usage["modelUsage"]) == usage[category] + assert usage["coverage"] == ("complete" if receipt == counter else "partial") diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index f4059187ba..b6f72a95c9 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1416,7 +1416,16 @@ export class CodexSecurity { model_reasoning_summary: summary, }; if (runtime.configPath !== undefined) - await writeCodexConfig(runtime.configPath, preflightConfig); + await writeCodexConfig(runtime.configPath, { + ...preflightConfig, + ...(options.cyberAccessProgram === undefined + ? {} + : { + codex_security: { + cyber_access_program: options.cyberAccessProgram, + }, + }), + }); } } diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index bf2f7dcc7f..53714723ac 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -462,6 +462,8 @@ export class ScanCostTracker { usages.set(threadId, session.usage); } if (!usages.has(threadId)) usages.set(threadId, null); + } + for (const [threadId, session] of usageSessions) { if ( (session.counterRegressed && !session.responseUsageObserved) || session.expectedResponseTokens > session.responseTokens diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 540e9287dc..3541717c73 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -6787,12 +6787,13 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s }); test.each([ - ["standard", false], - ["deep", false], - ["deep", true], + ["standard", false, false], + ["deep", false, false], + ["deep", false, true], + ["deep", true, true], ] as const)( - "isolates concurrent managed %s sessions at the Codex child boundary (capture=%s)", - async (mode, captureSummary) => { + "isolates concurrent managed %s sessions at the Codex child boundary (capture=%s, program=%s)", + async (mode, captureSummary, selectedProgram) => { const clients: TestClient[] = []; try { const outcomes = await Promise.allSettled( @@ -6918,9 +6919,11 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s ); clients.push(client); const postScanPrompt = "Summarize the completed synthetic scan."; + const program = name === "first" ? "daybreak_blue" : "daybreak_red"; const result = await client.run(repository, { mode, postScanPrompt, + ...(selectedProgram ? { cyberAccessProgram: program } : {}), }); expect(result.threadId).toBe(`fixture-${name}-thread`); expect(result.turnResult.usage).toBeNull(); @@ -6948,6 +6951,11 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s summary, ); expect(preflight).not.toContain(`synthetic-${name}-key`); + if (selectedProgram) + expect(parseToml(preflight)).toMatchObject({ + codex_security: { cyber_access_program: program }, + }); + else expect(parseToml(preflight)["codex_security"]).toBeUndefined(); expect(children).toHaveLength(2); expect(children[1].prompt).toBe(postScanPrompt); expect(children[1].args).toContain("resume"); @@ -6961,6 +6969,14 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s expect(child.home).toBe(codexHome); expect(child.key).toBe(`synthetic-${name}-key`); expect(child.value).toBe(name); + if (selectedProgram) { + const programIndex = child.args.indexOf( + "--cyber-access-program", + ); + expect( + child.args.slice(programIndex, programIndex + 2), + ).toEqual(["--cyber-access-program", program]); + } else expect(child.args).not.toContain("--cyber-access-program"); expect(child.args).toContain(`model=${JSON.stringify(model)}`); expect(child.args).toContain( `model_provider=${JSON.stringify(provider)}`, diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 173540148b..7e269db24b 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -2662,6 +2662,10 @@ describe("recorded Deep worker homes", () => { ["prefix-first", true], ["prefix-last", false], ["prefix-last", true], + ["truncated-first", false], + ["truncated-first", true], + ["truncated-last", false], + ["truncated-last", true], ] as const)( "prices copied response records (%s, attribution: %s)", async (copy, attributed) => { @@ -2705,6 +2709,9 @@ describe("recorded Deep worker homes", () => { .join("\n") + "\n"; if (copy === "prefix-first") await writeFile(path, prefix); if (copy === "prefix-last") await writeFile(copiedPath, prefix); + const truncated = prefix + '{"type":"token_usage_record"'; + if (copy === "truncated-first") await writeFile(path, truncated); + if (copy === "truncated-last") await writeFile(copiedPath, truncated); const tracker = new ScanCostTracker({ codexHome: home, scanDirectory, @@ -2730,6 +2737,7 @@ describe("recorded Deep worker homes", () => { output_tokens: 15, total_tokens: 165, }); + expect(snapshot.usage).not.toMatchObject({ coverage: "partial" }); expect( Object.fromEntries( snapshot.cost!.modelCosts!.map((part) => [ From 9ee0253300996132394349e9653b57ef29eeb24b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 6 Oct 2026 23:57:51 +0000 Subject: [PATCH 183/251] fix(deep): preserve admitted usage and interrupted scan results --- .../scripts/workbench_scan_usage.py | 10 + .../tests/test_workbench_scan_usage.py | 39 ++++ sdk/typescript/src/api.ts | 42 +++- sdk/typescript/src/cost.ts | 60 ++++- sdk/typescript/src/scan-sessions.ts | 30 +-- .../tests-ts/api-audit-admission.test.ts | 209 +++++++++++++++++ sdk/typescript/tests-ts/cli-scan-logs.test.ts | 28 ++- sdk/typescript/tests-ts/cost.test.ts | 217 +++++++++++++++++- sdk/typescript/tests-ts/scan-logs.test.ts | 36 +++ 9 files changed, 603 insertions(+), 68 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 779bbb7717..a81a7be170 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -861,6 +861,16 @@ def _read_rollout_usage( if counter_total["totalTokens"] > total["totalTokens"]: remainder = {key: max(0, value - total[key]) for key, value in counter_total.items()} + remainder["cachedInputTokens"] = min( + remainder["cachedInputTokens"], remainder["inputTokens"] + ) + remainder["cacheWriteInputTokens"] = min( + remainder["cacheWriteInputTokens"], + remainder["inputTokens"] - remainder["cachedInputTokens"], + ) + remainder["reasoningOutputTokens"] = min( + remainder["reasoningOutputTokens"], remainder["outputTokens"] + ) remainder["totalTokens"] = remainder["inputTokens"] + remainder["outputTokens"] _add_token_usage(total, remainder) _add_token_usage(local_models.setdefault(None, _empty_token_usage()), remainder) diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 3e11125c32..47f8c5deda 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -1315,3 +1315,42 @@ def test_completion_retains_receipt_and_counter_categories(tmp_path, receipt, co for category in ("inputTokens", "outputTokens", "totalTokens"): assert sum(row[category] for row in usage["modelUsage"]) == usage[category] assert usage["coverage"] == ("complete" if receipt == counter else "partial") + + +@pytest.mark.parametrize("counter_input,expected_cached", [(100, 20), (250, 90)]) +def test_completion_keeps_cached_subsets_valid_when_categories_diverge( + tmp_path, counter_input, expected_cached +): + fixture = _start_scan(tmp_path) + counted = fixture.started_at + timedelta(microseconds=1) + response = _event( + counted, + "token_usage_record", + { + "response_id": "cache-response", + "thread_id": "scan-parent", + "model": "gpt-5.6-sol", + "usage": { + "input_tokens": 150, + "cached_input_tokens": 20, + "output_tokens": 15, + "total_tokens": 165, + }, + }, + ) + parent = _rollout( + tmp_path, + "scan-parent", + [response, _token_event(counted, counter_input, 200, cached_input_tokens=90)], + ) + _state_graph(fixture.environment, {"scan-parent": parent}, []) + usage = _complete_scan(fixture)["scan"]["usage"] + assert (usage["inputTokens"], usage["cachedInputTokens"], usage["outputTokens"]) == ( + max(150, counter_input), + expected_cached, + 200, + ) + for row in usage["modelUsage"]: + assert row["cachedInputTokens"] + row["cacheWriteInputTokens"] <= row["inputTokens"] + for key in ("inputTokens", "cachedInputTokens", "outputTokens", "totalTokens"): + assert sum(row[key] for row in usage["modelUsage"]) == usage[key] diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index b6f72a95c9..a990a492ce 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2066,6 +2066,16 @@ export class CodexSecurity { let result = await runScanEvents({ savedCompletion: savedCompletion ?? undefined, recoverCompletion: recoverSelectedCompletion, + reconcileCheckpoint: + mode === "deep" + ? undefined + : async () => { + await workbench(workbenchOptions, [ + "prepare-scan-completion", + "--scan-id", + scanId, + ]); + }, thread, events, signal, @@ -3816,6 +3826,7 @@ interface ScanEventRunOptions extends Pick { recoverCompletion?: () => Promise > | null>; + reconcileCheckpoint?: () => Promise; thread: Pick & Partial>; events: AsyncGenerator; @@ -3960,16 +3971,27 @@ export async function runScanEvents( }; const accept = async () => { // Matching, custom validation and the canonical seal remain with the caller. - const [manifest, findings, coverage] = await Promise.all( - ["scan-manifest.json", "findings.json", "coverage.json"].map( - async (name) => - JSON.parse( - ( - await readScanFile(options.scanDir, name, name, options.signal) - ).toString("utf8"), - ), - ), - ); + const readDocuments = () => + Promise.all( + ["scan-manifest.json", "findings.json", "coverage.json"].map( + async (name) => + JSON.parse( + ( + await readScanFile( + options.scanDir, + name, + name, + options.signal, + ) + ).toString("utf8"), + ), + ), + ); + let [manifest, findings, coverage] = await readDocuments(); + if (manifest?.scan?.complete === false && options.reconcileCheckpoint) { + await options.reconcileCheckpoint(); + [manifest, findings, coverage] = await readDocuments(); + } const helper = ( await import( pathToFileURL(join(options.pluginRoot, "mcp/helpers.mjs")).href diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 53714723ac..3d1503dfbf 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -240,7 +240,7 @@ export class ScanCostTracker { if (fallbackUsage !== undefined) this.recordUsage(fallbackUsage); await this.refresh(); if ( - this.#readAttribution || + this.#attribution !== null || this.#receipts.size > 0 || this.#snapshot.usage !== null ) @@ -256,9 +256,8 @@ export class ScanCostTracker { let recordedAttribution = this.#attribution; if (this.#readAttribution) { const record = await this.#readAttribution(); - if (record === null) return; recordedAttribution = record ?? null; - const attribution = record === undefined || record.legacy ? null : record; + const attribution = record == null || record.legacy ? null : record; if ( attribution && (!this.#attribution || @@ -434,7 +433,10 @@ export class ScanCostTracker { if ( previous === undefined || (session.usage?.total_tokens ?? -1) > - (previous.usage?.total_tokens ?? -1) + (previous.usage?.total_tokens ?? -1) || + (session.usage?.total_tokens === previous.usage?.total_tokens && + session.pendingLineBytes === 0 && + previous.pendingLineBytes > 0) ) { usageSessions.set(threadId, session); } @@ -464,6 +466,19 @@ export class ScanCostTracker { if (!usages.has(threadId)) usages.set(threadId, null); } for (const [threadId, session] of usageSessions) { + const selected = usages.get(threadId); + if ( + selected && + session.usage && + selected.total_tokens > session.usage.total_tokens + ) + usages.set( + threadId, + addTokenUsage( + session.usage, + tokenUsageRemainder(selected, session.usage), + ), + ); if ( (session.counterRegressed && !session.responseUsageObserved) || session.expectedResponseTokens > session.responseTokens @@ -1220,3 +1235,40 @@ function subtractTokenUsage( function isMissingFile(error: unknown): boolean { return isRecord(error) && error["code"] === "ENOENT"; } + +function tokenUsageRemainder( + usage: ScanTokenUsage, + inherited: ScanTokenUsage, +): ScanTokenUsage { + const input = Math.max(0, usage.input_tokens - inherited.input_tokens); + const cached = Math.min( + input, + Math.max(0, usage.cached_input_tokens - inherited.cached_input_tokens), + ); + const writes = Math.min( + input - cached, + Math.max( + 0, + usage.cache_write_input_tokens - inherited.cache_write_input_tokens, + ), + ); + const output = Math.max(0, usage.output_tokens - inherited.output_tokens); + return { + input_tokens: input, + cached_input_tokens: cached, + cache_write_input_tokens: writes, + output_tokens: output, + reasoning_output_tokens: Math.min( + output, + Math.max( + 0, + usage.reasoning_output_tokens - inherited.reasoning_output_tokens, + ), + ), + total_tokens: input + output, + ...(usage.cache_write_input_tokens_reported === false || + inherited.cache_write_input_tokens_reported === false + ? { cache_write_input_tokens_reported: false } + : {}), + }; +} diff --git a/sdk/typescript/src/scan-sessions.ts b/sdk/typescript/src/scan-sessions.ts index 6ddbf120cd..e074210533 100644 --- a/sdk/typescript/src/scan-sessions.ts +++ b/sdk/typescript/src/scan-sessions.ts @@ -1,37 +1,11 @@ -import { readFile } from "node:fs/promises"; import { isAbsolute, join, relative, sep } from "node:path"; import { isRecord } from "./record.js"; export async function recordedScanCodexHome( - scanDirectory: string, + _scanDirectory: string, attribution?: ScanExecutionAttribution | null, ): Promise { - if (attribution && "workerCodexHome" in attribution) - return attribution.workerCodexHome ?? undefined; - try { - const saved: unknown = JSON.parse( - await readFile( - join( - scanDirectory, - "artifacts", - "deep_discovery", - "execution-settings.json", - ), - "utf8", - ), - ); - if ( - isRecord(saved) && - saved["version"] === 1 && - isRecord(saved["settings"]) - ) { - const home = saved["settings"]["codexHome"]; - if (typeof home === "string" && home !== "") return home; - } - } catch (error) { - if (!isRecord(error) || error["code"] !== "ENOENT") throw error; - } - return undefined; + return attribution?.workerCodexHome ?? undefined; } export interface ScanExecutionAttribution { diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index 0bfc8c2695..cb54ce55ac 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -4,6 +4,10 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, expect, test } from "bun:test"; import { build } from "esbuild"; import { runScanEvents } from "../src/api.js"; +import { runWorkbench, type WorkbenchCommandOptions } from "../src/runtime.js"; +import { TestClient } from "./support/api-client.js"; +import { preparedRuntime } from "./support/api-events.js"; +import { runNodePython } from "./support/python-probe.js"; import type { ScanDraftInput } from "../src/accepted-audit.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; import { @@ -36,6 +40,7 @@ await writeFile(bundlePath, bundle.outputFiles[0]!.contents); const { createDeepScanArtifacts, recordCodexSecurityScanDraft, + recordCodexSecurityScanDraftViaWorkbench, readDiscoveryAuditDraft, DeepScanWorkerRunner, } = await import(pathToFileURL(bundlePath).href); @@ -393,3 +398,207 @@ function mutateDraft( }; return draft; } + +for (const scenario of ["published", "interrupted", "unfinished"] as const) { + test(`Standard admission reconciles its committed terminal checkpoint: ${scenario}`, async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const home = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await writeFile(join(repository, "extract.py"), "# Synthetic source\n"); + await mkdir(home); + await mkdir(scanDir, { mode: 0o700 }); + const environment = { + ...process.env, + CODEX_HOME: home, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }; + const python = process.env["PYTHON"] ?? Bun.which("python3")!; + let registration: Record; + let savedOptions: WorkbenchCommandOptions; + const commands: string[] = []; + const runtime = preparedRuntime(home); + runtime.plugin.version = JSON.parse( + await readFile(join(PLUGIN_ROOT, ".codex-plugin", "plugin.json"), "utf8"), + ).version; + const client = new TestClient( + {}, + { + environment, + prepareRuntime: async () => ({ ...runtime, environment }), + resolvePluginPython: async () => python, + prepareOutputDir: async () => scanDir, + runWorkbench: async (options, args, input) => { + savedOptions = options; + commands.push(args[0]!); + const value = await runWorkbench(options, args, input); + if (args[0] === "register-cli-scan") registration = value; + return value; + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + const id = String(registration!["scanId"]); + const context = { + root: scanDir, + repoRoot: repository, + layout: "scan", + scanId: id, + mode: "standard", + status: "running", + scope: ".", + targetContract: registration!["contract"], + }; + const draft: ScanDraftInput = { + scanId: id, + complete: false, + scope: { summary: "Archive extraction." }, + threatModel: { + summary: "An untrusted caller supplies archive entries.", + }, + findings: [ + { + ruleId: "path-traversal.archive", + title: "Unsafe archive extraction", + summary: "An archive entry reaches a filesystem write.", + severity: { level: "high" }, + confidence: { level: "high", rationale: "Source review." }, + taxonomy: { category: "path-traversal", cwe: ["CWE-22"] }, + locations: [ + { path: "extract.py", startLine: 1, endLine: 1 }, + ], + remediation: + "Validate the resolved output path before writing.", + provenance: { + source: "local_plugin", + candidateId: "archive-entry", + }, + }, + ], + coverage: { + completeness: "partial", + surfaces: [ + { + id: "archive", + label: "Archive extraction", + disposition: "reported", + }, + ], + explicitExclusions: [], + deferred: [ + { + id: "deployment", + reason: "Deployment controls remain unverified.", + }, + ], + }, + }; + const publish = (args: readonly string[]) => + runWorkbench(savedOptions!, args); + await recordCodexSecurityScanDraftViaWorkbench( + context, + draft, + publish, + ); + if (scenario !== "unfinished") { + const terminal = { ...draft, complete: true }; + if (scenario === "interrupted") { + await expect( + recordCodexSecurityScanDraftViaWorkbench( + context, + terminal, + async (args: readonly string[]) => { + const result = runNodePython( + python, + [ + "-c", + [ + "import sys", + "sys.path.insert(0, sys.argv[1])", + "import workbench_db as db", + "original = db.saved_results.write_scan_local_bytes", + "def fail_manifest(root, relative, contents):", + " if relative == 'scan-manifest.json': raise OSError('Synthetic interrupted canonical manifest publication')", + " return original(root, relative, contents)", + "db.saved_results.write_scan_local_bytes = fail_manifest", + "sys.argv = ['workbench_db.py', *sys.argv[2:]]", + "db.main()", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + ...args, + ], + { + env: { + ...process.env, + ...savedOptions!.environment, + }, + }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Synthetic interrupted canonical manifest publication", + ); + throw new Error( + "Synthetic interrupted canonical manifest publication", + ); + }, + ), + ).rejects.toThrow( + "Synthetic interrupted canonical manifest publication", + ); + const head = JSON.parse( + await readFile( + join(scanDir, "checkpoint-head.json"), + "utf8", + ), + ); + const checkpoint = JSON.parse( + await readFile( + join(scanDir, "checkpoints", head.checkpoint), + "utf8", + ), + ); + expect(checkpoint.complete).not.toBe(false); + expect( + JSON.parse( + await readFile( + join(scanDir, "scan-manifest.json"), + "utf8", + ), + ).scan.complete, + ).toBe(false); + } else + await recordCodexSecurityScanDraftViaWorkbench( + context, + terminal, + publish, + ); + } + return { events: completedEvents("standard-checkpoint-thread") }; + }, + }), + }), + }, + ); + try { + const pending = client.run(repository); + if (scenario === "unfinished") { + await expect(pending).rejects.toThrow(); + expect(commands).not.toContain("complete-scan"); + } else { + const result = await pending; + expect(result.findings.findings).toHaveLength(1); + expect(result.findings.findings[0]!.title).toBe( + "Unsafe archive extraction", + ); + expect(commands).toContain("prepare-scan-completion"); + expect(commands).toContain("complete-scan"); + expect(commands).not.toContain("fail-scan"); + } + } finally { + await client.close(); + } + }); +} diff --git a/sdk/typescript/tests-ts/cli-scan-logs.test.ts b/sdk/typescript/tests-ts/cli-scan-logs.test.ts index 42fb6ea495..9348921683 100644 --- a/sdk/typescript/tests-ts/cli-scan-logs.test.ts +++ b/sdk/typescript/tests-ts/cli-scan-logs.test.ts @@ -110,21 +110,19 @@ async function fixture(attributedOwner = false) { mode: "deep", scanDir, executionThreadIds: ["worker"], - ...(attributedOwner - ? { - executionAttribution: { - formatVersion: 1 as const, - executionThreadIds: ["worker"], - owner: { - threadId: "thread-1", - turnId: "scan-turn", - startedAt: timestamp, - }, - startedAt: timestamp, - completedAt: timestamp, - }, - } - : {}), + executionAttribution: { + formatVersion: 1 as const, + ...(attributedOwner ? {} : { legacy: true as const }), + workerCodexHome: originalHome, + executionThreadIds: ["worker"], + owner: { + threadId: "thread-1", + turnId: attributedOwner ? "scan-turn" : null, + startedAt: timestamp, + }, + startedAt: timestamp, + completedAt: timestamp, + }, }; const logs = await readSavedScanLogs(scan, [home, originalHome]); const deps = dependencies({ diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 7e269db24b..77646c7195 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -2410,7 +2410,21 @@ describe("recorded Deep worker homes", () => { model: "gpt-5.6-sol", onSessionEvent: (event: ScanSessionEvent) => events.push(event), }; + const recordedOwner = async () => ({ + formatVersion: 1 as const, + legacy: true as const, + workerCodexHome: recordedHome, + executionThreadIds: [], + owner: { + threadId: "worker", + turnId: null, + startedAt: "2026-09-01T00:00:00Z", + }, + startedAt: "2026-09-01T00:00:00Z", + completedAt: null, + }); const tracker = new ScanCostTracker(options); + tracker.setAttributionReader(recordedOwner); tracker.start("worker"); try { expect((await tracker.refresh()).cost?.inputTokens).toBe(100); @@ -2436,6 +2450,7 @@ describe("recorded Deep worker homes", () => { // events, but must not renumber the surviving source occurrences. tracker.setAttributionReader(async () => ({ formatVersion: 1, + workerCodexHome: recordedHome, executionThreadIds: ["worker"], owner: { threadId: "worker", @@ -2452,6 +2467,7 @@ describe("recorded Deep worker homes", () => { ]); events.length = 0; const reconstructed = new ScanCostTracker(options); + reconstructed.setAttributionReader(recordedOwner); reconstructed.start("worker"); await reconstructed.stop(); expect(events.map((event) => event.event)).toEqual([ @@ -2666,6 +2682,10 @@ describe("recorded Deep worker homes", () => { ["truncated-first", true], ["truncated-last", false], ["truncated-last", true], + ["tail-first", false], + ["tail-first", true], + ["tail-last", false], + ["tail-last", true], ] as const)( "prices copied response records (%s, attribution: %s)", async (copy, attributed) => { @@ -2712,23 +2732,27 @@ describe("recorded Deep worker homes", () => { const truncated = prefix + '{"type":"token_usage_record"'; if (copy === "truncated-first") await writeFile(path, truncated); if (copy === "truncated-last") await writeFile(copiedPath, truncated); + if (copy === "tail-first") await appendFile(path, '{"type":"event_msg"'); + if (copy === "tail-last") + await appendFile(copiedPath, '{"type":"event_msg"'); const tracker = new ScanCostTracker({ codexHome: home, scanDirectory, model: "gpt-5.6-sol", }); - if (attributed) - tracker.setAttributionReader(async () => ({ - formatVersion: 1, - executionThreadIds: ["worker"], - owner: { - threadId: null, - turnId: null, - startedAt: "2026-09-01T00:00:00Z", - }, + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + ...(attributed ? {} : { legacy: true as const }), + workerCodexHome: recordedHome, + executionThreadIds: ["worker"], + owner: { + threadId: null, + turnId: null, startedAt: "2026-09-01T00:00:00Z", - completedAt: null, - })); + }, + startedAt: "2026-09-01T00:00:00Z", + completedAt: null, + })); tracker.start("worker"); try { const snapshot = await tracker.stop(); @@ -2755,3 +2779,174 @@ describe("recorded Deep worker homes", () => { }, ); }); + +test.each([ + [150_000, 15_000, 100_000, 200_000], + [100_000, 50_000, 200_000, 10_000], + [100_000, 10_000, 100_000, 10_000], +] as const)( + "preserves receipt pricing across divergent counters %s/%s vs %s/%s", + async (input, output, counterInput, counterOutput) => { + const home = await codexHome(); + const path = await writeSession(home, "worker", {}); + const counts = (input: number, output: number) => ({ + input_tokens: input, + output_tokens: output, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: input + output, + }); + await appendFile( + path, + JSON.stringify({ + type: "token_usage_record", + timestamp: "2026-09-01T00:00:02Z", + payload: { + thread_id: "worker", + turn_id: "turn", + response_id: "receipt", + model: "gpt-5.6-sol", + usage: counts(input, output), + thread_token_usage: counts(counterInput, counterOutput), + }, + }) + + "\n" + + JSON.stringify({ + type: "event_msg", + timestamp: "2026-09-01T00:00:02Z", + payload: { + type: "token_count", + info: { total_token_usage: counts(counterInput, counterOutput) }, + }, + }) + + "\n", + ); + const lowerBounds: Readonly[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + maxCostUsd: 0.8, + onCost: (cost) => lowerBounds.push(cost), + onCostLowerBound: (cost) => lowerBounds.push(cost), + }); + tracker.start("worker"); + try { + const snapshot = await tracker.stop(); + expect(snapshot.usage).toMatchObject({ + input_tokens: Math.max(input, counterInput), + output_tokens: Math.max(output, counterOutput), + }); + const measured = tokenUsage(snapshot.usage); + expect(measured).not.toBeNull(); + expect(lowerBounds.length).toBeGreaterThan(0); + expect(lowerBounds.at(-1)!.estimatedUsd).toBeGreaterThanOrEqual( + estimateScanCost("gpt-5.6-sol", counts(input, output))!.estimatedUsd, + ); + } finally { + await tracker.stop(); + } + }, +); + +test.each(["standard", "pending-deep", "legacy-deep", "bound-deep"] as const)( + "uses bound log homes rather than draft settings: %s", + async (kind) => { + const home = await codexHome(); + const foreign = await codexHome(); + const scanDirectory = join(home, "scan"); + const settings = join(scanDirectory, "artifacts", "deep_discovery"); + await mkdir(settings, { recursive: true }); + await writeFile( + join(settings, "execution-settings.json"), + JSON.stringify({ version: 1, settings: { codexHome: foreign } }), + ); + for (const [directory, input] of [ + [home, 100], + [foreign, 900_000], + ] as const) { + const path = await writeSession( + directory, + "scan-thread", + { + input_tokens: input, + output_tokens: 0, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + }, + { timestamp: "2026-09-01T00:00:02Z" }, + ); + const lines = (await readFile(path, "utf8")) + .trimEnd() + .split("\n") + .map((line) => ({ + ...JSON.parse(line), + timestamp: "2026-09-01T00:00:02Z", + })); + await writeFile(path, jsonLines(lines) + "\n"); + } + const tracker = new ScanCostTracker({ + codexHome: home, + scanDirectory, + model: "gpt-5.6-sol", + }); + if (kind !== "standard") + tracker.setAttributionReader(async () => + kind === "pending-deep" + ? null + : { + formatVersion: 1, + ...(kind === "legacy-deep" + ? { legacy: true as const } + : { workerCodexHome: foreign }), + executionThreadIds: ["scan-thread"], + owner: { + threadId: null, + turnId: null, + startedAt: "2026-09-01T00:00:00Z", + }, + startedAt: "2026-09-01T00:00:00Z", + completedAt: null, + }, + ); + tracker.start("scan-thread"); + try { + expect((await tracker.stop()).usage).toMatchObject({ + input_tokens: kind === "bound-deep" ? 900_000 : 100, + }); + } finally { + await tracker.stop(); + } + }, +); + +test.each(["no-reader", "pending-reader"] as const)( + "enforces dedicated parent cost before orchestration: %s", + async (kind) => { + const home = await codexHome(); + await writeSession(home, "new-parent", { + input_tokens: 100_000, + output_tokens: 0, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + }); + const costs: Readonly[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + maxCostUsd: 0.003, + onCost: (cost) => costs.push(cost), + onCostLowerBound: (cost) => costs.push(cost), + }); + if (kind === "pending-reader") + tracker.setAttributionReader(async () => null); + tracker.start("new-parent"); + try { + const snapshot = await tracker.stop(); + expect(snapshot.cost?.estimatedUsd).toBeGreaterThan(0.003); + expect(costs.length).toBeGreaterThan(0); + } finally { + await tracker.stop(); + } + }, +); diff --git a/sdk/typescript/tests-ts/scan-logs.test.ts b/sdk/typescript/tests-ts/scan-logs.test.ts index 0c52655cd5..2111a43767 100644 --- a/sdk/typescript/tests-ts/scan-logs.test.ts +++ b/sdk/typescript/tests-ts/scan-logs.test.ts @@ -58,6 +58,41 @@ function commandEvent(command: string, id: string, timestamp?: string) { } describe("saved scan logs", () => { + test.each(["standard", "deep"])( + "ignores model-owned log homes without host ownership for %s scans", + async (mode) => { + const configured = await temporaryHome(); + const foreign = await temporaryHome(); + const scanDir = await temporaryHome(); + const settings = join(scanDir, "artifacts", "deep_discovery"); + await mkdir(settings, { recursive: true }); + await writeFile( + join(settings, "execution-settings.json"), + JSON.stringify({ version: 1, settings: { codexHome: foreign } }), + ); + const configuredEvent = commandEvent( + "configured owner", + "configured-call", + ); + await writeSession(configured, "owner", [configuredEvent]); + await writeSession(foreign, "owner", [ + configuredEvent, + commandEvent("foreign suffix", "foreign-call"), + ]); + const result = await readSavedScanLogs( + { scanId: "scan-1", mode, scanDir, continuationThreadId: "owner" }, + configured, + ); + expect(result.events.map(({ event }) => event)).toEqual([ + { type: "session_meta", payload: { id: "owner" } }, + configuredEvent, + ]); + expect(result.sessions.map(({ path }) => path)).toEqual([ + join(configured, "sessions", "2026", "08", "11", "rollout-owner.jsonl"), + ]); + }, + ); + test.each( ["sessions", "archived_sessions"].flatMap((directory) => ["prefix only", "unrelated owner turn"].map((tail) => [directory, tail]), @@ -127,6 +162,7 @@ describe("saved scan logs", () => { continuationThreadId: "owner", executionAttribution: { formatVersion: 1, + workerCodexHome: original, executionThreadIds: [], owner: { threadId: "owner", From 0fec0f7eff6500ff2ed2297706f44674dc8c1a11 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 00:32:36 +0000 Subject: [PATCH 184/251] fix(deep): reconcile token categories and copied worker activities --- .../scripts/workbench_scan_usage.py | 26 +++--- .../tests/test_workbench_scan_usage.py | 10 ++- sdk/typescript/src/cost.ts | 53 +++++++----- sdk/typescript/tests-ts/cost.test.ts | 80 +++++++++++++++++++ 4 files changed, 136 insertions(+), 33 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index a81a7be170..d503bbced2 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -859,19 +859,19 @@ def _read_rollout_usage( local_models.setdefault(current_model, _empty_token_usage()), delta ) - if counter_total["totalTokens"] > total["totalTokens"]: - remainder = {key: max(0, value - total[key]) for key, value in counter_total.items()} - remainder["cachedInputTokens"] = min( - remainder["cachedInputTokens"], remainder["inputTokens"] - ) - remainder["cacheWriteInputTokens"] = min( - remainder["cacheWriteInputTokens"], - remainder["inputTokens"] - remainder["cachedInputTokens"], - ) - remainder["reasoningOutputTokens"] = min( - remainder["reasoningOutputTokens"], remainder["outputTokens"] - ) - remainder["totalTokens"] = remainder["inputTokens"] + remainder["outputTokens"] + remainder = {key: max(0, value - total[key]) for key, value in counter_total.items()} + remainder["cachedInputTokens"] = min(remainder["cachedInputTokens"], remainder["inputTokens"]) + remainder["cacheWriteInputTokens"] = min( + remainder["cacheWriteInputTokens"], + remainder["inputTokens"] - remainder["cachedInputTokens"], + ) + remainder["reasoningOutputTokens"] = min( + remainder["reasoningOutputTokens"], remainder["outputTokens"] + ) + remainder["totalTokens"] = remainder["inputTokens"] + remainder["outputTokens"] + if remainder["totalTokens"] > 0: + if response_usage_observed: + warnings.add("token_receipts_incomplete") _add_token_usage(total, remainder) _add_token_usage(local_models.setdefault(None, _empty_token_usage()), remainder) if response_usage_observed: diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 47f8c5deda..f865fe7e9b 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -1278,10 +1278,18 @@ def usage(value): "receipt,counter,expected", [ ((150, 15), (100, 200), (150, 200)), + ((150, 15), (100, 20), (150, 20)), + ((100, 50), (110, 10), (110, 50)), ((100, 50), (200, 10), (200, 50)), ((100, 10), (100, 10), (100, 10)), ], - ids=["counter-output", "counter-input", "equal-control"], + ids=[ + "counter-output", + "smaller-counter-output", + "smaller-counter-input", + "counter-input", + "equal-control", + ], ) def test_completion_retains_receipt_and_counter_categories(tmp_path, receipt, counter, expected): fixture = _start_scan(tmp_path) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 3d1503dfbf..688bd625ff 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -67,7 +67,7 @@ interface SessionUsage { expectedResponseTokens: number; counterRegressed: boolean; calls: Map; - activities: ScanActivity[]; + activities: { index: number; activity: ScanActivity }[]; progress: ScanProgress[]; filesCompleted: number; filesTotal: number | null; @@ -146,6 +146,7 @@ export class ScanCostTracker { readonly #workerProgress = new Map(); readonly #reportedProgress = new Set(); readonly #reportedSessionEvents = new Map>(); + readonly #reportedActivities = new Map>(); #threadId: string | null = null; #timer: NodeJS.Timeout | null = null; #pending: Promise = Promise.resolve(); @@ -419,7 +420,17 @@ export class ScanCostTracker { }); } if (worker !== undefined) { - for (const activity of session.activities.splice(0)) { + for (const { index, activity } of session.activities.splice(0)) { + let reported = this.#reportedActivities.get(threadId); + if (reported === undefined) { + reported = new Set(); + this.#reportedActivities.set(threadId, reported); + } + const identity = `${index}:${createHash("sha256") + .update(JSON.stringify(activity)) + .digest("hex")}`; + if (reported.has(identity)) continue; + reported.add(identity); this.#options.onActivity?.({ ...activity, id: `${threadId}:${activity.id}`, @@ -466,19 +477,20 @@ export class ScanCostTracker { if (!usages.has(threadId)) usages.set(threadId, null); } for (const [threadId, session] of usageSessions) { - const selected = usages.get(threadId); - if ( - selected && - session.usage && - selected.total_tokens > session.usage.total_tokens - ) - usages.set( - threadId, - addTokenUsage( - session.usage, - tokenUsageRemainder(selected, session.usage), - ), + let selected = usages.get(threadId); + const counter = counterSessions.get(threadId)?.counterUsage; + if (selected && counter) + selected = addTokenUsage( + selected, + tokenUsageRemainder(counter, selected), ); + if (selected && session.usage) { + const remainder = tokenUsageRemainder(selected, session.usage); + if (remainder.total_tokens > 0) { + usages.set(threadId, addTokenUsage(session.usage, remainder)); + if (session.responseUsageObserved) incomplete = true; + } + } if ( (session.counterRegressed && !session.responseUsageObserved) || session.expectedResponseTokens > session.responseTokens @@ -957,7 +969,7 @@ function readSessionEvent( if (activity.status === "running") { session.calls.set(activity.id, activity); } - session.activities.push(activity); + session.activities.push({ index: session.eventIndex - 1, activity }); return; } if ( @@ -968,8 +980,11 @@ function readSessionEvent( const call = session.calls.get(payload["call_id"]); if (call !== undefined) { session.activities.push({ - ...call, - status: payload["status"] === "failed" ? "failed" : "completed", + index: session.eventIndex - 1, + activity: { + ...call, + status: payload["status"] === "failed" ? "failed" : "completed", + }, }); session.calls.delete(call.id); } @@ -1002,7 +1017,7 @@ function readSessionEvent( !session.prose.has(`${activity.kind}:${activity.description}`) ) { session.prose.add(`${activity.kind}:${activity.description}`); - session.activities.push(activity); + session.activities.push({ index: session.eventIndex - 1, activity }); } return; } @@ -1143,7 +1158,7 @@ function recordReasoningActivity( } reasoning.activity = activity; session.prose.add(`${activity.kind}:${activity.description}`); - session.activities.push(activity); + session.activities.push({ index: session.eventIndex - 1, activity }); } function sessionProgressUpdates( diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 77646c7195..0f5e3d494e 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -2782,6 +2782,8 @@ describe("recorded Deep worker homes", () => { test.each([ [150_000, 15_000, 100_000, 200_000], + [150_000, 15_000, 100_000, 20_000], + [100_000, 50_000, 110_000, 10_000], [100_000, 50_000, 200_000, 10_000], [100_000, 10_000, 100_000, 10_000], ] as const)( @@ -2837,6 +2839,9 @@ test.each([ input_tokens: Math.max(input, counterInput), output_tokens: Math.max(output, counterOutput), }); + if (counterInput > input || counterOutput > output) + expect(snapshot.usage).toMatchObject({ coverage: "partial" }); + else expect(snapshot.usage).not.toMatchObject({ coverage: "partial" }); const measured = tokenUsage(snapshot.usage); expect(measured).not.toBeNull(); expect(lowerBounds.length).toBeGreaterThan(0); @@ -2950,3 +2955,78 @@ test.each(["no-reader", "pending-reader"] as const)( } }, ); + +test.each(["single", "identical", "prefix-first", "prefix-last"] as const)( + "forwards copied worker activities without replaying completed calls: %s", + async (copy) => { + const { home, worker } = await workerSessionFixture(); + const recordedHome = await codexHome(); + await mkdir(join(recordedHome, "sessions")); + const second = join(recordedHome, "sessions", "worker-copy.jsonl"); + const call = (id: string) => ({ + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + call_id: id, + arguments: JSON.stringify({ cmd: "rg -n source routes/login.ts" }), + }, + }); + const done = (id: string) => ({ + type: "response_item", + payload: { type: "function_call_output", call_id: id }, + }); + const prefix = + (await readFile(worker, "utf8")) + jsonLines([call("first")]) + "\n"; + const complete = prefix + jsonLines([done("first")]) + "\n"; + await writeFile(worker, copy === "prefix-first" ? prefix : complete); + if (copy !== "single") + await writeFile(second, copy === "prefix-last" ? prefix : complete); + const activities: ScanActivity[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + scanDirectory: join(home, "scan"), + model: "gpt-5.6-sol", + repository: "/code/juice-shop", + onActivity: (activity) => activities.push(activity), + }); + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + legacy: true, + workerCodexHome: recordedHome, + executionThreadIds: [], + owner: { + threadId: "scan-thread", + turnId: null, + startedAt: "2026-09-01T00:00:00Z", + }, + startedAt: "2026-09-01T00:00:00Z", + completedAt: null, + })); + tracker.start("scan-thread"); + try { + await tracker.refresh(); + expect(activities.map(({ id, status }) => [id, status])).toEqual([ + ["worker-thread:first", "running"], + ["worker-thread:first", "completed"], + ]); + await writeFile(worker, complete); + if (copy !== "single") await writeFile(second, complete); + await tracker.refresh(); + expect(activities).toHaveLength(2); + const later = jsonLines([call("second"), done("second")]) + "\n"; + await appendFile(worker, later); + await tracker.refresh(); + if (copy !== "single") await appendFile(second, later); + await tracker.stop(); + expect(activities.map(({ id, status }) => [id, status])).toEqual([ + ["worker-thread:first", "running"], + ["worker-thread:first", "completed"], + ["worker-thread:second", "running"], + ["worker-thread:second", "completed"], + ]); + } finally { + await tracker.stop(); + } + }, +); From 1869333d660aec50cf185949f6353c76ecb76e70 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 01:00:55 +0000 Subject: [PATCH 185/251] fix(sdk): preserve canonical and custom plugin admission --- .../mcp-app/src/artifact-scan-draft.ts | 8 ++ sdk/typescript/src/api.ts | 2 +- .../tests-ts/api-audit-admission.test.ts | 114 +++++++++++++----- 3 files changed, 95 insertions(+), 29 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index e4401003c0..64c60b9535 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -8,6 +8,7 @@ import type * as z from "zod/v4"; import commonSchema from "../../schemas/definitions/artifact-common.schema.json"; import scanDraftDocument from "../../schemas/tools/scan-draft.schema.json"; import scanManifestDocument from "../../schemas/scan-manifest.schema.json"; +import coverageDocument from "../../schemas/coverage.schema.json"; import type { ArtifactContext } from "./artifact-context.js"; import type { RunArtifactWorkbench } from "./artifact-context.js"; import { @@ -85,6 +86,13 @@ const canonicalScanDraftInputSchema = loadArtifactZodSchema( $defs: { ...scanDraftDocument.$defs, scanId: scanManifestDocument.properties.scan.properties.id, + surface: { + ...scanDraftDocument.$defs.surface, + properties: { + ...scanDraftDocument.$defs.surface.properties, + id: coverageDocument.properties.surfaces.items.properties.id, + }, + }, }, }, ] as SchemaDocument[], diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index a990a492ce..91162b47fa 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -3994,7 +3994,7 @@ export async function runScanEvents( } const helper = ( await import( - pathToFileURL(join(options.pluginRoot, "mcp/helpers.mjs")).href + pathToFileURL(join(await bundledPluginRoot(), "mcp/helpers.mjs")).href ) ).default; const draft: ScanDraftInput = helper.parseCanonicalScanDraft({ diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index cb54ce55ac..f547442ad5 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -39,6 +39,7 @@ const bundlePath = join(await temporaryDirectory(), "deep-admission.mjs"); await writeFile(bundlePath, bundle.outputFiles[0]!.contents); const { createDeepScanArtifacts, + parseCanonicalScanDraft, recordCodexSecurityScanDraft, recordCodexSecurityScanDraftViaWorkbench, readDiscoveryAuditDraft, @@ -203,7 +204,6 @@ for (const scenario of cases) { writeFile(join(standardRoot, "coverage.json"), JSON.stringify(coverage)), ]); const standard = await observeStandardAdmission( - root, repository, standardRoot, scanId, @@ -286,7 +286,6 @@ for (const scenario of cases) { expect(deepResult.status).toBe("failed"); expect(acceptedPaths).toHaveLength(0); } - expect(standard.calls).toBe(1); const manifest = JSON.parse( await readFile(join(standardRoot, "scan-manifest.json"), "utf8"), ); @@ -303,41 +302,87 @@ test("Standard admission preserves existing canonical scan IDs", async () => { const manifest = JSON.parse( await readFile(join(scanDir, "scan-manifest.json"), "utf8"), ); - const standard = await observeStandardAdmission(root, repository, scanDir); + const standard = await observeStandardAdmission(repository, scanDir); expect(manifest.scan.id).toBe("scan_example_001"); expect(standard.error).toBe(standard.finalization); expect(standard.finalizations).toBe(1); expect(standard.drafts).toHaveLength(1); - expect(standard.calls).toBe(1); expect(standard.drafts[0]!.scanId).toBe(manifest.scan.id); }); +test.each(["http", "HTTP"])( + "Standard admission preserves file-authored canonical surface ID %s", + async (surfaceId) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + const scanDir = await copyCompletedScan(root); + const coveragePath = join(scanDir, "coverage.json"); + const coverage = JSON.parse(await readFile(coveragePath, "utf8")); + coverage.surfaces[0].id = surfaceId; + await writeFile(coveragePath, JSON.stringify(coverage)); + const standard = await observeStandardAdmission(repository, scanDir); + expect(standard.error).toBe(standard.finalization); + expect(standard.finalizations).toBe(1); + expect( + (standard.drafts[0]!.coverage["surfaces"] as { id: string }[])[0]!.id, + ).toBe(surfaceId); + expect(JSON.parse(await readFile(coveragePath, "utf8"))).toEqual(coverage); + }, +); + +test.each(["current", "legacy"])( + "Standard admission retains selected %s plugin helper compatibility", + async (kind) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + const scanDir = await copyCompletedScan(root); + const pluginRoot = join(root, "selected-plugin"); + await mkdir(join(pluginRoot, "mcp"), { recursive: true }); + await writeFile( + join(pluginRoot, "mcp", "helpers.mjs"), + kind === "legacy" + ? "process.exitCode = 2;\n" + : `export { default } from ${JSON.stringify(pathToFileURL(join(PLUGIN_ROOT, "mcp", "helpers.mjs")).href)};\n`, + ); + const finalization = new Error("Finalization receives the admitted scan."); + let finalizations = 0; + const previousExitCode = process.exitCode; + try { + const error = await runScanEvents({ + thread: { id: "standard-thread" }, + events: completedEvents("standard-thread"), + signal: new AbortController().signal, + scanDir, + pluginRoot, + expectation: { + repository, + repositoryRevision: null, + target: { kind: "repository", paths: [] }, + mode: "standard", + pluginVersion: "0.1.95", + }, + onFinalize: async () => { + finalizations++; + throw finalization; + }, + }).catch((error: unknown) => error); + expect(error).toBe(finalization); + expect(finalizations).toBe(1); + expect(process.exitCode).toBe(previousExitCode); + } finally { + process.exitCode = previousExitCode; + } + }, +); + async function observeStandardAdmission( - root: string, repository: string, scanDir: string, scanId?: string, ) { - const pluginRoot = join(root, "observed-plugin"); - const helperPath = join(pluginRoot, "mcp", "helpers.mjs"); - await mkdir(join(pluginRoot, "mcp"), { recursive: true }); - // The bundled exports are immutable getters. Observe the real parser through - // a local delegator instead of mocking its module or copying its semantics. - await writeFile( - helperPath, - `import helpers from ${JSON.stringify(pathToFileURL(join(PLUGIN_ROOT, "mcp", "helpers.mjs")).href)}; -export const drafts = []; -export let calls = 0; -export default { ...helpers, parseCanonicalScanDraft(input) { - calls++; - const draft = helpers.parseCanonicalScanDraft(input); - drafts.push(draft); - return draft; -} };`, - ); - const observed: { drafts: ScanDraftInput[]; calls: number } = await import( - pathToFileURL(helperPath).href - ); + const drafts: ScanDraftInput[] = []; const finalization = new Error("The enclosing finalizer owns the next step."); let finalizations = 0; const error = await runScanEvents({ @@ -351,7 +396,7 @@ export default { ...helpers, parseCanonicalScanDraft(input) { events: completedEvents("standard-thread"), signal: new AbortController().signal, scanDir, - pluginRoot, + pluginRoot: PLUGIN_ROOT, expectation: { repository, repositoryRevision: null, @@ -361,6 +406,20 @@ export default { ...helpers, parseCanonicalScanDraft(input) { }, onFinalize: async () => { finalizations++; + const [manifest, findings, coverage] = await Promise.all( + ["scan-manifest.json", "findings.json", "coverage.json"].map( + async (name) => + JSON.parse(await readFile(join(scanDir, name), "utf8")), + ), + ); + drafts.push( + parseCanonicalScanDraft({ + scanId: scanId ?? manifest.scan.id, + manifest, + findings, + coverage, + }), + ); throw finalization; }, }).catch((error: unknown) => error); @@ -368,8 +427,7 @@ export default { ...helpers, parseCanonicalScanDraft(input) { error, finalization, finalizations, - drafts: observed.drafts, - calls: observed.calls, + drafts, }; } From 54be38d7891603bfeeec266a504219f4b280366d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 01:31:35 +0000 Subject: [PATCH 186/251] test(sdk): consume paged reducer inputs in package fixture --- .../scripts/fixtures/package-deep-codex.mjs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs index 4d645dfb9f..fc59a91e50 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs @@ -139,10 +139,17 @@ async function run() { } } else { assert.equal(layout, "reducer"); - const inputs = await server.call( - "get_codex_security_deep_reducer_inputs", - {}, - ); + let document = ""; + let cursor; + do { + const page = await server.call( + "get_codex_security_deep_reducer_inputs", + { maxBytes: 4096, ...(cursor === undefined ? {} : { cursor }) }, + ); + document += page.json; + cursor = page.nextCursor; + } while (cursor !== undefined); + const inputs = JSON.parse(document); assert.ok(inputs.discoveries.length > 0); await server.call("record_codex_security_deep_reduction", { scanId: env.CODEX_SECURITY_SCAN_ID, From a6f16ba555beca3b3b628ae2aaaf4f6f66321ed7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 02:40:42 +0000 Subject: [PATCH 187/251] Preserve priced owned usage and complete rollout copies --- .../scripts/workbench_scan_usage.py | 14 ++- .../tests/test_workbench_scan_usage.py | 52 ++++++++- sdk/typescript/src/cost-model.ts | 15 ++- sdk/typescript/src/cost.ts | 14 +-- sdk/typescript/tests-ts/cost.test.ts | 110 ++++++++++++++++++ 5 files changed, 189 insertions(+), 16 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index d503bbced2..9e2aae66ba 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -313,7 +313,11 @@ def _read_rollout_copies_usage( # Restored indexes can reference a prefix and its complete continuation. # Keep totals and model attribution from the same copy, counting it once. usage, warnings, selected_models = max( - attributable or readings, key=lambda reading: reading[0]["totalTokens"] + attributable or readings, + key=lambda reading: ( + reading[0]["totalTokens"], + "rollout_record_incomplete" not in reading[1], + ), ) for model, tokens in selected_models.items(): _add_token_usage(model_usage.setdefault(model, _empty_token_usage()), tokens) @@ -801,10 +805,6 @@ def _read_rollout_usage( expected_response_tokens = max( expected_response_tokens, cumulative["totalTokens"] ) - if not response_usage_observed: - response_usage_observed = True - total = _empty_token_usage() - local_models = {} response_tokens += usage["totalTokens"] timestamp = _timestamp(event.get("timestamp")) if timestamp is None: @@ -819,6 +819,10 @@ def _read_rollout_usage( and payload.get("turn_id", current_turn_id) != owner_turn_id ): continue + if not response_usage_observed: + response_usage_observed = True + total = _empty_token_usage() + local_models = {} usage_observed = True model = payload.get("model", current_model) if not isinstance(model, str): diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index f865fe7e9b..a17da0b249 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -653,6 +653,8 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N "inherited-sqlite", "current-prefix", "recorded-prefix", + "current-equal-incomplete", + "recorded-equal-incomplete", "current-unreadable", "current-mismatched", "external-sqlite", @@ -669,6 +671,8 @@ def test_completion_keeps_owner_and_workers_in_their_recorded_homes( copied_rollout = worker_home in { "current-prefix", "recorded-prefix", + "current-equal-incomplete", + "recorded-equal-incomplete", "current-unreadable", "current-mismatched", } @@ -822,6 +826,8 @@ def complete(index: int) -> dict[str, Any]: "recorded", "current-prefix", "recorded-prefix", + "current-equal-incomplete", + "recorded-equal-incomplete", "current-unreadable", "current-mismatched", }: @@ -831,12 +837,14 @@ def complete(index: int) -> dict[str, Any]: full = worker_threads[thread_id] copied = full.with_name(f"copied-{thread_id}.jsonl") copied.write_bytes(b"\n".join(full.read_bytes().splitlines()[:3]) + b"\n") + if worker_home in {"current-equal-incomplete", "recorded-equal-incomplete"}: + copied.write_bytes(full.read_bytes() + b'{"type":"event_msg"') if worker_home == "current-unreadable": copied.write_text("invalid session metadata\n") elif worker_home == "current-mismatched": copied.write_text(full.read_text().replace(thread_id, "unrelated-thread")) current_copy = copied - if worker_home == "recorded-prefix": + if worker_home in {"recorded-prefix", "recorded-equal-incomplete"}: recorded_threads[thread_id] = copied current_copy = full with sqlite3.connect(environment["CODEX_STATE_DB"]) as connection: @@ -1362,3 +1370,45 @@ def test_completion_keeps_cached_subsets_valid_when_categories_diverge( assert row["cachedInputTokens"] + row["cacheWriteInputTokens"] <= row["inputTokens"] for key in ("inputTokens", "cachedInputTokens", "outputTokens", "totalTokens"): assert sum(row[key] for row in usage["modelUsage"]) == usage[key] + + +@pytest.mark.parametrize("receipt_owner", ["other-turn", "outside-window"]) +def test_owned_legacy_model_survives_unrelated_first_receipt( + tmp_path: Path, workbench_api, receipt_owner: str +) -> None: + reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + end = start + timedelta(seconds=5) + events = [ + _event(start, "turn_context", {"turn_id": "scan-turn", "model": "gpt-5.6-sol"}), + _token_event(start, 1000, 10), + _event( + end + timedelta(seconds=1) if receipt_owner == "outside-window" else start, + "token_usage_record", + { + "thread_id": "parent", + "turn_id": "other-turn" if receipt_owner == "other-turn" else "scan-turn", + "response_id": "unrelated-response", + "model": "gpt-6-astra", + "usage": { + "input_tokens": 50, + "cached_input_tokens": 0, + "cache_write_input_tokens": 0, + "output_tokens": 0, + "reasoning_output_tokens": 0, + "total_tokens": 50, + }, + }, + ), + ] + models = {} + counts, warnings = reader._read_rollout_usage( + reader.RolloutSession("parent", None, _rollout(tmp_path, "parent", events)), + started_at=start, + completed_at=end, + owner_turn_id="scan-turn", + model_usage=models, + ) + assert counts == _counts(1000, 0, 10) + assert warnings == set() + assert models == {"gpt-5.6-sol": _counts(1000, 0, 10)} diff --git a/sdk/typescript/src/cost-model.ts b/sdk/typescript/src/cost-model.ts index 5360211c29..3852467553 100644 --- a/sdk/typescript/src/cost-model.ts +++ b/sdk/typescript/src/cost-model.ts @@ -187,11 +187,20 @@ export function estimateScanCost( if (total === null || usage["modelUsage"].length === 0) return null; const costs: ScanCost[] = []; for (const part of usage["modelUsage"]) { - if (!isRecord(part) || typeof part["model"] !== "string") return null; - const cost = estimateModelCost(part["model"], part); - if (cost === null) return null; + if (!isRecord(part)) return null; + const tokens = tokenUsage(part); + if (tokens === null) return null; + const cost = + typeof part["model"] === "string" + ? estimateModelCost(part["model"], part) + : null; + if (cost === null) { + if (tokens.total_tokens === 0) continue; + return null; + } costs.push(cost); } + if (costs.length === 0) return null; const sum = ( key: | "inputTokens" diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 688bd625ff..7e6e28bdda 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -853,13 +853,6 @@ function readSessionEvent( session.expectedResponseTokens, cumulative.total_tokens, ); - if (!session.responseUsageObserved) { - // Exact receipts include compaction and survive counter resets. Keep the - // legacy counter as an independent lower bound, never add it to receipts. - session.responseUsageObserved = true; - session.usage = null; - session.modelUsage.clear(); - } session.responseTokens += usage.total_tokens; const turnId = typeof payload["turn_id"] === "string" @@ -875,6 +868,13 @@ function readSessionEvent( ) ) return; + if (!session.responseUsageObserved) { + // Exact receipts include compaction and survive counter resets. Keep the + // legacy counter as an independent lower bound, never add it to receipts. + session.responseUsageObserved = true; + session.usage = null; + session.modelUsage.clear(); + } const model = typeof payload["model"] === "string" ? payload["model"] : session.model; session.usage = addTokenUsage(session.usage, usage); diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 0f5e3d494e..13dbaaa4e2 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -3030,3 +3030,113 @@ test.each(["single", "identical", "prefix-first", "prefix-last"] as const)( } }, ); + +test.each([0, 100])( + "ignores only zero unpriced counter buckets: %i", + async (unpricedTokens) => { + const home = await codexHome(); + const path = await writeSession(home, "owner", { + input_tokens: unpricedTokens, + output_tokens: 0, + }); + await appendFile( + path, + jsonLines([ + { + type: "turn_context", + timestamp: "2026-09-01T00:00:02Z", + payload: { turn_id: "turn", model: "gpt-5.6-sol" }, + }, + { + type: "event_msg", + timestamp: "2026-09-01T00:00:02Z", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: unpricedTokens + 1000, + output_tokens: 0, + }, + }, + }, + }, + ]) + "\n", + ); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + tracker.start("owner"); + try { + const snapshot = await tracker.refresh(); + expect(tokenUsage(snapshot.usage)?.input_tokens).toBe( + unpricedTokens + 1000, + ); + if (unpricedTokens === 0) + expect(snapshot.cost?.estimatedUsd).toBeCloseTo(0.004, 10); + else expect(snapshot.cost).toBeNull(); + } finally { + await tracker.stop(); + } + }, +); + +test.each(["other turn", "outside window"])( + "keeps owned legacy model before an unrelated first receipt: %s", + async (kind) => { + const home = await codexHome(); + const path = await writeSession(home, "owner", {}); + const at = "2026-09-01T00:00:02Z"; + await appendFile( + path, + jsonLines([ + { + type: "turn_context", + timestamp: at, + payload: { turn_id: "turn", model: "gpt-5.6-sol" }, + }, + { + type: "event_msg", + timestamp: at, + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 1000, output_tokens: 0 }, + }, + }, + }, + { + type: "token_usage_record", + timestamp: kind === "outside window" ? "2026-09-01T00:00:04Z" : at, + payload: { + thread_id: "owner", + turn_id: kind === "other turn" ? "other" : "turn", + response_id: "unrelated-response", + model: "gpt-6-astra", + usage: { input_tokens: 50, output_tokens: 0 }, + }, + }, + ]) + "\n", + ); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: [], + owner: { threadId: "owner", turnId: "turn", startedAt: at }, + startedAt: at, + completedAt: "2026-09-01T00:00:03Z", + })); + tracker.start("owner"); + try { + const snapshot = await tracker.refresh(); + expect(tokenUsage(snapshot.usage)?.input_tokens).toBe(1000); + expect(snapshot.cost?.estimatedUsd).toBeCloseTo(0.004, 10); + expect(snapshot.usage).not.toMatchObject({ coverage: "partial" }); + } finally { + await tracker.stop(); + } + }, +); From a67c8a2b8e2ebf4f83d9d78b451a5ba37ef22a16 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 03:13:32 +0000 Subject: [PATCH 188/251] Keep known usage assertions before attribution arrives --- sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts b/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts index 6265ebf0b5..13bc7e95c9 100644 --- a/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts +++ b/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts @@ -106,7 +106,7 @@ describe("scan usage reconciliation", () => { } }); - test("waits for attribution and retains uncertainty until missing attempt usage arrives", async () => { + test("retains known usage before attribution and uncertainty until missing attempt usage arrives", async () => { const home = await mkdtemp(join(tmpdir(), "usage-delayed-")); const tracker = new ScanCostTracker({ codexHome: home, @@ -144,7 +144,7 @@ describe("scan usage reconciliation", () => { ); tracker.start("worker"); tracker.recordUsage({ input_tokens: 20, output_tokens: 0 }); - expect((await tracker.refresh()).cost).toBeNull(); + expect((await tracker.refresh()).cost).toMatchObject({ inputTokens: 20 }); attribution = { formatVersion: 1, executionThreadIds: ["worker", "failed-attempt"], From 5698bc0e9243f483fe3fcfe893025cd855631e33 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 03:44:53 +0000 Subject: [PATCH 189/251] fix(deep-scan): order recovered rollout parents before descendants --- .../scripts/workbench_scan_usage.py | 19 ++++++- .../tests/test_workbench_scan_usage.py | 53 +++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 9e2aae66ba..7886af9ecb 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -204,7 +204,24 @@ def collect_scan_usage( accepted_thread_ids: set[str] = set() excluded_thread_ids: set[str] = set() model_usage: dict[str | None, dict[str, int]] = {} - for copies in sessions.values(): + children: dict[str | None, list[str]] = {} + for thread_id, copies in sessions.items(): + children.setdefault(copies[0].parent_thread_id, []).append(thread_id) + pending = deque( + thread_id + for thread_id, copies in sessions.items() + if copies[0].parent_thread_id not in sessions + ) + ordered_thread_ids: list[str] = [] + while pending: + thread_id = pending.popleft() + ordered_thread_ids.append(thread_id) + pending.extend(children.get(thread_id, [])) + # Keep unresolved lineage in the existing accounting checks as well. + ordered = set(ordered_thread_ids) + ordered_thread_ids.extend(thread_id for thread_id in sessions if thread_id not in ordered) + for thread_id in ordered_thread_ids: + copies = sessions[thread_id] session = copies[0] owner_turn_id = None if ( diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index a17da0b249..5cfc36356f 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -661,6 +661,8 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N "external-shared-home", "external-missing-copy", "external-missing-child", + "external-current-middle-missing", + "external-current-middle-complete", "unavailable", ], ) @@ -861,7 +863,17 @@ def complete(index: int) -> dict[str, Any]: "external-shared-home", "external-missing-copy", "external-missing-child", + "external-current-middle-missing", + "external-current-middle-complete", }: + if worker_home.startswith("external-current-middle"): + grandchild_id = f"grandchild-{index}" + worker_threads[grandchild_id] = _rollout( + root, + grandchild_id, + [_token_event(counted, index * 11, 2)], + parent_thread_id=child_id, + ) # Native keeps rollouts in its Codex home even when its SQLite # index lives elsewhere and recovery chooses a different index. sessions = selected_home / "sessions" / "2026" / "01" / "01" @@ -892,6 +904,27 @@ def complete(index: int) -> dict[str, Any]: "INSERT INTO thread_spawn_edges VALUES (?, ?)", (first_id, child_id) ) worker_threads[child_id].unlink() + if worker_home.startswith("external-current-middle"): + # Recovery's current index sees the grandchild while its parent + # rollout is restored later from the worker's original home. + with sqlite3.connect(environment["CODEX_STATE_DB"]) as connection: + connection.executemany( + "INSERT INTO threads VALUES (?, ?)", + [ + (f"discovery-{index}", str(worker_threads[f"discovery-{index}"])), + ( + child_id, + str(root / "missing-middle.jsonl") + if worker_home.endswith("missing") + else str(worker_threads[child_id]), + ), + (grandchild_id, str(worker_threads[grandchild_id])), + ], + ) + connection.executemany( + "INSERT INTO thread_spawn_edges VALUES (?, ?)", + [(f"discovery-{index}", child_id), (child_id, grandchild_id)], + ) result = _complete_scan(fixture)["scan"]["usage"] assert snapshot.read_bytes() == original_bytes return result @@ -924,8 +957,12 @@ def complete(index: int) -> dict[str, Any]: assert usage == { "coverage": "complete", "source": "codex_rollout", - **_counts(index * 77, 0, 13), - "threadCount": 4, + **_counts( + index * (88 if worker_home.startswith("external-current-middle") else 77), + 0, + 15 if worker_home.startswith("external-current-middle") else 13, + ), + "threadCount": 5 if worker_home.startswith("external-current-middle") else 4, "modelUsage": ( [ {"model": None, **_counts(index * 47, 0, 8)}, @@ -933,7 +970,17 @@ def complete(index: int) -> dict[str, Any]: {"model": "model-beta", **_counts(index * 10, 0, 2)}, ] if copied_rollout - else [{"model": None, **_counts(index * 77, 0, 13)}] + else [ + { + "model": None, + **_counts( + index + * (88 if worker_home.startswith("external-current-middle") else 77), + 0, + 15 if worker_home.startswith("external-current-middle") else 13, + ), + } + ] ), } From dca41d97b1722c547445188f537e1b711e5d778f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 04:15:41 +0000 Subject: [PATCH 190/251] fix(deep-scan): preserve usage and replay checks after integration --- plugins/codex-security/scripts/workbench_validation.py | 7 +++++-- .../tests/test_deep_scan_publication_replay.py | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_validation.py b/plugins/codex-security/scripts/workbench_validation.py index f9d6b9af97..3d6ecf0096 100644 --- a/plugins/codex-security/scripts/workbench_validation.py +++ b/plugins/codex-security/scripts/workbench_validation.py @@ -137,9 +137,12 @@ def _valid_measured_scan_usage(usage: object) -> bool: return False if part["model"] is not None and not isinstance(part["model"], str): return False - if not _valid_scan_token_counts({key: part[key] for key in SCAN_USAGE_TOKEN_KEYS}): + if ( + not all(type(part[key]) is int and part[key] >= 0 for key in SCAN_USAGE_TOKEN_KEYS) + or part["cachedInputTokens"] + part["cacheWriteInputTokens"] > part["inputTokens"] + ): return False - if any(sum(part[key] for part in parts) != counts[key] for key in SCAN_USAGE_TOKEN_KEYS): + if any(sum(part[key] for part in parts) != usage[key] for key in SCAN_USAGE_TOKEN_KEYS): return False missing = usage.get("missingThreadCount", 0) if type(missing) is not int or missing < 0: diff --git a/plugins/codex-security/tests/test_deep_scan_publication_replay.py b/plugins/codex-security/tests/test_deep_scan_publication_replay.py index cfa040b38d..c232f3e426 100644 --- a/plugins/codex-security/tests/test_deep_scan_publication_replay.py +++ b/plugins/codex-security/tests/test_deep_scan_publication_replay.py @@ -23,15 +23,16 @@ boundary = sys.argv[4] class CrashConnection(sqlite3.Connection): - def commit(self): - completing = self.execute( + def __exit__(self, exc_type, exc_value, traceback): + completing = exc_type is None and self.execute( "SELECT status FROM scans WHERE id = ?", (args.scan_id,) ).fetchone()[0] == "complete" if completing and boundary == "sqlite-before": os._exit(72) - super().commit() + result = super().__exit__(exc_type, exc_value, traceback) if completing and boundary == "sqlite-after": os._exit(73) + return result connection = sqlite3.connect(sys.argv[2], factory=CrashConnection) connection.row_factory = sqlite3.Row From 0fc92b1f0869750bbb0bdf6ce82fb427222c1d2e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 06:58:35 +0000 Subject: [PATCH 191/251] fix(deep-scan): retain archived usage and legacy owner accounting --- .github/workflows/node-ci.yml | 3 +- .../scripts/workbench_scan_usage.py | 13 +- .../test_budget_selection_publication.py | 24 +--- .../test_deep_scan_publication_replay.py | 3 +- .../tests/test_deep_scan_usage_owner.py | 111 +++++++++++++++++- .../tests/test_workbench_scan_usage.py | 10 +- sdk/typescript/src/cost.ts | 48 ++++---- sdk/typescript/tests-ts/cost.test.ts | 69 +++++++++++ 8 files changed, 224 insertions(+), 57 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 0fbe038d0e..7f964a5d47 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -341,13 +341,14 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - name: Checkout plugin source without the SDK + - name: Checkout plugin and shared SDK source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false sparse-checkout: | plugins/codex-security .github/actions/setup-tools + sdk/typescript - name: Set up TypeScript tools uses: ./.github/actions/setup-tools with: diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index cdfbc3da90..ec2411946e 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -443,12 +443,7 @@ def scan_execution_attribution( owner_json = run["usage_owner_json"] if "usage_owner_json" in run.keys() else None legacy = False if owner_json is None: - legacy = ( - connection.execute( - "SELECT 1 FROM deep_scan_attempts WHERE scan_id = ? LIMIT 1", (scan["id"],) - ).fetchone() - is None - ) + legacy = True roots = _scan_root_thread_ids(connection, scan, None) owner = { "threadId": roots[0] if roots else None, @@ -636,7 +631,11 @@ def _discover_rollout_sessions( def _discover_recorded_worker_sessions(codex_home: Path, roots: set[str]) -> list[RolloutSession]: recorded: dict[str, list[RolloutSession]] = {} children: dict[str, set[str]] = {} - for candidate in sorted((codex_home / "sessions").rglob("*.jsonl")): + for candidate in sorted( + candidate + for directory in ("sessions", "archived_sessions") + for candidate in (codex_home / directory).rglob("*.jsonl") + ): path = _rollout_path(str(candidate)) if path is None: continue diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index 10150bc284..cddbcbea44 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -631,20 +631,10 @@ def write_then_die(scan_dir, name, contents): } saved_scan = workbench_api["require_scan"](db, scan.scan_id) resumed = workbench_api["scan_history"].cli_scan_resume( + workbench_api["_WORKBENCH_DB_CONTEXT"], db, saved_scan, workbench_api["require_workspace"](db, saved_scan["workspace_id"]), - **{ - name: workbench_api[source] - for name, source in { - "parse_scan_recipe": "parse_scan_recipe", - "scan_contract": "scan_contract", - "require_scan_directory": "require_canonical_scan_directory", - "artifact_path": "artifact_path", - "read_json_object": "read_json_object", - "workbench_completion_binding": "workbench_completion_binding", - }.items() - }, ) assert resumed["scanId"] == scan.scan_id assert resumed["threadId"] == thread_id @@ -806,20 +796,10 @@ def test_budget_resume_keeps_explicit_stop_and_unselected_guards( saved_scan = workbench_api["require_scan"](workbench_db, scan.scan_id) with pytest.raises(SystemExit, match="cannot resume"): workbench_api["scan_history"].cli_scan_resume( + workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, saved_scan, workbench_api["require_workspace"](workbench_db, saved_scan["workspace_id"]), - **{ - name: workbench_api[source] - for name, source in { - "parse_scan_recipe": "parse_scan_recipe", - "scan_contract": "scan_contract", - "require_scan_directory": "require_canonical_scan_directory", - "artifact_path": "artifact_path", - "read_json_object": "read_json_object", - "workbench_completion_binding": "workbench_completion_binding", - }.items() - }, ) diff --git a/plugins/codex-security/tests/test_deep_scan_publication_replay.py b/plugins/codex-security/tests/test_deep_scan_publication_replay.py index c232f3e426..039ca00de3 100644 --- a/plugins/codex-security/tests/test_deep_scan_publication_replay.py +++ b/plugins/codex-security/tests/test_deep_scan_publication_replay.py @@ -152,7 +152,8 @@ def test_publication_crash_replays_selected_input_without_stale_overwrite( digest = after_selection.pop("publicationSha256") assert len(digest) == 64 and all(char in "0123456789abcdef" for char in digest) if "publicationSha256" in before_selection: - assert digest == before_selection.pop("publicationSha256") + before_digest = before_selection.pop("publicationSha256") + assert digest == before_digest assert after_selection == before_selection run_after["finalization_input_json"] = run_before["finalization_input_json"] assert run_after == run_before diff --git a/plugins/codex-security/tests/test_deep_scan_usage_owner.py b/plugins/codex-security/tests/test_deep_scan_usage_owner.py index 9b5cb237cd..63b91f8416 100644 --- a/plugins/codex-security/tests/test_deep_scan_usage_owner.py +++ b/plugins/codex-security/tests/test_deep_scan_usage_owner.py @@ -2,10 +2,12 @@ import json import sqlite3 -from datetime import datetime, timezone +import uuid +from datetime import datetime, timedelta, timezone from pathlib import Path -from test_workbench_scan_usage import _event, _rollout, _state_graph +import pytest +from test_workbench_scan_usage import _event, _rollout, _state_graph, _token_event from workbench_test_support import run_workbench @@ -97,3 +99,108 @@ def test_original_usage_turn_survives_join_and_coordinator_recovery(tmp_path: Pa state, "get-scan", "--scan-id", begun["scanId"], environment=environment )["scan"] assert original["executionAttribution"]["owner"] == owner + + +@pytest.mark.parametrize("migrated", [False, True]) +@pytest.mark.parametrize("attempt", [False, True]) +def test_migrated_owner_accounting_survives_first_worker_attempt( + tmp_path: Path, migrated: bool, attempt: bool, workbench_api, monkeypatch +) -> None: + from workbench_scan_usage import collect_scan_usage, scan_execution_attribution + + state = tmp_path / "state" + target = tmp_path / "target" + target.mkdir() + environment = { + "CODEX_HOME": str(tmp_path / "codex"), + "CODEX_SQLITE_HOME": str(tmp_path / "native"), + "CODEX_STATE_DB": "", + } + timestamp = datetime.now(timezone.utc) + parent = _rollout( + tmp_path, + "legacy-owner", + [ + _event(timestamp, "turn_context", {"turn_id": "original-turn", "model": "gpt-5.6-sol"}), + ], + ) + _state_graph(environment, {"legacy-owner": parent}, []) + begun = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "legacy-owner", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + environment=environment, + )["deepScan"] + database = state / "workbench.sqlite3" + if migrated: + # Migration 48 adds a nullable owner to pre-existing runs, without an original turn binding. + with sqlite3.connect(database) as connection: + connection.execute("ALTER TABLE deep_scan_runs DROP COLUMN usage_owner_json") + connection.execute("DELETE FROM schema_migrations WHERE version = 48") + run_workbench( + state, + "get-deep-scan", + "--scan-id", + begun["scanId"], + "--thread-id", + "legacy-owner", + environment=environment, + ) + if attempt: + artifact = Path(begun["scanDir"]) / "artifacts" / "first-worker" + artifact.mkdir(parents=True) + prompt = artifact / "prompt.md" + prompt.write_text("Review the synthetic target.\n") + run_workbench( + state, + "upsert-deep-scan-worker", + "--scan-id", + begun["scanId"], + "--worker-id", + str(uuid.uuid4()), + "--kind", + "discovery", + "--status", + "running", + "--prompt-path", + str(prompt), + "--artifact-dir", + str(artifact), + environment=environment, + ) + counted = datetime.fromisoformat(begun["createdAt"].replace("Z", "+00:00")) + timedelta( + microseconds=1 + ) + _rollout( + tmp_path, + "legacy-owner", + [ + _event( + counted, + "event_msg", + { + "type": "task_started", + "turn_id": "original-turn", + "started_at": int(counted.timestamp()), + }, + ), + _event(counted, "turn_context", {"turn_id": "original-turn", "model": "gpt-5.6-sol"}), + _token_event(counted, 100, 10), + ], + task_started_at=counted, + ) + for name, value in environment.items(): + monkeypatch.setenv(name, value) + with sqlite3.connect(database) as connection: + connection.row_factory = sqlite3.Row + scan = workbench_api["require_scan"](connection, begun["scanId"]) + attribution = scan_execution_attribution(connection, scan) + assert bool(attribution.get("legacy")) is migrated + usage = collect_scan_usage(connection, scan) + assert usage["inputTokens"] == 100 + assert usage["outputTokens"] == 10 diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 56e492593d..5d7d7a2e3e 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -660,6 +660,7 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N "current-unreadable", "current-mismatched", "external-sqlite", + "external-archived", "external-shared-home", "external-missing-copy", "external-missing-child", @@ -862,6 +863,7 @@ def complete(index: int) -> dict[str, Any]: ) elif worker_home in { "external-sqlite", + "external-archived", "external-shared-home", "external-missing-copy", "external-missing-child", @@ -878,7 +880,13 @@ def complete(index: int) -> dict[str, Any]: ) # Native keeps rollouts in its Codex home even when its SQLite # index lives elsewhere and recovery chooses a different index. - sessions = selected_home / "sessions" / "2026" / "01" / "01" + sessions = ( + selected_home + / ("archived_sessions" if worker_home == "external-archived" else "sessions") + / "2026" + / "01" + / "01" + ) sessions.mkdir(parents=True, exist_ok=True) for thread_id, path in worker_threads.items(): recorded = sessions / f"rollout-{thread_id}.jsonl" diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 7e6e28bdda..aaa592503a 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -282,31 +282,33 @@ export class ScanCostTracker { // continue in the current home. Apply the same scan membership to both. const directories = new Set(); for (const home of homes) { - let directory: string; - try { - directory = await realpath(join(home, "sessions")); - } catch (error) { - if (isMissingFile(error)) continue; - throw error; - } - if (directories.has(directory)) continue; - directories.add(directory); - for await (const path of sessionFiles(directory)) { - let session = this.#sessions.get(path); - if (session === undefined) { - session = createSessionUsage(); - this.#sessions.set(path, session); - } + for (const source of ["sessions", "archived_sessions"]) { + let directory: string; try { - await readSessionUsage( - path, - session, - this.#options.repository, - this.#attribution, - ); + directory = await realpath(join(home, source)); } catch (error) { - if (session.threadId === null) throw error; - unreadable.push({ session, error }); + if (isMissingFile(error)) continue; + throw error; + } + if (directories.has(directory)) continue; + directories.add(directory); + for await (const path of sessionFiles(directory)) { + let session = this.#sessions.get(path); + if (session === undefined) { + session = createSessionUsage(); + this.#sessions.set(path, session); + } + try { + await readSessionUsage( + path, + session, + this.#options.repository, + this.#attribution, + ); + } catch (error) { + if (session.threadId === null) throw error; + unreadable.push({ session, error }); + } } } } diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 13dbaaa4e2..ce1961fce3 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -5,6 +5,7 @@ import { cp, mkdir, readFile, + rename, unlink, symlink, writeFile, @@ -2131,6 +2132,74 @@ describe("live scan cost tracking", () => { }); describe("recorded Deep worker homes", () => { + test.each([ + [false, false], + [false, true], + [true, false], + [true, true], + ])( + "retains archived worker usage: recorded=%s archived=%s", + async (recorded, archived) => { + const home = await codexHome(); + const workerHome = recorded ? await codexHome() : home; + const at = "2026-09-01T00:00:02Z"; + await writeSession(home, "owner", {}); + const worker = await writeSession(workerHome, "worker", {}); + await appendFile( + worker, + jsonLines([ + { + type: "turn_context", + timestamp: at, + payload: { turn_id: "worker-turn", model: "gpt-5.6-sol" }, + }, + { + type: "token_usage_record", + timestamp: at, + payload: { + thread_id: "worker", + turn_id: "worker-turn", + response_id: "worker-response", + model: "gpt-5.6-sol", + usage: { input_tokens: 1_000, output_tokens: 0 }, + }, + }, + ]) + "\n", + ); + if (archived) { + const directory = join(workerHome, "archived_sessions"); + await mkdir(directory, { recursive: true }); + await rename(worker, join(directory, "worker.jsonl")); + } + const costs: Readonly[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + scanDirectory: join(home, "scan"), + model: "gpt-5.6-sol", + maxCostUsd: 0.003, + onCost: (cost) => costs.push(cost), + onCostLowerBound: (cost) => costs.push(cost), + }); + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + workerCodexHome: workerHome, + executionThreadIds: ["worker"], + owner: { threadId: "owner", turnId: "owner-turn", startedAt: at }, + startedAt: at, + completedAt: null, + })); + tracker.start("owner"); + try { + await tracker.refresh(); + expect(costs.at(-1)?.inputTokens).toBe(1_000); + expect(costs.at(-1)?.estimatedUsd).toBeCloseTo(0.004, 10); + expect(costs.at(-1)!.estimatedUsd).toBeGreaterThan(0.003); + } finally { + await tracker.stop(); + } + }, + ); + test("only enforces a priced subtotal from a valid attributed usage partition", () => { const known = { model: "gpt-5.6-sol", From 9553242a7fd4b04ba1105247bd1ae388d1ca65a7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 12:12:42 +0000 Subject: [PATCH 192/251] Use shared report bullet section for retained remediation details --- plugins/codex-security/scripts/report_projection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/codex-security/scripts/report_projection.py b/plugins/codex-security/scripts/report_projection.py index bd9a4d84c8..8f465d5562 100644 --- a/plugins/codex-security/scripts/report_projection.py +++ b/plugins/codex-security/scripts/report_projection.py @@ -535,7 +535,7 @@ def _remediation_section(finding: dict[str, Any]) -> list[str]: ) ) if values: - lines.extend(["", f"{label}:", *_bullets(values, "None recorded.")]) + lines.extend(_bullet_section(f"{label}:", values)) return lines From e684671d1176487b21e18f44391c5e10e35acadb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 12:44:41 +0000 Subject: [PATCH 193/251] Supply prepared Deep worker configuration in concurrent child fixture --- sdk/typescript/tests-ts/api.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 48e59a2f63..d9f766cfa6 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -7168,6 +7168,7 @@ if ([basename(process.argv[1]), ...process.argv.slice(2)].join(" ") !== "login s prepareRuntime: async () => ({ ...preparedRuntime(codexHome), configPath, + deepScanConfigPath: join(codexHome, "deep-scan.toml"), environment: { ...fake.environment, FIXTURE_SCAN_VALUE: name, From bc8e2843905faaa4fb3d64c074d1884645fd2663 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 15:05:24 +0000 Subject: [PATCH 194/251] fix(deep-scan): keep provider catalog settings in private profiles --- .../tests/test_deep_scan_stdio_lifecycle.ts | 1 + sdk/typescript/src/api.ts | 3 + sdk/typescript/src/reasoning-summary.ts | 7 +- .../tests-ts/reasoning-summary.test.ts | 82 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts index 072bba2712..be21a4d22e 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts @@ -833,6 +833,7 @@ async function testDeepScanStdioLifecycle() { "references", "schemas", ".codex-plugin", + ...(installedPluginRoot ? ["mcp"] : []), ]) { await cp( path.join(pluginRoot, directory), diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 72dae1713b..3ebe8ae40b 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1383,6 +1383,9 @@ export class CodexSecurity { if (mode === "deep" && options.resumeScanId === undefined) { const summary = await captureOriginalReasoningSummary({ config: session.sessionConfig, + ...(runtime.providerProfile === undefined + ? {} + : { nativeProfile: runtime.providerProfile.name }), command: this.#codexCommand(), cwd: scanDir, environment: { diff --git a/sdk/typescript/src/reasoning-summary.ts b/sdk/typescript/src/reasoning-summary.ts index a313bc00c4..f8f7e444ef 100644 --- a/sdk/typescript/src/reasoning-summary.ts +++ b/sdk/typescript/src/reasoning-summary.ts @@ -2,6 +2,7 @@ import { inlineToml, resolveCodexProfile, scanModelConfiguration, + structuredCodexConfig, type JsonObject, } from "./config.js"; import { scanPreflightCodexConfig } from "./preflight-config.js"; @@ -14,6 +15,7 @@ import { /** Capture a future owner's selected model default; never infer historical settings. */ export async function captureOriginalReasoningSummary(options: { config: JsonObject; + nativeProfile?: string; command: CodexCommand; cwd: string; environment: ProcessEnvironment; @@ -28,10 +30,13 @@ export async function captureOriginalReasoningSummary(options: { return undefined; const { model } = scanModelConfiguration(options.config); // The same per-session overrides protect the lookup from concurrent home edits. - const config = JSON.parse(JSON.stringify(options.config)) as JsonObject; + const config = structuredCodexConfig(options.config); const args = [ "debug", "models", + ...(options.nativeProfile === undefined + ? [] + : ["--profile", options.nativeProfile]), ...Object.entries(config).flatMap(([key, value]) => [ "--config", `${key}=${inlineToml(value)}`, diff --git a/sdk/typescript/tests-ts/reasoning-summary.test.ts b/sdk/typescript/tests-ts/reasoning-summary.test.ts index 854ee85ead..4bff796150 100644 --- a/sdk/typescript/tests-ts/reasoning-summary.test.ts +++ b/sdk/typescript/tests-ts/reasoning-summary.test.ts @@ -13,6 +13,7 @@ import { pathToFileURL } from "node:url"; import { expect, test } from "bun:test"; import { captureOriginalReasoningSummary } from "../src/reasoning-summary.js"; import type { JsonObject } from "../src/config.js"; +import { createProviderProfile } from "../src/provider-profile.js"; test.each([ { model_reasoning_summary: "none" }, @@ -80,3 +81,84 @@ test.each(["unknown-model", "unsupported-command", "missing-metadata"])( } }, ); + +test.each(["literal", "selected", "default"])( + "original model lookup keeps %s provider credentials in its native profile", + async (scenario) => { + const root = await mkdtemp(join(tmpdir(), "summary-provider-")); + let profile: Awaited> | undefined; + try { + const script = join(root, "native.mjs"); + const receipt = join(root, "arguments.json"); + const provider = "synthetic.gateway"; + const definition = { + name: "Synthetic gateway", + http_headers: { Authorization: "synthetic-summary-header" }, + }; + const config: JsonObject = { + model: "selected-model", + model_reasoning_effort: "high", + ...(scenario === "default" + ? {} + : scenario === "selected" + ? { + profile: "selected", + profiles: { + selected: { + model_provider: provider, + model_providers: { [provider]: definition }, + }, + }, + } + : { + model_provider: provider, + model_providers: { [provider]: definition }, + }), + }; + if (scenario !== "default") + profile = await createProviderProfile(root, config); + await writeFile( + script, + [ + 'import { writeFileSync, readFileSync } from "node:fs";', + 'import { join } from "node:path";', + `writeFileSync(${JSON.stringify(receipt)}, JSON.stringify(process.argv));`, + 'const i = process.argv.indexOf("--profile");', + "const selected = i === -1 ? undefined : process.argv[i + 1];", + ...(scenario === "default" + ? [] + : [ + 'if (!selected || !readFileSync(join(process.env.CODEX_HOME, selected + ".config.toml"), "utf8").includes("synthetic-summary-header")) process.exit(13);', + ]), + 'console.log(JSON.stringify({models:[{slug:"selected-model",default_reasoning_summary:"none"}]}));', + "process.exit(0);", + ].join("\n"), + ); + const command = execFileSync("node", ["-p", "process.execPath"], { + encoding: "utf8", + }).trim(); + const value = await captureOriginalReasoningSummary({ + config, + command: { command }, + cwd: root, + environment: { + CODEX_HOME: root, + NODE_OPTIONS: `--import=${pathToFileURL(script).href}`, + }, + signal: new AbortController().signal, + ...(profile === undefined ? {} : { nativeProfile: profile.name }), + }); + const args: string[] = JSON.parse(await readFile(receipt, "utf8")); + expect(args.some((arg) => arg.includes("synthetic-summary-header"))).toBe( + false, + ); + expect(value).toBe("none"); + expect(args).toContain('model="selected-model"'); + if (profile !== undefined) + expect(args[args.indexOf("--profile") + 1]).toBe(profile.name); + } finally { + await profile?.cleanup(); + await rm(root, { recursive: true, force: true }); + } + }, +); From da2e70102359ee4aeb7800d698291bcdca5845b7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 16:11:58 +0000 Subject: [PATCH 195/251] Preserve native provider profiles across restored account homes --- plugins/codex-security/mcp-app/server.ts | 2 + .../mcp-app/src/deep-scan/executor.ts | 43 +++- .../src/deep-scan/recovery-settings.ts | 4 + .../tests/test_deep_scan_coordinator.ts | 1 + .../mcp-app/tests/test_deep_scan_executor.ts | 197 ++++++++++++++++++ 5 files changed, 245 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 522fdf7073..780290bf60 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -37,6 +37,7 @@ import { restoredDeepScanWorkerSettings, type DeepScanLegacySettingsContext, } from "./src/deep-scan/recovery-settings.js"; +import { createProviderProfile } from "../../../sdk/typescript/src/provider-profile.js"; import { CodexSdkWorkerExecutor } from "./src/deep-scan/executor.js"; import { CODEX_SANDBOX_STATE_META_CAPABILITY, @@ -1171,6 +1172,7 @@ export function createCodexSecurityServer(): McpServer { store: deepScanStore, prepareExecutor: async (run) => new CodexSdkWorkerExecutor({ + createProviderProfile, ...restoredDeepScanWorkerSettings( await loadDeepScanExecutionSettings( run.scanDir, diff --git a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts index f62945651c..8117d7bd4d 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/executor.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/executor.ts @@ -46,7 +46,14 @@ import type { CodexWorkerResult, } from "./types.js"; +import type { + createProviderProfile, + ProviderProfile, +} from "../../../../../sdk/typescript/src/provider-profile.js"; + export interface CodexSdkWorkerModelSettings { + nativeProfileHome?: string; + createProviderProfile?: typeof createProviderProfile; /** Resolved by the execution owner, including when reconstructing a scan. */ codexOptions?: CodexOptions; model?: string; @@ -89,6 +96,7 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { ) {} async run(request: CodexWorkerRequest): Promise { + let providerProfile: ProviderProfile | undefined; try { const parentSandbox = this.modelSettings.parentSandbox; if (!parentSandbox) { @@ -101,7 +109,10 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { const originalCwd = process.cwd(); const childEnv = await snapshotWorkerEnvironment(resolved?.env); const runtimeSettings = await (this.runtimeSettings ??= (async () => ({ - ...(await workerRuntimeSettings(childEnv)), + ...(await workerRuntimeSettings( + childEnv, + this.modelSettings.nativeProfileHome, + )), ...this.modelSettings.runtimeSettings, }))()); for (const [name, value] of Object.entries( @@ -165,6 +176,29 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { workerProfile, }), ]; + const profileHome = this.modelSettings.nativeProfileHome; + const boundHome = + environmentVariable(childEnv, "CODEX_HOME", process.platform) || + join(homedir(), ".codex"); + if ( + runtimeSettings.nativeProfile !== undefined && + profileHome !== undefined && + this.modelSettings.createProviderProfile && + (await fs.realpath(profileHome)) !== (await fs.realpath(boundHome)) + ) { + // The account home is bound to the original scan; the current private + // provider profile belongs to this execution owner's environment. + const profileConfig = parseToml( + await fs.readFile( + join(profileHome, `${runtimeSettings.nativeProfile}.config.toml`), + "utf8", + ), + ); + providerProfile = await this.modelSettings.createProviderProfile( + boundHome, + profileConfig as Parameters[1], + ); + } const openAiApiKey = environmentVariable( childEnv, "OPENAI_API_KEY", @@ -220,7 +254,8 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { ? createCodexClient(codexOptions) : createCodexProfileClient({ ...codexOptions, - profileName: runtimeSettings.nativeProfile, + profileName: + providerProfile?.name ?? runtimeSettings.nativeProfile, }); const threadOptions = { ...(this.modelSettings.model @@ -311,6 +346,8 @@ export class CodexSdkWorkerExecutor implements CodexWorkerExecutor { } } catch (error) { throw classifyCodexWorkerError(error); + } finally { + await providerProfile?.cleanup(); } } @@ -633,6 +670,7 @@ export function workerRuntimeSettingsFromConfig( async function workerRuntimeSettings( environment: Record, + nativeProfileHome?: string, ): Promise { const configPath = environmentVariable( environment, @@ -680,6 +718,7 @@ async function workerRuntimeSettings( } settings.nativeProfile = nativeProfile; const codexHome = + nativeProfileHome || environmentVariable(environment, "CODEX_HOME", process.platform) || join(homedir(), ".codex"); const nativeProfileConfig = parseToml( diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 815643605c..2cee397de0 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -336,6 +336,7 @@ export function restoredDeepScanWorkerSettings( currentParentSandbox: DeepWorkerParentSandbox, environment: () => NodeJS.ProcessEnv = () => process.env, ): { + nativeProfileHome: string; codexOptions: CodexOptions; model?: string; reasoningEffort?: string; @@ -357,6 +358,9 @@ export function restoredDeepScanWorkerSettings( ), ); return { + get nativeProfileHome() { + return environment().CODEX_HOME || join(homedir(), ".codex"); + }, model: settings.model, reasoningEffort: settings.reasoningEffort, ...(settings.runtimeSettings === undefined diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts index ed4d9321fd..2ecda8d4bb 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts @@ -19,6 +19,7 @@ import { } from "node:fs/promises"; import path from "node:path"; import { + DeepScanCoordinator, DeepScanCoordinatorRegistry, DeepScanNonRetryableError, DeepScanRemoteCoordinator, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 9e66ad609c..5c2c3b3610 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -102,6 +102,7 @@ try { await testUnsupportedProviderSnapshotFailsBeforeLaunch(); await testWorkerCyberAccessSettings(); await testIsolatedReconstructedWorkers(); + await testRestoredPrivateProfileHomes(); await testCompletedWorkerSettlesWithoutWaitingForProcessExit(); if (process.platform !== "win32") { await testMissingParentSandboxFailsBeforeWorkerLaunch(); @@ -3738,3 +3739,199 @@ async function testReducerCoveragePersistenceBinding() { restoreEnv("FAKE_CODEX_MARKER", previousMarker); } } + +async function testRestoredPrivateProfileHomes() { + const providerSource = new URL( + "../../../../sdk/typescript/src/provider-profile.ts", + import.meta.url, + ); + const { createProviderProfile } = await importModule({ + entryPoints: [fileURLToPath(providerSource)], + define: { "import.meta.url": JSON.stringify(providerSource.href) }, + banner: { + js: `import { createRequire as profileRequire } from "node:module"; const require = profileRequire(${JSON.stringify(providerSource.href)});`, + }, + }); + const fixture = await fakeCodexFixture(); + const owners = []; + for (const name of ["same", "first", "second"]) { + const originalHome = path.join(fixture.root, name, "original"); + const currentHome = + name === "same" ? originalHome : path.join(fixture.root, name, "current"); + await mkdir(originalHome, { recursive: true }); + await mkdir(currentHome, { recursive: true }); + const config = { + model: "fixture-model", + model_provider: "fixture-provider", + model_providers: { + "fixture-provider": { + name: "Fixture", + wire_api: "responses", + requires_openai_auth: true, + http_headers: { Authorization: `synthetic-${name}-profile-header` }, + }, + }, + }; + const configPath = path.join(fixture.root, name, "preflight.toml"); + await writeFile(configPath, stringifyToml(config)); + const sandbox = { + filesystemDenies: [...new Set([originalHome, currentHome])], + }; + const saved = await captureDeepScanExecutionSettings( + { model: config.model }, + sandbox, + { + CODEX_HOME: originalHome, + CODEX_SECURITY_CONFIG_PATH: configPath, + CODEX_CLI_PATH: process.execPath, + }, + ); + const current = await createProviderProfile(currentHome, config); + const workerConfigPath = path.join(currentHome, "workers.toml"); + await writeFile( + workerConfigPath, + stringifyToml({ worker_runtime: { native_profile: current.name } }), + ); + const promptPath = path.join(fixture.root, name, "prompt.md"); + await writeFile( + promptPath, + "NULL_USAGE CAPTURE_SYNTHETIC_OPENAI_AUTH fixture worker\n", + ); + const markerPath = path.join(fixture.root, name, "invocation.json"); + const environment = { + ...process.env, + CODEX_HOME: currentHome, + CODEX_SECURITY_CONFIG_PATH: configPath, + CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH: workerConfigPath, + CODEX_CLI_PATH: process.execPath, + CODEX_API_KEY: `synthetic-${name}-worker-key`, + FAKE_CODEX_MARKER: markerPath, + FAKE_CODEX_PREFLIGHT_MARKER: path.join( + fixture.root, + name, + "preflight.json", + ), + FAKE_CODEX_PREFLIGHT_PROFILE: JSON.stringify({ + extends: ":read-only", + filesystem: Object.fromEntries([ + [":root", "read"], + ...sandbox.filesystemDenies.map((value) => [value, "deny"]), + ]), + network: { enabled: false }, + }), + }; + const recreated: { + name: string; + path: string; + cleanup(): Promise; + }[] = []; + const executor = new CodexSdkWorkerExecutor({ + ...restoredDeepScanWorkerSettings(saved, sandbox, () => environment), + createProviderProfile: async ( + home: string, + profileConfig: Record, + ) => { + const profile = await createProviderProfile(home, profileConfig); + recreated.push(profile); + return profile; + }, + }); + owners.push({ + name, + originalHome, + currentHome, + current, + promptPath, + markerPath, + environment, + recreated, + executor, + }); + } + const originalSpawn = childProcess.spawn; + childProcess.spawn = (( + command: string, + args: readonly string[] = [], + options: SpawnOptions = {}, + ) => + originalSpawn( + command, + command === process.execPath || + command === path.win32.toNamespacedPath(process.execPath) + ? [fixture.executablePath, ...args] + : args, + options, + )) as typeof childProcess.spawn; + syncBuiltinESMExports(); + try { + for (const kind of ["discovery", "dedup"] as const) { + await Promise.all( + owners.map(async (owner) => { + const request = workerRequest(owner.promptPath, fixture.root, { + kind, + ...(kind === "dedup" + ? { resumeThreadId: `fixture-resumed-${owner.name}` } + : {}), + }); + const result = await owner.executor.run(request); + assert.equal( + result.threadId, + kind === "dedup" + ? `fixture-resumed-${owner.name}` + : "fixture-thread-id", + ); + const child = await readJson(owner.markerPath); + assert.equal(child.codexHome, owner.originalHome); + assert.match( + child.profileContents, + new RegExp(`synthetic-${owner.name}-profile-header`), + ); + assert.equal( + child.argv + .join(" ") + .includes(`synthetic-${owner.name}-profile-header`), + false, + ); + assert.equal( + child.openaiAuthentication.CODEX_API_KEY, + `synthetic-${owner.name}-worker-key`, + ); + for (const profile of owner.recreated) + await assert.rejects(readFile(profile.path), { code: "ENOENT" }); + await readFile(owner.current.path); + }), + ); + } + assert.equal(owners[0].recreated.length, 0); + assert.equal( + new Set( + owners.flatMap((owner) => + owner.recreated.map((profile) => profile.name), + ), + ).size, + 4, + ); + const owner = owners[1]; + const acceptedProfile = owner.environment.FAKE_CODEX_PREFLIGHT_PROFILE; + owner.environment.FAKE_CODEX_PREFLIGHT_PROFILE = JSON.stringify({ + ...emptyWorkerPermissionProfile, + network: { enabled: true }, + }); + await assert.rejects( + owner.executor.run(workerRequest(owner.promptPath, fixture.root)), + ); + for (const profile of owner.recreated) + await assert.rejects(readFile(profile.path), { code: "ENOENT" }); + owner.environment.FAKE_CODEX_PREFLIGHT_PROFILE = acceptedProfile; + await writeFile(owner.promptPath, "CONFIG_ERROR\n"); + await assert.rejects( + owner.executor.run(workerRequest(owner.promptPath, fixture.root)), + ); + for (const profile of owner.recreated) + await assert.rejects(readFile(profile.path), { code: "ENOENT" }); + } finally { + childProcess.spawn = originalSpawn; + syncBuiltinESMExports(); + await Promise.all(owners.map((owner) => owner.current.cleanup())); + } +} From 46662f89b0fafdb236fdf8c48effb554ed695f59 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 17:34:08 +0000 Subject: [PATCH 196/251] fix: qualify recovered profile imports and worker fixtures --- plugins/codex-security/mcp-app/package.json | 2 ++ plugins/codex-security/mcp-app/pnpm-lock.yaml | 26 +++++++++++++++++++ plugins/codex-security/mcp-app/tsconfig.json | 3 ++- sdk/typescript/src/reasoning-summary.ts | 1 + .../deep-scan-worker-shutdown.test.ts | 12 +++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/package.json b/plugins/codex-security/mcp-app/package.json index c1ebd60132..466a6df064 100644 --- a/plugins/codex-security/mcp-app/package.json +++ b/plugins/codex-security/mcp-app/package.json @@ -14,10 +14,12 @@ "@openai/codex-sdk": "0.162.0-alpha.18", "ajv": "8.20.0", "smol-toml": "1.9.0", + "yauzl": "3.4.0", "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^26.6.3", + "@types/yauzl": "3.4.0", "esbuild": "^0.28.2", "prettier": "3.9.9", "typescript": "^7.0.2" diff --git a/plugins/codex-security/mcp-app/pnpm-lock.yaml b/plugins/codex-security/mcp-app/pnpm-lock.yaml index 8e9e6ccdc7..ce83d06a6e 100644 --- a/plugins/codex-security/mcp-app/pnpm-lock.yaml +++ b/plugins/codex-security/mcp-app/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: smol-toml: specifier: 1.9.0 version: 1.9.0 + yauzl: + specifier: 3.4.0 + version: 3.4.0 zod: specifier: ^4.6.5 version: 4.6.5 @@ -27,6 +30,9 @@ importers: '@types/node': specifier: ^26.6.3 version: 26.6.3 + '@types/yauzl': + specifier: 3.4.0 + version: 3.4.0 esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -259,6 +265,9 @@ packages: '@types/node@26.6.3': resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + '@types/yauzl@3.4.0': + resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -636,6 +645,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -747,6 +759,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -896,6 +912,10 @@ snapshots: dependencies: undici-types: 8.9.0 + '@types/yauzl@3.4.0': + dependencies: + '@types/node': 26.6.3 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1236,6 +1256,8 @@ snapshots: path-to-regexp@8.4.2: {} + pend@1.2.0: {} + pkce-challenge@5.0.1: {} prettier@3.9.9: {} @@ -1381,6 +1403,10 @@ snapshots: wrappy@1.0.2: {} + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + zod-to-json-schema@3.25.2(zod@4.6.5): dependencies: zod: 4.6.5 diff --git a/plugins/codex-security/mcp-app/tsconfig.json b/plugins/codex-security/mcp-app/tsconfig.json index 7b8116b0b0..44f919e99f 100644 --- a/plugins/codex-security/mcp-app/tsconfig.json +++ b/plugins/codex-security/mcp-app/tsconfig.json @@ -5,7 +5,8 @@ "noEmit": true, "paths": { "@openai/codex-sdk": ["./node_modules/@openai/codex-sdk"], - "smol-toml": ["./node_modules/smol-toml/dist/index"] + "smol-toml": ["./node_modules/smol-toml/dist/index"], + "yauzl": ["./node_modules/@types/yauzl", "./node_modules/yauzl"] }, "resolveJsonModule": true, "skipLibCheck": true, diff --git a/sdk/typescript/src/reasoning-summary.ts b/sdk/typescript/src/reasoning-summary.ts index f8f7e444ef..e5babaabad 100644 --- a/sdk/typescript/src/reasoning-summary.ts +++ b/sdk/typescript/src/reasoning-summary.ts @@ -23,6 +23,7 @@ export async function captureOriginalReasoningSummary(options: { }): Promise { // Keep explicit selections and invalid values with their existing native validator. if ( + options.config["model_reasoning_summary"] !== undefined || scanPreflightCodexConfig(options.config)["model_reasoning_summary"] !== undefined || resolveCodexProfile(options.config)["model_reasoning_summary"] !== undefined diff --git a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts index 4e1054fbda..35b8a203be 100644 --- a/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-worker-shutdown.test.ts @@ -1,3 +1,5 @@ +import * as nodeOs from "node:os"; +import * as nodePath from "node:path"; import { expect, test } from "bun:test"; import { loadBundledRuntime } from "./plugin-root.js"; import { profileConfigOverrides } from "../../../plugins/codex-security/scripts/codex_profile.mjs"; @@ -48,6 +50,12 @@ async function bundledWorkerExecutor( source, )?.[1]; expect(fileSystemImport).toBeDefined(); + const pathImport = /\b(import_node_path\d*)\.(?:join|resolve)\b/u.exec( + source, + )?.[1]; + expect(pathImport).toBeDefined(); + const osImport = /\b(import_node_os\d*)\.homedir\b/u.exec(source)?.[1]; + expect(osImport).toBeDefined(); class FakeCodex { startThread(options: { threadSource: string }) { @@ -64,6 +72,8 @@ async function bundledWorkerExecutor( return new Function( "Codex", fileSystemImport!, + pathImport!, + osImport!, "workerPermissionProfile", "profileConfigOverrides", "DEEP_SCAN_WORKER_PERMISSION_PROFILE_ID", @@ -83,6 +93,8 @@ async function bundledWorkerExecutor( )( FakeCodex, { promises: { readFile: async () => "fixture worker prompt" } }, + nodePath, + nodeOs, () => ({}), profileConfigOverrides, "codex_security_deep_scan_worker", From 4765f3280a42466612079627428e97424213f2e1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 18:25:29 +0000 Subject: [PATCH 197/251] fix: preserve budget validation errors and legacy replay controls --- .../codex-security/scripts/workbench_db.py | 27 ++++++++++--------- .../tests/test_budget_coverage_provenance.py | 19 ++++++++++++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 7c41874332..a97ab6e9d2 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1194,21 +1194,24 @@ def complete_budget_exhausted_scan( except ContractError as exc: raise SystemExit(str(exc)) from exc if before_selection: - deep_scan.prepare_budget_exhausted_deep_scan( - connection, - scan, - scan_dir, - warning, - lambda accepted, unmerged: budget_exhausted_draft( + try: + deep_scan.prepare_budget_exhausted_deep_scan( + connection, scan, scan_dir, - [], warning, - before_selection=True, - accepted_result=accepted, - unmerged_workers=unmerged, - ), - ) + lambda accepted, unmerged: budget_exhausted_draft( + scan, + scan_dir, + [], + warning, + before_selection=True, + accepted_result=accepted, + unmerged_workers=unmerged, + ), + ) + except ContractError as exc: + raise SystemExit(str(exc)) from exc else: budget_exhausted_draft( scan, diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py index 458f873a45..aadcdc3d1a 100644 --- a/plugins/codex-security/tests/test_budget_coverage_provenance.py +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -163,7 +163,19 @@ def legacy_projection(*args): for key in descriptions: item["provenance"].pop(key, None) - def interrupt_before_seal(*args): + def interrupt_before_seal(connection, *_args): + selection = json.loads( + connection.execute( + "SELECT finalization_input_json FROM deep_scan_runs WHERE scan_id = ?", + (scan.scan_id,), + ).fetchone()[0] + ) + selection.pop("publicationSha256", None) + with connection: + connection.execute( + "UPDATE deep_scan_runs SET finalization_input_json = ? WHERE scan_id = ?", + (json.dumps(selection), scan.scan_id), + ) raise RuntimeError("Publication interrupted after the budget draft committed.") database = tmp_path / "interrupted-budget.sqlite3" @@ -175,6 +187,11 @@ def interrupt_before_seal(*args): patch.setattr(saved, "retain_unmerged_budget_coverage", legacy_projection) # The legacy writer predates the selected-publication digest. patch.setattr(saved, "record_selected_publication", lambda *args: None) + patch.setattr( + budget.__globals__["deep_scan"], + "_selected_publication_digest_for_documents", + lambda *args: None, + ) patch.setitem(budget.__globals__, "complete_scan_locked", interrupt_before_seal) with pytest.raises(RuntimeError, match="budget draft committed"): budget(connection, args) From 4a4151b81bb6f37c1657c174c2788f60c9b37e78 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 19:47:17 +0000 Subject: [PATCH 198/251] fix: retain selected worker provider and owner pricing --- .../mcp-app/tests/test_deep_scan_executor.ts | 30 +++++---- sdk/typescript/src/cost.ts | 3 +- sdk/typescript/src/preflight-config.ts | 6 +- .../tests-ts/api-preflight-config.test.ts | 66 ++++++++++++++++++- sdk/typescript/tests-ts/api.test.ts | 2 +- sdk/typescript/tests-ts/cost.test.ts | 46 +++++++++++++ 6 files changed, 135 insertions(+), 18 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 5c2c3b3610..af38a05647 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -3024,20 +3024,24 @@ async function testIsolatedReconstructedWorkers() { }; await writeFile( configPath, - Object.entries(config) - .filter( - ([key]) => - name !== "first" || - ![ - "model_provider", - "model_reasoning_summary", - "service_tier", - ].includes(key), - ) - .map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`) - .join("") + + (name === "second" + ? 'model_provider = "openai"\nprofile = "cloud.production"\n' + : "") + + Object.entries(config) + .filter( + ([key]) => + (name !== "second" || key !== "model_provider") && + (name !== "first" || + ![ + "model_provider", + "model_reasoning_summary", + "service_tier", + ].includes(key)), + ) + .map(([key, value]) => `${key} = ${JSON.stringify(value)}\n`) + .join("") + (name === "second" - ? '[model_providers.amazon-bedrock.aws]\nregion = "us-west-2"\nprofile = "fixture-profile"\n' + ? '[profiles."cloud.production"]\nmodel_provider = "amazon-bedrock"\n[model_providers.amazon-bedrock.aws]\nregion = "us-west-2"\nprofile = "fixture-profile"\n' : "") + `[codex_security]\ncyber_access_program = "${name === "first" ? "daybreak_blue" : "standard"}"\n[features]\napi_key_cyber_access_programs = ${name === "first"}\napi_key_model_discovery = ${name !== "first"}\n`, ); diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 486a5b744e..ef625df7b2 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -553,7 +553,8 @@ export class ScanCostTracker { : value; if (remainder !== null && remainder.total_tokens > 0) { const model = - this.#attribution || (session?.modelUsage.size ?? 0) > 0 + (this.#attribution && threadId !== this.#threadId) || + (session?.modelUsage.size ?? 0) > 0 ? null : (session?.model ?? (threadId === this.#threadId ? this.#options.model : null)); diff --git a/sdk/typescript/src/preflight-config.ts b/sdk/typescript/src/preflight-config.ts index c3d21c4b61..98a1484344 100644 --- a/sdk/typescript/src/preflight-config.ts +++ b/sdk/typescript/src/preflight-config.ts @@ -80,7 +80,11 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject { const result = executionConfig(config); // Keep effective worker settings even when preflight filters the profile name. const resolved = resolveCodexProfile(config); - for (const key of ["model_reasoning_summary", "service_tier"]) { + for (const key of [ + "model_provider", + "model_reasoning_summary", + "service_tier", + ]) { const value = resolved[key]; if (safeString(value)) result[key] = value; } diff --git a/sdk/typescript/tests-ts/api-preflight-config.test.ts b/sdk/typescript/tests-ts/api-preflight-config.test.ts index e1b90d3ede..0bf9709a9e 100644 --- a/sdk/typescript/tests-ts/api-preflight-config.test.ts +++ b/sdk/typescript/tests-ts/api-preflight-config.test.ts @@ -17,6 +17,8 @@ import { } from "../src/config.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; import { TestClient } from "./support/api-client.js"; +import { preparedRuntime } from "./support/api-events.js"; +import { parse as parseToml } from "smol-toml"; import { createApiTestFixtures } from "./support/temporary-directories.js"; const { temporaryDirectory, cleanup } = createApiTestFixtures( @@ -956,7 +958,7 @@ describe("CodexSecurity preflight configuration", () => { }); expect(config).toEqual({ - model_provider: "openai", + model_provider: provider, profile: "selected", profiles: { selected: { model, model_provider: provider } }, model_providers: { [provider]: providerConfig }, @@ -993,7 +995,7 @@ describe("CodexSecurity preflight configuration", () => { }); expect(config).toEqual({ - model_provider: "openai", + model_provider: "amazon-bedrock", profile: "bedrock", profiles: { bedrock: { @@ -1011,3 +1013,63 @@ describe("CodexSecurity preflight configuration", () => { expect(JSON.stringify(config)).not.toContain("synthetic-"); }); }); + +test.each(["cloud.production", "selected", undefined] as const)( + "saved Deep runtime retains the resolved provider for profile %s", + async (profile) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const home = join(root, "home"); + const scanDir = join(root, "scan"); + const configPath = join(home, "config-preflight.toml"); + const deepScanConfigPath = join(home, "deep-scan.toml"); + await Promise.all([ + mkdir(repository), + mkdir(home), + mkdir(scanDir, { mode: 0o700 }), + ]); + const expected = profile === undefined ? "openai" : "openrouter"; + const client = new TestClient( + { + codexOverrides: { + model_provider: "openai", + model_reasoning_summary: "none", + ...(profile === undefined ? {} : { profile }), + profiles: { + [profile ?? "unselected"]: { model_provider: "openrouter" }, + }, + }, + }, + { + environment: { + OPENAI_API_KEY: "synthetic-parent-key", + OPENROUTER_API_KEY: "synthetic-provider-key", + }, + prepareRuntime: async () => ({ + ...preparedRuntime(home), + configPath, + deepScanConfigPath, + }), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: () => { + throw new Error("Synthetic parent boundary reached"); + }, + }, + ); + try { + await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( + "Synthetic parent boundary reached", + ); + const saved = parseToml(await readFile(configPath, "utf8")) as JsonObject; + expect(scanModelProvider(saved)).toBe(expected); + const worker = parseToml(await readFile(deepScanConfigPath, "utf8")); + expect((worker["worker_runtime"] as JsonObject)["model_provider"]).toBe( + expected, + ); + } finally { + await client.close(); + } + }, +); diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 9b962ac6d0..1af9e4da15 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -1579,7 +1579,7 @@ describe("CodexSecurity orchestration", () => { expect(result.cost).toMatchObject({ model: "openai.gpt-5.6-luna" }); expect(savedRecipe).toMatchObject({ config: { - model_provider: "openai", + model_provider: "amazon-bedrock", profile: "bedrock", profiles: { bedrock: { diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index db4aa721cd..c112c6e455 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -3320,3 +3320,49 @@ test.each(["other turn", "outside window"])( } }, ); + +test.each(["sdk-owner", "unbound-owner", "unknown-worker"] as const)( + "prices known completion receipts without a rollout: %s", + async (kind) => { + const home = await codexHome(); + const observed: Readonly[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + maxCostUsd: 0.003, + onCost: (cost) => observed.push(cost), + onCostLowerBound: (cost) => observed.push(cost), + }); + if (kind !== "unbound-owner") + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: [kind === "unknown-worker" ? "worker" : "owner"], + owner: { + threadId: "owner", + turnId: "turn", + startedAt: "2026-09-01T00:00:00Z", + dedicated: true, + }, + startedAt: "2026-09-01T00:00:00Z", + completedAt: null, + })); + tracker.start("owner"); + try { + const receipt = { input_tokens: 1000, output_tokens: 0 }; + if (kind === "unknown-worker") tracker.recordUsage(receipt, "worker"); + const snapshot = await tracker.stop( + kind === "unknown-worker" ? undefined : receipt, + ); + expect(tokenUsage(snapshot.usage)?.input_tokens).toBe(1000); + if (kind === "unknown-worker") { + expect(snapshot.cost).toBeNull(); + expect(observed).toHaveLength(0); + } else { + expect(snapshot.cost?.estimatedUsd).toBeCloseTo(0.004, 10); + expect(observed.at(-1)?.estimatedUsd).toBeGreaterThan(0.003); + } + } finally { + await tracker.stop(); + } + }, +); From 914f56af0da1b7d0b7ecbe2bbbe11e29b3d47aba Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 21:31:17 +0000 Subject: [PATCH 199/251] Keep Deep Scan bundle and build fixtures aligned with shared migrations --- .../mcp-app/tests/test_workbench_database.ts | 8 ++++---- plugins/codex-security/plugin-files.json | 1 + sdk/typescript/tests-ts/api-audit-admission.test.ts | 4 ++-- sdk/typescript/tests-ts/build-plugin.test.ts | 3 ++- sdk/typescript/tests-ts/deep-finalization.test.ts | 10 +++------- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_database.ts b/plugins/codex-security/mcp-app/tests/test_workbench_database.ts index 6f7b777184..b233875873 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_database.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_database.ts @@ -99,7 +99,7 @@ test("opens a private WAL database at the configured state path", async () => { assert.equal( database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get() ?.count, - 42, + migrations.length, ); assert.deepEqual(database.prepare("PRAGMA foreign_key_check").all(), []); if (process.platform !== "win32") { @@ -129,7 +129,7 @@ test("every released schema upgrades to the same current schema and remains idem const current = memory(t); applyMigrations(current); const expected = schema(current); - for (let version = 0; version <= 42; version++) { + for (const version of [0, ...migrations.map((item) => item.version)]) { const database = memory(t, version); applyMigrations(database); assert.deepEqual( @@ -243,7 +243,7 @@ test( database .prepare("SELECT MAX(version) AS version FROM schema_migrations") .get()?.version, - 42, + migrations.at(-1)?.version, ); } finally { database.close(); @@ -721,7 +721,7 @@ test("retries an upgrade when another process holds the write lock beyond the bu database .prepare("SELECT COUNT(*) AS count FROM schema_migrations") .get()?.count, - 42, + migrations.length, ); assert.equal( database.prepare("SELECT COUNT(*) AS count FROM security_targets").get() diff --git a/plugins/codex-security/plugin-files.json b/plugins/codex-security/plugin-files.json index e4a8fecc29..864ffe0724 100644 --- a/plugins/codex-security/plugin-files.json +++ b/plugins/codex-security/plugin-files.json @@ -27,6 +27,7 @@ "mcp/native/win32-x64/windows.node", "mcp/helpers.mjs", "mcp/helpers.mjs.br.part-000", + "mcp/helpers.mjs.br.part-001", "mcp/permission-profile-preflight.mjs", "mcp/server.mjs", "mcp/server.mjs.br.part-000", diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index d2a71d75bc..9362953b7b 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, expect, test } from "bun:test"; import { build } from "esbuild"; import { runScanEvents } from "../src/api.js"; -import { runWorkbench, type WorkbenchCommandOptions } from "../src/runtime.js"; +import { runWorkbench } from "../src/runtime.js"; import { TestClient } from "./support/api-client.js"; import { preparedRuntime } from "./support/api-events.js"; import { runNodePython } from "./support/python-probe.js"; @@ -471,7 +471,7 @@ for (const scenario of ["published", "interrupted", "unfinished"] as const) { }; const python = process.env["PYTHON"] ?? Bun.which("python3")!; let registration: Record; - let savedOptions: WorkbenchCommandOptions; + let savedOptions: Parameters[0]; const commands: string[] = []; const runtime = preparedRuntime(home); runtime.plugin.version = JSON.parse( diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 01aa0188f0..c44816be69 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -78,7 +78,7 @@ describe("bundled plugin build", () => { join(sdk, "node_modules"), process.platform === "win32" ? "junction" : "dir", ); - for (const name of ["schemas", "mcp-app/src"]) { + for (const name of ["schemas", "shared", "mcp-app/src"]) { await cp(new URL(name, source), join(plugin, name), { recursive: true }); } await copyFile( @@ -197,6 +197,7 @@ describe("bundled plugin build", () => { } for (const name of [ "schemas", + "shared", "native/prebuilt", "plugin-files.json", "scripts/reserved_artifact_paths.json", diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index e5cfaaa24c..ecb5953338 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -8,11 +8,7 @@ import { ScanCostLimitExceededError, ScanInterruptedError, } from "../src/errors.js"; -import { - prepareScanArtifactRestorer, - runWorkbench, - type WorkbenchCommandOptions, -} from "../src/runtime.js"; +import { prepareScanArtifactRestorer, runWorkbench } from "../src/runtime.js"; import { resumeSelectedDeepScan } from "../src/deep-scan-finalization.js"; import { TestClient } from "./support/api-client.js"; import { completedEvents, preparedRuntime } from "./support/api-events.js"; @@ -47,7 +43,7 @@ for (const boundary of ["registration", "stream-start"] as const) { ); const commands: string[][] = []; let scanId = ""; - let savedOptions: WorkbenchCommandOptions; + let savedOptions: Parameters[0]; let startedTurns = 0; const client = new TestClient( {}, @@ -233,7 +229,7 @@ for (const { "Parent turn ended before its final completion tool call", ); let scanId = ""; - let workbenchOptions: WorkbenchCommandOptions; + let workbenchOptions: Parameters[0]; let publicationFails = restart && !statusReadFault; let selectionReadLost = false; let completionReceiptLost = loseCompletionResponse; From 40cd60715acbee420ec51eeec72efb0cc26adbce Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Wed, 7 Oct 2026 23:50:46 +0000 Subject: [PATCH 200/251] Preserve accepted worker paths and retire coordinator leases through the host --- .../codex-security/mcp-app/helpers-main.ts | 3 +- plugins/codex-security/mcp-app/server.ts | 24 ++- .../mcp-app/src/deep-scan/store.ts | 28 ++- .../mcp-app/tests/build-server.ts | 1 + .../mcp-app/tests/import-module.ts | 2 + .../mcp-app/tests/test_deep_scan_store.ts | 73 +++++++ .../mcp-app/tests/test_mcp_app_smoke.ts | 2 + .../scripts/deep_scan_workbench.py | 58 +++++- .../codex-security/scripts/workbench_db.py | 13 +- .../tests/test_deep_scan_persistence.py | 191 +++++++++++++++++- .../tests/test_workbench_scan_usage.py | 2 +- .../scripts/fixtures/package-deep-scan.mjs | 6 +- sdk/typescript/tests-ts/cli.test.ts | 2 +- 13 files changed, 386 insertions(+), 19 deletions(-) diff --git a/plugins/codex-security/mcp-app/helpers-main.ts b/plugins/codex-security/mcp-app/helpers-main.ts index cfad3b2a44..8c1fbbbeaf 100644 --- a/plugins/codex-security/mcp-app/helpers-main.ts +++ b/plugins/codex-security/mcp-app/helpers-main.ts @@ -117,7 +117,8 @@ function runHelper(): void { character .split("") .map( - (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`, + (unit) => + `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`, ) .join(""), ), diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index da71f92c8d..6ceeff6c06 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -2527,6 +2527,7 @@ async function runWorkbench( selectFinalization = false, withExecutionSettings = false, signal?: AbortSignal, + releaseCoordinator = false, ): Promise { let pythonCommand: string | undefined; try { @@ -2538,6 +2539,7 @@ async function runWorkbench( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); } catch (error) { const launchError = pythonCommand @@ -2560,6 +2562,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization = false, withExecutionSettings = false, signal?: AbortSignal, + releaseCoordinator = false, ): Promise { if (WORKBENCH_COMMANDS_WITHOUT_DATABASE.has(args[0] ?? "")) { return await executeWorkbench( @@ -2570,6 +2573,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); } if (CONFIGURED_WORKBENCH_STATE_DIR) { @@ -2581,6 +2585,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); } if (fallbackWorkbenchStateDir) { @@ -2592,6 +2597,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); } if (persistentWorkbenchStateSucceeded) { @@ -2603,6 +2609,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); } return await workbenchStateSelectionLock.run(async () => { @@ -2615,6 +2622,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); } if (persistentWorkbenchStateSucceeded) { @@ -2626,6 +2634,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); } try { @@ -2637,6 +2646,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); persistentWorkbenchStateSucceeded = true; return result; @@ -2658,6 +2668,7 @@ async function executeWorkbenchWithStateSelection( selectFinalization, withExecutionSettings, signal, + releaseCoordinator, ); } }); @@ -2671,6 +2682,7 @@ async function executeWorkbench( selectFinalization = false, withExecutionSettings = false, signal?: AbortSignal, + releaseCoordinator = false, ): Promise { const userContextIndex = args.indexOf("--user-context"); const userContext = @@ -2680,11 +2692,13 @@ async function executeWorkbench( workbenchArgs.splice(userContextIndex, 2, "--user-context-stdin"); } const workbenchInput = input ?? userContext; - const internalInvocation = selectFinalization - ? "select_finalization=True" - : withExecutionSettings - ? "with_execution_settings=True" - : undefined; + const internalInvocation = releaseCoordinator + ? "release_coordinator=True" + : selectFinalization + ? "select_finalization=True" + : withExecutionSettings + ? "with_execution_settings=True" + : undefined; const pythonArgs = internalInvocation ? [ "-c", diff --git a/plugins/codex-security/mcp-app/src/deep-scan/store.ts b/plugins/codex-security/mcp-app/src/deep-scan/store.ts index f565c6a71a..7f99fe3c19 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/store.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/store.ts @@ -30,6 +30,8 @@ export type WorkbenchRunner = ( input?: string, selectFinalization?: boolean, withExecutionSettings?: boolean, + signal?: AbortSignal, + releaseCoordinator?: boolean, ) => Promise; const WORKFLOW_VERSION = "deep-security-scan/v2"; @@ -275,13 +277,31 @@ export class WorkbenchDeepScanStore { const lease = this.coordinatorLeases.get(scanId); if (!lease) return; this.coordinatorLeases.delete(scanId); - await this.writeCoordinatorHeartbeat(lease, new Date().toISOString(), true); + await lease.heartbeatWrite; + await this.enqueueWrite( + [ + "claim-deep-scan-coordinator", + "--scan-id", + scanId, + "--thread-id", + lease.input.threadId, + "--coordinator-generation", + String(lease.run.coordinatorGeneration), + ...(lease.input.handoffClaimToken + ? ["--claim-token", lease.input.handoffClaimToken] + : []), + ], + false, + undefined, + false, + false, + true, + ); } private writeCoordinatorHeartbeat( lease: { run: DeepScanRunState; heartbeatWrite?: Promise }, updatedAt: string, - released = false, ): Promise { // Heartbeats bypass SQLite writes; only writes to this lease file must settle in order. const { run } = lease; @@ -296,7 +316,6 @@ export class WorkbenchDeepScanStore { { coordinatorGeneration: run.coordinatorGeneration, updatedAt, - ...(released ? { released: true } : {}), }, ), ); @@ -591,6 +610,7 @@ export class WorkbenchDeepScanStore { input?: string, selectFinalization = false, withExecutionSettings = false, + releaseCoordinator = false, ): Promise { const operation = this.writeTail.then(async () => { try { @@ -601,6 +621,8 @@ export class WorkbenchDeepScanStore { input, selectFinalization, withExecutionSettings, + undefined, + releaseCoordinator, ); } catch (error) { const scanId = argumentValue(args, "--scan-id"); diff --git a/plugins/codex-security/mcp-app/tests/build-server.ts b/plugins/codex-security/mcp-app/tests/build-server.ts index c916ec0751..4d3fc2070b 100644 --- a/plugins/codex-security/mcp-app/tests/build-server.ts +++ b/plugins/codex-security/mcp-app/tests/build-server.ts @@ -11,6 +11,7 @@ export function buildServer(outfile: string, options: BuildOptions = {}) { external: ["fsevents"], format: "cjs", loader: { ".md": "text" }, + nodePaths: [path.join(applicationRoot, "node_modules")], logLevel: "silent", outfile, platform: "node", diff --git a/plugins/codex-security/mcp-app/tests/import-module.ts b/plugins/codex-security/mcp-app/tests/import-module.ts index efdb75f1fd..a5b0567eb5 100644 --- a/plugins/codex-security/mcp-app/tests/import-module.ts +++ b/plugins/codex-security/mcp-app/tests/import-module.ts @@ -1,10 +1,12 @@ import { build, type BuildOptions } from "esbuild"; +import path from "node:path"; export async function importModule(options: BuildOptions) { const result = await build({ bundle: true, format: "esm", platform: "node", + nodePaths: [path.resolve(import.meta.dirname, "../node_modules")], write: false, ...options, }); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.ts index 1905187516..49bb14d728 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store.ts @@ -23,6 +23,7 @@ testConfiguredMaximumDurationParsing(); await testWriteSerializationAndRecovery(); await testBeginUsesTheWriteQueue(); await testHeartbeatBypassesBlockedWriteQueue(); +await testReleaseRetiresTheOwnedLeaseThroughWorkbench(); await testOwnershipReadClearsStaleLease(); await testWorkerResponseParsing(); await testReplaceableDiscoveryFailureProtocol(); @@ -1090,3 +1091,75 @@ for (const version of [1, 99]) { "inspection preserves finalization input and version before execution compatibility checks", ); } + +async function testReleaseRetiresTheOwnedLeaseThroughWorkbench() { + const scanId = randomUUID(); + const handoffClaimToken = randomUUID(); + const scanDir = await mkdtemp(join(tmpdir(), "deep-scan-release-")); + const calls: { args: string[]; release: boolean }[] = []; + const store = new WorkbenchDeepScanStore( + async ( + args: string[], + _input?: string, + _selectFinalization?: boolean, + _withExecutionSettings?: boolean, + _signal?: AbortSignal, + releaseCoordinator = false, + ) => { + calls.push({ args, release: releaseCoordinator }); + return { + ...stateResult(scanId, { + deepScan: { coordinatorGeneration: 2, scanDir }, + }), + coordinatorDisposition: "claimed", + }; + }, + ); + try { + const input = { scanId, threadId: "thread-fixture", handoffClaimToken }; + await store.claimCoordinator(input); + const heartbeat = store.heartbeatCoordinator(input); + await store.releaseCoordinator(scanId); + const renewed = await heartbeat; + assert.equal(calls.length, 2); + assert.equal(calls[0]!.release, false); + assert.equal( + calls[1]!.release, + true, + "release must reach the authoritative host mutation", + ); + assert.deepEqual(calls[1]!.args, [ + "claim-deep-scan-coordinator", + "--scan-id", + scanId, + "--thread-id", + input.threadId, + "--coordinator-generation", + "2", + "--claim-token", + handoffClaimToken, + ]); + assert.deepEqual( + await readJson( + scanDir, + "artifacts", + "deep_discovery", + "coordinator-heartbeat-2.json", + ), + { + coordinatorGeneration: 2, + updatedAt: renewed.updatedAt, + }, + "artifact heartbeat data must not retire a persisted lease", + ); + assert.deepEqual(store.coordinatorLeaseArgs(scanId), []); + await store.releaseCoordinator(scanId); + assert.equal( + calls.length, + 2, + "a retired local lease must not release a later owner", + ); + } finally { + await rm(scanDir, { recursive: true, force: true }); + } +} diff --git a/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.ts b/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.ts index f2ae81693d..b3791b4cba 100644 --- a/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.ts +++ b/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.ts @@ -654,6 +654,8 @@ async function assertDeepScanPersistsRetryableWorkerStartupError() { await writeFile(path.join(fixtureTarget, "app", "routes.py"), "route = 1\n"); const fixtureEnvironment = { + // Keep the missing-executable fixture independent of ambient session history. + CODEX_HOME: path.join(fixtureRoot, "codex-home"), CODEX_CLI_PATH: path.join(fixtureRoot, "missing-deep-scan-codex"), CODEX_SECURITY_SCAN_ROOT: fixtureScanRoot, CODEX_SECURITY_STATE_DIR: fixtureState, diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index a03c2e4f61..f9a1da9cae 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -1042,8 +1042,6 @@ def coordinator_lease_is_live( try: heartbeat = json.loads(heartbeat_path.read_text(encoding="utf-8")) if heartbeat["coordinatorGeneration"] == run["coordinator_generation"]: - if heartbeat.get("released") is True: - return False heartbeat_time = max(heartbeat_time, _parse_timestamp(heartbeat["updatedAt"])) except (OSError, KeyError, TypeError, ValueError): pass @@ -1144,6 +1142,46 @@ def claim_deep_scan_coordinator_locked( } +def release_deep_scan_coordinator( + connection: sqlite3.Connection, args: argparse.Namespace +) -> dict[str, Any]: + scan_id = require_uuid(args.scan_id, "scan-id") + with dependencies().scan_completion_lock(scan_id): + connection.execute("BEGIN IMMEDIATE") + with connection: + scan, _ = require_owned_scan(connection, scan_id, args.thread_id) + require_current_continuation( + scan, + args.claim_token, + error_message="Deep Scan orchestration is owned by another continuation.", + ) + run = require_deep_scan_run(connection, scan_id) + require_current_coordinator(run, args) + if scan["status"] != "running" or run["status"] not in {"running", "succeeded"}: + return deep_scan_result(connection, scan_id) + expired_at = ( + _parse_timestamp(dependencies().now()) + - timedelta(seconds=DEEP_SCAN_COORDINATOR_LEASE_SECONDS) + ).isoformat() + # The private host invocation retires its lease in the database; + # model-writable heartbeat fields cannot release a live owner. + write_scan_local_bytes( + Path(scan["scan_dir"]), + f"artifacts/deep_discovery/coordinator-heartbeat-{run['coordinator_generation']}.json", + json.dumps( + { + "coordinatorGeneration": run["coordinator_generation"], + "updatedAt": expired_at, + } + ).encode(), + ) + connection.execute( + "UPDATE deep_scan_runs SET updated_at = ? WHERE scan_id = ?", + (expired_at, scan_id), + ) + return deep_scan_result(connection, scan_id) + + def recover_expired_coordinator( connection: sqlite3.Connection, run: sqlite3.Row, timestamp: str ) -> None: @@ -1302,7 +1340,13 @@ def snapshot_accepted_result(scan: sqlite3.Row, worker: sqlite3.Row) -> tuple[st scan, worker["result_manifest_path"], "Accepted worker result", kind="file" ) scan_dir = Path(scan["scan_dir"]) - contents = Path(source).read_bytes() + with os.fdopen( + open_scan_local_file_descriptor( + scan_dir, Path(source).relative_to(scan_dir).as_posix(), "Accepted worker result" + ), + "rb", + ) as handle: + contents = handle.read() semantic = json.loads(contents) if isinstance(semantic, dict): semantic.pop("handoffClaimToken", None) @@ -1317,7 +1361,13 @@ def snapshot_accepted_result(scan: sqlite3.Row, worker: sqlite3.Row) -> tuple[st candidates = [scan_dir / head] if head else sorted(directory.glob("*.json")) for checkpoint in candidates: safe = deep_scan_path(scan, str(checkpoint), "Accepted worker checkpoint", kind="file") - checkpoint_bytes = Path(safe).read_bytes() + with os.fdopen( + open_scan_local_file_descriptor( + scan_dir, Path(safe).relative_to(scan_dir).as_posix(), "Accepted worker checkpoint" + ), + "rb", + ) as handle: + checkpoint_bytes = handle.read() if json.loads(checkpoint_bytes) == semantic: return safe, hashlib.sha256(checkpoint_bytes).hexdigest() if head: diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index d703b3816f..0e20f783e7 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -3207,7 +3207,12 @@ def read_json_object(path: Path) -> dict[str, Any]: _WORKBENCH_PUBLICATION_CONTEXT = _WORKBENCH_DB_CONTEXT = SimpleNamespace(**globals()) -def main(*, select_finalization: bool = False, with_execution_settings: bool = False) -> None: +def main( + *, + select_finalization: bool = False, + with_execution_settings: bool = False, + release_coordinator: bool = False, +) -> None: # Workbench callers send UTF-8 even when Windows uses a legacy code page. sys.stdin.reconfigure(encoding="utf-8") args = parse_args(__doc__, execution_settings=with_execution_settings) @@ -3278,7 +3283,11 @@ def main(*, select_finalization: bool = False, with_execution_settings: bool = F elif args.command == "get-deep-scan": result = deep_scan.get_deep_scan(connection, args) elif args.command == "claim-deep-scan-coordinator": - result = deep_scan.claim_deep_scan_coordinator(connection, args) + result = ( + deep_scan.release_deep_scan_coordinator(connection, args) + if release_coordinator + else deep_scan.claim_deep_scan_coordinator(connection, args) + ) elif args.command == "upsert-deep-scan-worker": result = deep_scan.upsert_deep_scan_worker(connection, args) elif args.command == "claim-deep-scan-dedup": diff --git a/plugins/codex-security/tests/test_deep_scan_persistence.py b/plugins/codex-security/tests/test_deep_scan_persistence.py index 22f81786d4..c2c8584276 100644 --- a/plugins/codex-security/tests/test_deep_scan_persistence.py +++ b/plugins/codex-security/tests/test_deep_scan_persistence.py @@ -281,7 +281,9 @@ def test_native_usage_keeps_replaced_failed_canceled_attempts_and_descendants( with sqlite3.connect(state / "workbench.sqlite3") as connection: connection.row_factory = sqlite3.Row scan = connection.execute("SELECT * FROM scans WHERE id = ?", (run["scanId"],)).fetchone() - timestamp = datetime.fromisoformat(scan["started_at"]) + timedelta(microseconds=1) + timestamp = datetime.fromisoformat(scan["started_at"].replace("Z", "+00:00")) + timedelta( + microseconds=1 + ) context = _event( timestamp, "turn_context", {"turn_id": "fixture-turn", "model": "gpt-5.6-sol"} ) @@ -423,3 +425,190 @@ def test_acceptance_rejects_mutable_result_behind_checkpoint_head(tmp_path: Path upsert_worker(state, home, **mutation, status="succeeded", result_path=result) assert "does not match its current checkpoint head" in failure.value.stderr assert checkpoint.read_bytes() == content + + +@pytest.mark.parametrize("replacement", ["result", "checkpoint", "ordinary"]) +def test_accepted_result_does_not_reopen_replaced_path( + tmp_path, workbench_api, monkeypatch, replacement +): + from finalize_scan_contract import ContractError + + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + run = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + scan_id, scan_dir = run["scanId"], Path(run["scanDir"]) + worker_id, prompt, artifacts, result = dispatch_discovery_worker( + state, home, scan_id=scan_id, scan_dir=scan_dir, name="discovery-1", succeed=False + ) + payload = { + "scanId": scan_id, + "complete": False, + "findings": [], + "coverage": { + "completeness": "complete", + "surfaces": [], + "explicitExclusions": [], + "deferred": [], + }, + } + original_bytes = json.dumps(payload).encode() + result.write_bytes(original_bytes) + checkpoint = artifacts / "checkpoints" / "accepted.json" + if replacement == "checkpoint": + checkpoint.parent.mkdir(parents=True) + checkpoint.write_bytes(original_bytes) + external = tmp_path / "outside-scan.json" + external.write_text(json.dumps(payload, indent=2) + "\n") + deep = sys.modules["deep_scan_workbench"] + monkeypatch.setattr( + deep, + "_dependencies", + SimpleNamespace( + **workbench_api, + preserve_stopped_results=workbench_api["preserve_stopped_results_after_transition"], + ), + ) + validate = deep.deep_scan_path + swapped = [] + + def validate_then_replace(scan, value, label, *, kind): + checked = validate(scan, value, label, kind=kind) + selected = ( + "Accepted worker result" if replacement == "result" else "Accepted worker checkpoint" + ) + if replacement != "ordinary" and label == selected: + path = Path(checked) + path.unlink() + path.symlink_to(external) + swapped.append(label) + return checked + + monkeypatch.setattr(deep, "deep_scan_path", validate_then_replace) + args = SimpleNamespace( + scan_id=scan_id, + worker_id=worker_id, + kind="discovery", + status="succeeded", + prompt_path=str(prompt), + artifact_dir=str(artifacts), + result_manifest_path=str(result), + attempt=1, + sdk_thread_id="synthetic-worker", + error_message=None, + replaceable_failure_kind=None, + coordinator_generation=None, + ) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.row_factory = sqlite3.Row + if replacement == "ordinary": + accepted = deep.upsert_deep_scan_worker(connection, args)["deepScan"] + attempt = accepted["attempts"][-1] + assert Path(attempt["acceptedResultPath"]).read_bytes() == original_bytes + assert ( + attempt["acceptedResultSha256"] + == __import__("hashlib").sha256(original_bytes).hexdigest() + ) + else: + with pytest.raises(ContractError, match="inside the scan directory"): + deep.upsert_deep_scan_worker(connection, args) + assert swapped + assert ( + connection.execute( + "SELECT status FROM deep_scan_workers WHERE id = ?", (worker_id,) + ).fetchone()[0] + == "running" + ) + assert ( + connection.execute( + "SELECT accepted_result_path FROM deep_scan_attempts WHERE worker_id = ?", + (worker_id,), + ).fetchone()[0] + is None + ) + + +@pytest.mark.parametrize("heartbeat", ["absent", "ordinary", "released", "other-generation"]) +def test_artifact_release_does_not_override_current_persisted_lease(tmp_path, heartbeat): + from test_workbench_deep_scan import claim_deep_scan_coordinator + + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + initial = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + claimed = claim_deep_scan_coordinator(state, home, initial["scanId"])["deepScan"] + assert claimed["coordinatorGeneration"] == 2 + if heartbeat != "absent": + record = { + "coordinatorGeneration": 3 if heartbeat == "other-generation" else 2, + "updatedAt": claimed["updatedAt"], + } + if heartbeat in {"released", "other-generation"}: + record["released"] = True + path = ( + Path(claimed["scanDir"]) + / "artifacts" + / "deep_discovery" + / "coordinator-heartbeat-2.json" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(record)) + rejoined = claim_deep_scan_coordinator(state, home, initial["scanId"]) + assert rejoined["coordinatorDisposition"] == "observing" + assert rejoined["deepScan"]["coordinatorGeneration"] == 2 + + +@pytest.mark.parametrize("release", ["normal", "repeat", "wrong-owner", "stale-generation"]) +def test_host_release_preserves_lease_owner_and_generation(tmp_path, release): + import os + + from test_workbench_deep_scan import claim_deep_scan_coordinator + from workbench_test_support import SCRIPT + + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + initial = begin_target_scan(state, home, target, tmp_path / "scans")["deepScan"] + scan_id = initial["scanId"] + claimed = claim_deep_scan_coordinator(state, home, scan_id)["deepScan"] + assert claimed["coordinatorGeneration"] == 2 + + def host_release(thread="thread-deep-scan", generation=2): + return subprocess.run( + [ + sys.executable, + "-c", + "import runpy,sys; script=sys.argv.pop(1); runpy.run_path(script)['main'](release_coordinator=True)", + str(SCRIPT), + "claim-deep-scan-coordinator", + "--scan-id", + scan_id, + "--thread-id", + thread, + "--coordinator-generation", + str(generation), + ], + capture_output=True, + text=True, + env={**os.environ, "CODEX_SECURITY_STATE_DIR": str(state), "CODEX_HOME": str(home)}, + ) + + if release == "wrong-owner": + rejected = host_release("different-synthetic-owner") + assert rejected.returncode != 0 + observed = claim_deep_scan_coordinator(state, home, scan_id) + assert observed["coordinatorDisposition"] == "observing" + assert observed["deepScan"]["coordinatorGeneration"] == 2 + return + accepted = host_release() + assert accepted.returncode == 0, accepted.stderr + if release == "repeat": + repeated = host_release() + assert repeated.returncode == 0, repeated.stderr + adopted = claim_deep_scan_coordinator(state, home, scan_id) + assert adopted["coordinatorDisposition"] == "adopted" + assert adopted["deepScan"]["coordinatorGeneration"] == 3 + if release == "stale-generation": + rejected = host_release() + assert rejected.returncode != 0 + assert "newer generation" in rejected.stderr + observed = claim_deep_scan_coordinator(state, home, scan_id) + assert observed["coordinatorDisposition"] == "observing" + assert observed["deepScan"]["coordinatorGeneration"] == 3 diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 9f8b52500b..fc265b6705 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -766,7 +766,7 @@ def complete(index: int) -> dict[str, Any]: target, scan_id, scan_dir, - datetime.fromisoformat(deep["createdAt"]), + datetime.fromisoformat(deep["createdAt"].replace("Z", "+00:00")), environment, "deep", ) diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index 1ebb3ab814..4dd8cf3c88 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -14,6 +14,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { createRequire } from "node:module"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; @@ -21,6 +22,7 @@ import { startRpc } from "./package-rpc.mjs"; import { packageSmokeTimeouts } from "../package-smoke-timeouts.mjs"; const installedRoot = await realpath(process.argv[2]); +const installedRequire = createRequire(join(installedRoot, "package.json")); const root = await realpath( await mkdtemp(join(tmpdir(), "package deep % fixture-")), ); @@ -47,7 +49,9 @@ try { join(root, name), (await readFile(new URL(name, import.meta.url), "utf8")).replace( '"smol-toml"', - JSON.stringify(import.meta.resolve("smol-toml")), + JSON.stringify( + pathToFileURL(installedRequire.resolve("smol-toml")).href, + ), ), ); } else { diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index dd5eb6d75b..54451748d2 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -3116,7 +3116,7 @@ describe("CLI", () => { ), ).toBe(0); expect(rerunConfig?.codexOverrides).toMatchObject({ - model_provider: "openai", + model_provider: provider, profile: "selected", profiles: { selected: { model, model_provider: provider } }, model_providers: { [provider]: providerConfig }, From 30d2a68bf9dea14d16c4b78f59f572e8dc2b684b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 00:33:41 +0000 Subject: [PATCH 201/251] fix: preserve native provider definitions during Deep Scan recovery --- .../src/deep-scan/recovery-settings.ts | 45 +++++++------ .../mcp-app/tests/test_deep_scan_executor.ts | 66 +++++++++++++------ .../test_deep_scan_recovery_settings.mjs | 45 +++++++++---- 3 files changed, 105 insertions(+), 51 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 2cee397de0..3b288e42cc 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -357,6 +357,28 @@ export function restoredDeepScanWorkerSettings( ["*", "?", "[", "]"].some((character) => path.includes(character)), ), ); + const config = scanPreflightCodexConfig({ + ...(settings.model === undefined ? {} : { model: settings.model }), + ...(settings.reasoningEffort === undefined + ? {} + : { model_reasoning_effort: settings.reasoningEffort }), + ...(settings.modelProvider === undefined + ? {} + : { model_provider: settings.modelProvider }), + ...(settings.reasoningSummary === undefined + ? {} + : { model_reasoning_summary: settings.reasoningSummary }), + ...(settings.serviceTier === undefined + ? {} + : { service_tier: settings.serviceTier }), + ...(settings.providerConfig === undefined + ? {} + : { model_providers: settings.providerConfig }), + }); + // Other provider definitions remain in the bound native home. Catalog + // defaults would override its customized endpoint or authentication key. + if (settings.modelProvider !== "amazon-bedrock") + delete config.model_providers; return { get nativeProfileHome() { return environment().CODEX_HOME || join(homedir(), ".codex"); @@ -407,24 +429,7 @@ export function restoredDeepScanWorkerSettings( ), ); }, - config: scanPreflightCodexConfig({ - ...(settings.model === undefined ? {} : { model: settings.model }), - ...(settings.reasoningEffort === undefined - ? {} - : { model_reasoning_effort: settings.reasoningEffort }), - ...(settings.modelProvider === undefined - ? {} - : { model_provider: settings.modelProvider }), - ...(settings.reasoningSummary === undefined - ? {} - : { model_reasoning_summary: settings.reasoningSummary }), - ...(settings.serviceTier === undefined - ? {} - : { service_tier: settings.serviceTier }), - ...(settings.providerConfig === undefined - ? {} - : { model_providers: settings.providerConfig }), - }) as NonNullable, + config: config as NonNullable, }, }; } @@ -432,8 +437,8 @@ export function restoredDeepScanWorkerSettings( function executionSettings( value: DeepScanExecutionSettings, ): DeepScanExecutionSettings { - // Catalog provider definitions are reconstructed by the existing launch - // projection. Only Bedrock's per-scan AWS selectors need persistence. + // Only Bedrock's per-scan AWS selectors need persistence. Other provider + // definitions remain in the original native home. const provider = value.modelProvider === "amazon-bedrock" ? (scanPreflightCodexConfig({ diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index e9e54a2f2f..784f2cf43b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -3498,24 +3498,20 @@ async function testIsolatedReconstructedWorkers() { const provider = launch.argv.filter((argument: string) => /^model_providers[.=]/u.test(argument), ); - assert.ok( - provider.length > 0, - "both preflight and worker launch receive provider configuration", - ); - const providers = parseToml(provider.join("\n")) - .model_providers as Record>; - if (scan.expectedProvider) + if (scan.expectedProvider) { + const providers = parseToml(provider.join("\n")) + .model_providers as Record>; assert.deepEqual( JSON.parse(JSON.stringify(providers)), scan.expectedProvider, ); - else - assert.deepEqual(Object.keys(providers.openrouter).sort(), [ - "base_url", - "env_key", - "name", - "wire_api", - ]); + } else { + assert.deepEqual( + provider, + [], + "restored external providers inherit the recorded native home", + ); + } assert.equal( workerPermissionProfileOverride(launch.argv).includes( "glob_scan_max_depth", @@ -3781,19 +3777,27 @@ async function testRestoredPrivateProfileHomes() { name === "same" ? originalHome : path.join(fixture.root, name, "current"); await mkdir(originalHome, { recursive: true }); await mkdir(currentHome, { recursive: true }); + const provider = + name === "first" + ? "openrouter" + : name === "second" + ? "fireworks" + : "fixture-provider"; const config = { model: "fixture-model", - model_provider: "fixture-provider", + model_provider: provider, model_providers: { - "fixture-provider": { + [provider]: { name: "Fixture", + base_url: `https://${name}.example.invalid/v1`, + env_key: `SYNTHETIC_${name.toUpperCase()}_KEY`, wire_api: "responses", requires_openai_auth: true, http_headers: { Authorization: `synthetic-${name}-profile-header` }, }, }, }; - const configPath = path.join(fixture.root, name, "preflight.toml"); + const configPath = path.join(originalHome, "config.toml"); await writeFile(configPath, stringifyToml(config)); const sandbox = { filesystemDenies: [...new Set([originalHome, currentHome])], @@ -3803,7 +3807,6 @@ async function testRestoredPrivateProfileHomes() { sandbox, { CODEX_HOME: originalHome, - CODEX_SECURITY_CONFIG_PATH: configPath, CODEX_CLI_PATH: process.execPath, }, ); @@ -3859,6 +3862,8 @@ async function testRestoredPrivateProfileHomes() { }); owners.push({ name, + provider, + config, originalHome, currentHome, current, @@ -3886,7 +3891,7 @@ async function testRestoredPrivateProfileHomes() { syncBuiltinESMExports(); try { for (const kind of ["discovery", "dedup"] as const) { - await Promise.all( + const launches: PromiseSettledResult[] = await Promise.allSettled( owners.map(async (owner) => { const request = workerRequest(owner.promptPath, fixture.root, { kind, @@ -3903,6 +3908,22 @@ async function testRestoredPrivateProfileHomes() { ); const child = await readJson(owner.markerPath); assert.equal(child.codexHome, owner.originalHome); + const profile = parseToml(child.profileContents); + assert.deepEqual( + JSON.parse(JSON.stringify(profile.model_providers)), + owner.config.model_providers, + ); + const preflight = await readJson( + owner.environment.FAKE_CODEX_PREFLIGHT_MARKER, + ); + assertConfigOverrides(child.argv, { + [`model_providers.${owner.provider}.base_url`]: undefined, + [`model_providers.${owner.provider}.env_key`]: undefined, + }); + assertConfigOverrides(preflight.argv, { + [`model_providers.${owner.provider}.base_url`]: undefined, + [`model_providers.${owner.provider}.env_key`]: undefined, + }); assert.match( child.profileContents, new RegExp(`synthetic-${owner.name}-profile-header`), @@ -3922,6 +3943,13 @@ async function testRestoredPrivateProfileHomes() { await readFile(owner.current.path); }), ); + assert.deepEqual( + launches.flatMap((launch) => + launch.status === "rejected" ? [String(launch.reason)] : [], + ), + [], + `native provider selections survive ${kind} child launches`, + ); } assert.equal(owners[0].recreated.length, 0); assert.equal( diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index d6b3ab5f76..65f52dea71 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -241,26 +241,47 @@ access_key_id = "synthetic-secret" await loadSettings(providerDir, boundRun(selected)), { filesystemDenies: [] }, ).codexOptions.config.model_providers; - if (expectedProvider) assert.deepEqual(restoredProvider, expectedProvider); - else - assert.deepEqual(Object.keys(restoredProvider[modelProvider]).sort(), [ - "base_url", - "env_key", - "name", - "wire_api", - ]); + assert.deepEqual(restoredProvider, expectedProvider); assert.equal(await readFile(path, "utf8"), bytes); - // Older snapshots can contain catalog definitions. Reading them must not - // rewrite their bytes or prevent the existing launch projection. + await writeFile( + join(root, "config.toml"), + `model_provider = ${JSON.stringify(modelProvider)} +[model_providers.${modelProvider}] +base_url = "https://native.example.invalid/v1" +env_key = "SYNTHETIC_NATIVE_KEY" +[model_providers.${modelProvider}.aws] +region = "us-west-2" +profile = "fixture-profile" +`, + ); + const native = await captureSettings( + {}, + { filesystemDenies: [] }, + { CODEX_CLI_PATH: process.execPath, CODEX_HOME: root }, + ); + const nativeConfig = restoreSettings(native, { filesystemDenies: [] }) + .codexOptions.config; + assert.equal(nativeConfig.model_provider, modelProvider); + assert.deepEqual(nativeConfig.model_providers, expectedProvider); + // Older snapshots can contain catalog definitions. Reading them keeps + // their bytes intact and still inherits the original native definition. if (!expectedProvider) { + const legacyProvider = { + [modelProvider]: { + name: "Fixture", + base_url: "https://catalog.example.invalid/v1", + env_key: "SYNTHETIC_CATALOG_KEY", + wire_api: "responses", + }, + }; await writeSnapshot(providerDir, { ...selected, - providerConfig: restoredProvider, + providerConfig: legacyProvider, }); const legacyBytes = await readFile(path, "utf8"); const legacy = await loadSettings( providerDir, - boundRun({ ...selected, providerConfig: restoredProvider }), + boundRun({ ...selected, providerConfig: legacyProvider }), ); assert.equal(legacy.providerConfig, undefined); assert.deepEqual( From 08c4369fcc96024cad9f0bedbeeba9ec69266b16 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 01:09:40 +0000 Subject: [PATCH 202/251] test(deep): retain resolved provider controls within source contract --- .../mcp-app/tests/assertions.ts | 87 +++++++++++ .../mcp-app/tests/test_deep_scan_executor.ts | 139 +++++------------- .../tests-ts/api-provider-isolation.test.ts | 3 +- 3 files changed, 124 insertions(+), 105 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/assertions.ts b/plugins/codex-security/mcp-app/tests/assertions.ts index abb186e4f8..eebdce2185 100644 --- a/plugins/codex-security/mcp-app/tests/assertions.ts +++ b/plugins/codex-security/mcp-app/tests/assertions.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { parse as parseToml } from "smol-toml"; interface ToolResponse { error?: { message?: string }; @@ -23,3 +24,89 @@ export function assertFlagPair( assert.notEqual(index, -1, `missing ${flag}`); assert.equal(args[index + 1], value); } + +export function assertReadOnlyWorkerPolicy(args: readonly string[]) { + assert.equal(args.includes("--sandbox"), false); + assert.equal(args.includes("--add-dir"), false); + assert.deepEqual( + args.filter((arg: string) => arg.startsWith("approval_policy=")), + ['approval_policy="never"'], + ); + assert.equal( + args.some((arg: string) => arg.includes("network_access")), + false, + ); + const override = workerPermissionProfileOverride(args); + assertConfigOverrides(args, { + "permissions.codex_security_deep_scan_worker.extends": ":read-only", + "permissions.codex_security_deep_scan_worker.filesystem.:root": "read", + "permissions.codex_security_deep_scan_worker.network.enabled": false, + }); + assert.equal(override.includes('"write"'), false); +} + +export function workerPermissionProfileOverride(args: readonly string[]) { + assert.deepEqual( + args.filter((arg: string) => arg.startsWith("default_permissions=")), + ['default_permissions="codex_security_deep_scan_worker"'], + ); + const overrides = args.filter((arg: string) => + arg.startsWith("permissions.codex_security_deep_scan_worker="), + ); + assert.equal(overrides.length, 1); + return overrides[0]; +} + +export function assertWorkerSubagentPolicy( + args: readonly string[], + subagents: number, +) { + assertConfigOverrides(args, { + "features.multi_agent_v2.enabled": false, + "features.multi_agent_v2.max_concurrent_threads_per_session": subagents + 1, + "features.multi_agent": undefined, + "features.code_mode.excluded_tool_namespaces": undefined, + ...(subagents === 0 + ? { + "agents.max_threads": undefined, + "features.enable_fanout": false, + "features.code_mode.enabled": undefined, + } + : { + "agents.max_threads": subagents, + "features.enable_fanout": undefined, + }), + }); + assert.equal(args.includes("features.multi_agent_v2.enabled=true"), false); +} + +export function nativeConfigOverrides(args: readonly string[]) { + return args.flatMap((arg, index) => + arg === "--config" || arg === "-c" ? [args[index + 1]] : [], + ); +} + +export function assertConfigOverrides( + args: readonly string[], + values: Record, +) { + const overrides = nativeConfigOverrides(args).map((value) => + parseToml(value), + ); + for (const [key, value] of Object.entries(values)) { + const supplied = overrides.map((config) => + key + .split(".") + .reduce( + (current, part) => + (current as Record | undefined)?.[part], + config, + ), + ); + assert.deepEqual( + supplied.findLast((item) => item !== undefined), + value, + key, + ); + } +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 5eddbc2d4b..83ef3c76a8 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -1,6 +1,13 @@ import { rm } from "node:fs/promises"; import { readJson, writeJson } from "./support/json.ts"; -import { assertFlagPair } from "./assertions.ts"; +import { + assertConfigOverrides, + assertFlagPair, + assertReadOnlyWorkerPolicy, + assertWorkerSubagentPolicy, + nativeConfigOverrides, + workerPermissionProfileOverride, +} from "./assertions.ts"; import { createTemporaryDirectories } from "./support/temporary-directories.ts"; import { mock } from "node:test"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; @@ -2908,97 +2915,11 @@ if (stdin.includes('COMPLETE_THEN_HANG')) { setInterval(() => {}, 1_000); await return { root, markerPath, preflightMarkerPath, executablePath: scriptPath }; } -function assertReadOnlyWorkerPolicy(args: readonly string[]) { - assert.equal(args.includes("--sandbox"), false); - assert.equal(args.includes("--add-dir"), false); - assert.deepEqual( - args.filter((arg: string) => arg.startsWith("approval_policy=")), - ['approval_policy="never"'], - ); - assert.equal( - args.some((arg: string) => arg.includes("network_access")), - false, - ); - const override = workerPermissionProfileOverride(args); - assertConfigOverrides(args, { - "permissions.codex_security_deep_scan_worker.extends": ":read-only", - "permissions.codex_security_deep_scan_worker.filesystem.:root": "read", - "permissions.codex_security_deep_scan_worker.network.enabled": false, - }); - assert.equal(override.includes('"write"'), false); -} - -function workerPermissionProfileOverride(args: readonly string[]) { - assert.deepEqual( - args.filter((arg: string) => arg.startsWith("default_permissions=")), - ['default_permissions="codex_security_deep_scan_worker"'], - ); - const overrides = args.filter((arg: string) => - arg.startsWith("permissions.codex_security_deep_scan_worker="), - ); - assert.equal(overrides.length, 1); - return overrides[0]; -} - -function assertWorkerSubagentPolicy( - args: readonly string[], - subagents: number, -) { - assertConfigOverrides(args, { - "features.multi_agent_v2.enabled": false, - "features.multi_agent_v2.max_concurrent_threads_per_session": subagents + 1, - "features.multi_agent": undefined, - "features.code_mode.excluded_tool_namespaces": undefined, - ...(subagents === 0 - ? { - "agents.max_threads": undefined, - "features.enable_fanout": false, - "features.code_mode.enabled": undefined, - } - : { - "agents.max_threads": subagents, - "features.enable_fanout": undefined, - }), - }); - assert.equal(args.includes("features.multi_agent_v2.enabled=true"), false); -} - function restoreEnv(name: string, value: string | undefined) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } -function nativeConfigOverrides(args: readonly string[]) { - return args.flatMap((arg, index) => - arg === "--config" || arg === "-c" ? [args[index + 1]] : [], - ); -} - -function assertConfigOverrides( - args: readonly string[], - values: Record, -) { - const overrides = nativeConfigOverrides(args).map((value) => - parseToml(value), - ); - for (const [key, value] of Object.entries(values)) { - const supplied = overrides.map((config) => - key - .split(".") - .reduce( - (current, part) => - (current as Record | undefined)?.[part], - config, - ), - ); - assert.deepEqual( - supplied.findLast((item) => item !== undefined), - value, - key, - ); - } -} - async function withWorkerFixture( action: ( fixture: Awaited>, @@ -3608,7 +3529,6 @@ async function testWorkerProviderSelection() { delete process.env.CODEX_API_KEY; const configPath = path.join(fixture.root, "scan config.toml"); const promptPath = path.join(fixture.root, "prompt.md"); - await writeFile(configPath, 'model_provider = "fixture-provider"\n'); await writeFile(promptPath, "fixture provider selection"); process.env.CODEX_CLI_PATH = process.execPath; process.env.CODEX_SECURITY_CONFIG_PATH = configPath; @@ -3626,22 +3546,35 @@ async function testWorkerProviderSelection() { options, )) as unknown as typeof childProcess.spawn; syncBuiltinESMExports(); - const executor = new CodexSdkWorkerExecutor({ - parentSandbox: trustedParentSandbox, - }); - for (const kind of ["discovery", "dedup"]) { - await executor.run({ - kind, - promptPath, - workingDirectory: fixture.root, - subagents: 0, - signal: new AbortController().signal, + for (const config of [ + { model_provider: "fixture-provider" }, + { + profile: "selected.profile", + profiles: { + "selected.profile": { model_provider: "fixture-provider" }, + }, + }, + ]) { + await writeFile(configPath, stringifyToml(config)); + const executor = new CodexSdkWorkerExecutor({ + parentSandbox: trustedParentSandbox, }); - const invocation = JSON.parse(await readFile(fixture.markerPath, "utf8")); - assert.equal( - invocation.argv.includes('model_provider="fixture-provider"'), - true, - ); + for (const kind of ["discovery", "dedup"]) { + await executor.run({ + kind, + promptPath, + workingDirectory: fixture.root, + subagents: 0, + signal: new AbortController().signal, + }); + const invocation = JSON.parse( + await readFile(fixture.markerPath, "utf8"), + ); + assert.equal( + invocation.argv.includes('model_provider="fixture-provider"'), + true, + ); + } } } finally { childProcess.spawn = originalSpawn; diff --git a/sdk/typescript/tests-ts/api-provider-isolation.test.ts b/sdk/typescript/tests-ts/api-provider-isolation.test.ts index 5c92302d86..3163795d05 100644 --- a/sdk/typescript/tests-ts/api-provider-isolation.test.ts +++ b/sdk/typescript/tests-ts/api-provider-isolation.test.ts @@ -723,7 +723,7 @@ const legacyScanCases: Array< ], ), [ - "deep with a filtered profile and readable snapshot", + "deep with a resolved dotted profile and readable snapshot", "deep", { profile: "selected.profile", @@ -733,7 +733,6 @@ const legacyScanCases: Array< capability: true, inherited: "synthetic.system", reads: true, - rejects: true, }, ], ]; From 677e1398e669edbd6d3fd111cfb4e08da2df7cc4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 01:58:16 +0000 Subject: [PATCH 203/251] test: align provider and bundled resolver fixtures with current runtime --- sdk/typescript/tests-ts/api-provider-isolation.test.ts | 2 +- sdk/typescript/tests-ts/deep-scan-windows-executable.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/tests-ts/api-provider-isolation.test.ts b/sdk/typescript/tests-ts/api-provider-isolation.test.ts index 3163795d05..2e07d7727c 100644 --- a/sdk/typescript/tests-ts/api-provider-isolation.test.ts +++ b/sdk/typescript/tests-ts/api-provider-isolation.test.ts @@ -732,7 +732,7 @@ const legacyScanCases: Array< { capability: true, inherited: "synthetic.system", - reads: true, + reads: false, }, ], ]; diff --git a/sdk/typescript/tests-ts/deep-scan-windows-executable.test.ts b/sdk/typescript/tests-ts/deep-scan-windows-executable.test.ts index 0e95ea9bcf..0b5a8947b0 100644 --- a/sdk/typescript/tests-ts/deep-scan-windows-executable.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-windows-executable.test.ts @@ -17,7 +17,7 @@ import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; async function bundledResolver() { const runtime = await loadBundledRuntime(); const start = runtime.indexOf("function resolveCodexPath("); - const end = runtime.indexOf("\n// server.ts", start); + const end = runtime.indexOf("\n// ", start); expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); const source = runtime.slice(start, end); From 120d2abb50c0963e2b6f75c5c8f42c544f7cce89 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 03:44:40 +0000 Subject: [PATCH 204/251] Keep pending parent scan publication cancellable --- .../mcp-app/src/deep-scan/coordinator.ts | 19 +- .../tests/test_deep_scan_cancel_response.ts | 3 + ..._deep_scan_selected_finalization_cancel.ts | 292 ++++++++++++++++++ .../mcp-app/tests/test_mcp_app_smoke.ts | 6 +- .../mcp-app/tests/test_workbench_process.ts | 3 + 5 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_selected_finalization_cancel.ts diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 9c68f858a3..f00355df78 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -122,6 +122,7 @@ export class DeepScanCoordinator { }; private started = false; private terminal = false; + private parentPublicationPending = false; private canceled = false; private externallyFailed = false; private setupComplete = false; @@ -255,7 +256,10 @@ export class DeepScanCoordinator { reason: string, persistCancellation: () => Promise, ): Promise { - if (this.terminal || this.state.status !== "running") + if ( + this.terminal || + (this.state.status !== "running" && !this.parentPublicationPending) + ) return await this.settled(); if (!this.cancellationPersistence) { let resolve!: () => void; @@ -513,10 +517,15 @@ export class DeepScanCoordinator { finish: (input) => this.options.store.finish(input), }); if (this.canceled || this.externallyFailed) return; - await this.options.onFinalized?.( - cloneState(this.state), - this.publicationAbortController.signal, - ); + this.parentPublicationPending = true; + try { + await this.options.onFinalized?.( + cloneState(this.state), + this.publicationAbortController.signal, + ); + } finally { + this.parentPublicationPending = false; + } if (this.canceled || this.externallyFailed) return; } private stopLocally(): boolean { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_cancel_response.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_cancel_response.ts index d5dde3b96e..c1fd476a70 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_cancel_response.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_cancel_response.ts @@ -101,6 +101,9 @@ try { new URL("../server.ts", import.meta.url).href, ), }, + banner: { + js: `import { createRequire as fixtureCreateRequire } from "node:module"; const require = fixtureCreateRequire(${JSON.stringify(new URL("../server.ts", import.meta.url).href)});`, + }, loader: { ".md": "text" }, })) as CancelResponseModule; const server = createCodexSecurityServer(); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_finalization_cancel.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_finalization_cancel.ts new file mode 100644 index 0000000000..267dc04cde --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_finalization_cancel.ts @@ -0,0 +1,292 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { spawn } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { temporaryDirectory } from "./support/temporary-directories.ts"; +import { importModule } from "./import-module.ts"; +import type { ScanDraftInput } from "../src/artifact-scan-draft.ts"; +import type { DeepScanRunState } from "../src/deep-scan/types.ts"; +import type { DeepScanPublication } from "../src/artifact-scan-draft.ts"; +const appRoot = path.resolve(import.meta.dirname, ".."); +const pluginRoot = path.resolve(appRoot, ".."); +const workbenchPath = path.join(pluginRoot, "scripts/workbench_db.py"); +const { + DeepScanCoordinatorRegistry, + WorkbenchDeepScanStore, + createScanArtifactContext, + recordCodexSecurityScanDraftViaWorkbench, + readSelectedDeepScanDraft, + createDeepScanArtifacts, + captureDeepScanExecutionSettings, +} = await importModule({ + stdin: { + contents: `export { captureDeepScanExecutionSettings } from "./src/deep-scan/recovery-settings.ts"; + export { readSelectedDeepScanDraft } from "./src/deep-scan/finalization.ts"; + export { createDeepScanArtifacts } from "./src/deep-scan/artifacts.ts"; + export { DeepScanCoordinatorRegistry } from "./src/deep-scan/registry.ts"; + export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts"; + export { createScanArtifactContext } from "./src/artifact-context.ts"; + export { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.ts";`, + resolveDir: appRoot, + }, + loader: { ".md": "text" }, +}); +for (const phase of [ + "draft", + "parent", + "resumed-parent", + "completed", +] as const) { + test(`real saved native scan cancellation during selected ${phase}`, async () => { + const root = await temporaryDirectory("real-detached-cancel-", true); + const home = path.join(root, "codex-home"); + const target = path.join(root, "target"); + const environment = { + ...process.env, + CODEX_HOME: home, + CODEX_SECURITY_STATE_DIR: path.join(root, "state"), + }; + await mkdir(target, { recursive: true }); + await writeFile(path.join(target, "fixture.py"), "print('fixture')\n"); + await mkdir(path.join(home, "codex-security"), { recursive: true }); + await writeFile( + path.join(home, "codex-security/config.toml"), + "[deep_scan]\nworkers = 1\nmax_discovery_runs = 3\nmax_time_hours = 1e-12\n", + ); + const runWorkbench = async ( + args: string[], + input?: string, + selection = false, + withSettings = false, + signal?: AbortSignal, + releaseCoordinator = false, + ) => { + const invocation = releaseCoordinator + ? "release_coordinator=True" + : selection + ? "select_finalization=True" + : withSettings + ? "with_execution_settings=True" + : undefined; + const pythonArgs = invocation + ? [ + "-c", + `import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](${invocation})`, + workbenchPath, + ...args, + ] + : [workbenchPath, ...args]; + return await new Promise((resolve, reject) => { + const child = spawn(process.env.PYTHON || "python3", pythonArgs, { + cwd: pluginRoot, + env: environment, + signal, + }); + const stdout: Buffer[] = [], + stderr: Buffer[] = []; + child.stdout.on("data", (data: Buffer) => stdout.push(data)); + child.stderr.on("data", (data: Buffer) => stderr.push(data)); + child.on("error", reject); + child.on("close", (code) => + code === 0 + ? resolve(JSON.parse(Buffer.concat(stdout).toString())) + : reject(new Error(Buffer.concat(stderr).toString())), + ); + child.stdin.end(input); + }); + }; + try { + let store = new WorkbenchDeepScanStore(runWorkbench); + const threadId = "real-selected-owner"; + const executionSettings = await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ); + const begun = await store.begin({ + targetPath: target, + scope: ".", + threadId, + scanRoot: path.join(root, "scans"), + executionSettings, + }); + const claimed = await store.claimCoordinator({ + scanId: begun.scanId, + threadId, + }); + assert.equal(claimed.acquired, true); + let selected = await store.selectFinalization({ + scanId: begun.scanId, + coordinatorGeneration: claimed.run.coordinatorGeneration, + reason: "capped", + manifestPath: path.join(begun.scanDir, "scan-manifest.json"), + omittedWorkerIds: [], + }); + assert.equal(selected.status, "running"); + assert.equal(selected.finalizationInput.resultPath, null); + if (phase === "resumed-parent") { + const context = await createScanArtifactContext( + begun.scanId, + runWorkbench, + { requireRunning: true, pluginRoot }, + ); + const draft = await readSelectedDeepScanDraft( + createDeepScanArtifacts(begun.scanDir), + begun.scanId, + selected.finalizationInput, + ); + await recordCodexSecurityScanDraftViaWorkbench( + context, + draft, + runWorkbench, + undefined, + { + coordinatorGeneration: selected.coordinatorGeneration, + resultPath: null, + }, + ); + await store.finish({ + scanId: begun.scanId, + reason: "capped", + manifestPath: path.join(begun.scanDir, "scan-manifest.json"), + omittedWorkerIds: [], + }); + await store.releaseCoordinator(begun.scanId); + store = new WorkbenchDeepScanStore(runWorkbench); + const resumed = await store.claimCoordinator({ + scanId: begun.scanId, + threadId, + }); + assert.equal(resumed.acquired, true); + selected = resumed.run; + assert.equal( + selected.status, + "succeeded", + "the resumed Deep result is committed while its parent is still running", + ); + } + const blocked = Promise.withResolvers(), + release = Promise.withResolvers(); + const observer = new AbortController(); + let writes = 0, + seals = 0; + let publicationSignal: AbortSignal | undefined; + const pause = async (signal: AbortSignal) => { + publicationSignal = signal; + blocked.resolve(); + await release.promise; + signal.throwIfAborted(); + }; + const registry = new DeepScanCoordinatorRegistry(); + const coordinator = registry.start({ + run: selected, + store, + pluginRoot, + threadId, + executor: { + run: async () => + assert.fail("Accepted selection cannot launch new model work"), + }, + onComplete: async ( + draft: ScanDraftInput, + signal: AbortSignal, + publication: DeepScanPublication, + ) => { + if (phase === "draft") await pause(signal); + const context = await createScanArtifactContext( + begun.scanId, + runWorkbench, + { requireRunning: true, requireClaim: true, pluginRoot }, + ); + await recordCodexSecurityScanDraftViaWorkbench( + context, + draft, + runWorkbench, + signal, + publication, + ); + }, + onFinalized: async (run: DeepScanRunState, signal: AbortSignal) => { + if (!observer.signal.aborted) return; + if (phase === "parent" || phase === "resumed-parent") + await pause(signal); + await runWorkbench( + ["complete-scan", "--scan-id", run.scanId, "--thread-id", threadId], + undefined, + false, + false, + signal, + ); + seals++; + }, + }); + const settled = coordinator.settled(); + settled.catch(() => {}); + const detached = coordinator.wait(observer.signal); + observer.abort(); + await assert.rejects(detached, { name: "AbortError" }); + const persist = async () => { + writes++; + await runWorkbench([ + "cancel-scan", + "--scan-id", + begun.scanId, + "--thread-id", + threadId, + ]); + }; + if (phase === "completed") { + await settled; + const before = await readFile( + path.join(begun.scanDir, "scan-manifest.json"), + ); + assert.equal( + await registry.cancelAndWait( + begun.scanId, + "user_canceled_scan", + persist, + ), + false, + ); + await assert.rejects(persist(), /Only a running scan can be canceled/); + const saved = await runWorkbench([ + "get-scan", + "--scan-id", + begun.scanId, + ]); + assert.equal(saved.scan.progress.status, "complete"); + assert.deepEqual( + await readFile(path.join(begun.scanDir, "scan-manifest.json")), + before, + ); + assert.equal(seals, 1); + return; + } + await blocked.promise; + const cancellation = registry.cancelAndWait( + begun.scanId, + "user_canceled_scan", + persist, + ); + const aborted = publicationSignal?.aborted; + release.resolve(); + assert.equal(await cancellation, true); + await settled; + // Match cancelSecurityScan: closed parents are observed; running ones cancel directly. + const saved = await runWorkbench(["get-scan", "--scan-id", begun.scanId]); + if (saved.scan.progress.status === "running") await persist(); + const final = await runWorkbench(["get-scan", "--scan-id", begun.scanId]); + assert.equal( + aborted, + true, + "cancel must reach the active parent publication signal", + ); + assert.equal(final.scan.progress.status, "canceled"); + assert.equal(writes, 1); + assert.equal(seals, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.ts b/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.ts index 9ba3b172f6..95d86e0081 100644 --- a/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.ts +++ b/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.ts @@ -187,7 +187,11 @@ function startTestServer({ cwd: string; env?: NodeJS.ProcessEnv; }) { - const childEnvironment = { ...process.env, ...env }; + const childEnvironment: NodeJS.ProcessEnv = { + ...process.env, + CODEX_HOME: path.join(scanRoot, "codex-home"), + ...env, + }; for (const [name, value] of Object.entries(env)) { if (value === undefined) { delete childEnvironment[name]; diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_process.ts b/plugins/codex-security/mcp-app/tests/test_workbench_process.ts index 7d70734cd0..346da8a972 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_process.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_process.ts @@ -53,6 +53,9 @@ try { new URL("../server.ts", import.meta.url).href, ), }, + banner: { + js: `import { createRequire as fixtureCreateRequire } from "node:module"; const require = fixtureCreateRequire(${JSON.stringify(new URL("../server.ts", import.meta.url).href)});`, + }, loader: { ".md": "text" }, })) as WorkbenchModule; for (const command of [ From 0bc10f66ed20c9e3689f8a4c6ff432aefa169de7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 03:55:49 +0000 Subject: [PATCH 205/251] Trim duplicate worker fixture comments for source compatibility --- plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 9178c6b994..033f419764 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -1617,7 +1617,6 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, launches.length = 0; await Promise.all( executors.map((executor, index) => { - // Each concurrent launch snapshots its own scan environment. process.env.CODEX_SECURITY_CONFIG_PATH = workerConfigurations[index].path; process.env.CODEX_SECURITY_KNOWLEDGE_BASE = @@ -2073,7 +2072,6 @@ async function testWorkerCyberAccessSettings() { launches.length = 0; await Promise.all( cases.map((testCase) => { - // Each scan captures its own config before its asynchronous launch. process.env.CODEX_SECURITY_CONFIG_PATH = testCase.configPath; return testCase.executor!.run( workerRequest(promptPath, fixture.root, { kind, resumeThreadId }), From 0b645d908dc9d22220aa0cfb1d4b9e13b51ef09a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 04:51:36 +0000 Subject: [PATCH 206/251] fix: preserve omitted optional reviews during saved publication --- plugins/codex-security/scripts/workbench_saved_results.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index bf76a0a4dc..53f164a97f 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -2122,6 +2122,8 @@ def coverage_candidate( and field not in {"surfaces", "explicitExclusions", "deferred"} ): continue + if field == "reviews" and field not in draft["coverage"]: + continue items = draft["coverage"].get(field, []) if field == "deferred": items = deferred_rows[relative] From dd9770981d739d018ad8098ee94b1f724c4c15e9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 05:31:33 +0000 Subject: [PATCH 207/251] test: wait for cancellation socket closure after reset --- sdk/typescript/tests-ts/api.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index ec519dfcd7..55e90cb904 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -2503,7 +2503,22 @@ describe("CodexSecurity orchestration", () => { expect( await readFile(join(root, "registration-input.json"), "utf8"), ).toBe(submitted!); - const closed = once(socket, "close", { signal: deadline }); + // Terminating the paused child can reset its socket on Windows. + // Still wait for close, and retain unexpected errors and the deadline. + const closed = new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException) => { + if (error.code !== "ECONNRESET") reject(error); + }; + const onAbort = () => reject(deadline.reason); + socket!.once("error", onError); + socket!.once("close", () => { + socket!.removeListener("error", onError); + deadline.removeEventListener("abort", onAbort); + resolve(); + }); + if (deadline.aborted) onAbort(); + else deadline.addEventListener("abort", onAbort, { once: true }); + }); if (cancel === "close") closing = client.close(); else controller.abort(); if (boundary === "commit" || boundary === "rollback") { From a39d51e9323560447f44609a3f4d9d0e9fe9a6ca Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 05:42:36 +0000 Subject: [PATCH 208/251] test: trim redundant worker fixture comments --- .../codex-security/mcp-app/tests/test_deep_scan_executor.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 4ddcdecfe3..7d6741d797 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -1355,7 +1355,6 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, { model: "gpt-6.1-sol", reasoningEffort: "max" }, { model: "gpt-6-sol", reasoningEffort: "high" }, { model: "fixture-future-model", reasoningEffort: "future-effort" }, - // Omitted settings preserve the model and effort in the Codex home. {}, ]; const providerKeys = settings.map((_, index) => @@ -1611,7 +1610,6 @@ env_key = "SYNTHETIC_GATEWAY_KEY"`, }, }), ); - // A running coordinator retains its settings if the source file changes. for (const kind of ["discovery", "dedup"] as const) { for (const resumeThreadId of [undefined, "fixture-resumed-thread"]) { launches.length = 0; @@ -2120,7 +2118,6 @@ async function testWorkerCyberAccessSettings() { assertReadOnlyWorkerPolicy(invocation.argv); assertWorkerSubagentPolicy(invocation.argv, 0); } - // Running coordinators and their resumed workers retain their own settings. for (const { configPath } of cases) { await writeFile( configPath!, From ab2622e29c43fbfcd783f125851c625a590afc41 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 06:45:19 +0000 Subject: [PATCH 209/251] test(package): run isolated Deep workflows concurrently --- sdk/typescript/scripts/fixtures/package-deep-scan.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index 4dd8cf3c88..6cecfa9699 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -68,8 +68,13 @@ try { await copyFile(process.execPath, executable); await chmod(executable, 0o700); - await runInstalledSdk(installedPlugin, executable); - await runDetachedPlugin(detachedPlugin, executable); + const workflows = await Promise.allSettled([ + runInstalledSdk(installedPlugin, executable), + runDetachedPlugin(detachedPlugin, executable), + ]); + for (const workflow of workflows) { + if (workflow.status === "rejected") throw workflow.reason; + } console.log( "Validated installed SDK and detached plugin: real Deep processes, bound artifact tools, checkpoints, reducer acceptance, restart before finalization, and sealed results.", ); From 2103f96917361e26f2e293cbb5bb8e24a121b2d8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 07:37:35 +0000 Subject: [PATCH 210/251] Remove redundant executor fixture comments --- .../codex-security/mcp-app/tests/test_deep_scan_executor.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 73895a86af..43ab96242b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -3309,12 +3309,8 @@ async function testIsolatedReconstructedWorkers() { if (phase.startsWith("reconstructed") || phase === "incomplete") { for (const scan of scans) { scan.run = await scan.readRun(); - // The caller restores recorded selections. Its old config file need - // not exist; current credentials still come from the selected home/env. if (phase === "reconstructed-fresh") await rm(scan.configPath); if (phase.startsWith("reconstructed")) { - // The managed parent can edit its output files. Neither a substituted - // executable/home nor other settings in that file are launch authority. const rewritten = JSON.parse(scan.snapshot); rewritten.settings.codexPath = process.execPath; rewritten.settings.codexHome = scans.find( @@ -3326,8 +3322,6 @@ async function testIsolatedReconstructedWorkers() { const saved = JSON.parse(scan.snapshot); for (const key of ["model", "reasoningEffort", "reasoningSummary"]) delete saved.settings[key]; - // Native history restores the first provider. The second snapshot - // retains the provider binding for its saved AWS selectors. if (scan.name === "first") delete saved.settings.modelProvider; if (scan.name === "first") delete saved.settings.serviceTier; await writeFile(scan.snapshotPath, JSON.stringify(saved)); From e72cb2ee9bfbf7b1e5585b704f20457892532113 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 09:01:50 +0000 Subject: [PATCH 211/251] Keep recovery fixtures aligned with final migrations --- plugins/codex-security/tests/test_checkpoint_schema.py | 6 +++--- .../tests/test_deep_scan_recovery_settings.py | 2 +- plugins/codex-security/tests/test_deep_scan_usage_owner.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/codex-security/tests/test_checkpoint_schema.py b/plugins/codex-security/tests/test_checkpoint_schema.py index bb471267cb..ab4e5f9ff9 100644 --- a/plugins/codex-security/tests/test_checkpoint_schema.py +++ b/plugins/codex-security/tests/test_checkpoint_schema.py @@ -22,7 +22,7 @@ def migrate(connection, selected): connection.execute("PRAGMA foreign_keys = ON") migrate( connection, - tuple(item for item in migrations if item[0] != 47) if upgrade else migrations, + tuple(item for item in migrations if item[0] != 50) if upgrade else migrations, ) connection.execute( "INSERT INTO workspaces (id, created_at, updated_at) VALUES (?, ?, ?)", @@ -76,7 +76,7 @@ def migrate(connection, selected): assert [ tuple(row) for row in connection.execute( - "SELECT version, name FROM schema_migrations WHERE version = 47" + "SELECT version, name FROM schema_migrations WHERE version = 50" ) - ] == [(47, "freeze stopped scan checkpoint selections")] + ] == [(50, "freeze stopped scan checkpoint selections")] assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] diff --git a/plugins/codex-security/tests/test_deep_scan_recovery_settings.py b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py index 37976c3e13..821c8d37b4 100644 --- a/plugins/codex-security/tests/test_deep_scan_recovery_settings.py +++ b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py @@ -105,7 +105,7 @@ def test_supported_old_run_snapshots_context_on_upgrade( )["deepScan"] with sqlite3.connect(state / "workbench.sqlite3") as connection: connection.execute("ALTER TABLE deep_scan_runs DROP COLUMN discovery_user_context") - connection.execute("DELETE FROM schema_migrations WHERE version = 44") + connection.execute("DELETE FROM schema_migrations WHERE version = 47") upgraded = run_workbench( state, "get-deep-scan", diff --git a/plugins/codex-security/tests/test_deep_scan_usage_owner.py b/plugins/codex-security/tests/test_deep_scan_usage_owner.py index 63b91f8416..d848bc9ab7 100644 --- a/plugins/codex-security/tests/test_deep_scan_usage_owner.py +++ b/plugins/codex-security/tests/test_deep_scan_usage_owner.py @@ -138,10 +138,10 @@ def test_migrated_owner_accounting_survives_first_worker_attempt( )["deepScan"] database = state / "workbench.sqlite3" if migrated: - # Migration 48 adds a nullable owner to pre-existing runs, without an original turn binding. + # Migration 51 adds a nullable owner to pre-existing runs, without an original turn binding. with sqlite3.connect(database) as connection: connection.execute("ALTER TABLE deep_scan_runs DROP COLUMN usage_owner_json") - connection.execute("DELETE FROM schema_migrations WHERE version = 48") + connection.execute("DELETE FROM schema_migrations WHERE version = 51") run_workbench( state, "get-deep-scan", From 924e23da231f289ca40b4a690be9c792b2366b1e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 11:31:38 +0000 Subject: [PATCH 212/251] Give recorded usage cases unique report titles --- sdk/typescript/tests-ts/cost.test.ts | 60 ++++++++++++++-------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 57f3a9e412..5af9480e9b 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -2256,13 +2256,13 @@ describe("live scan cost tracking", () => { describe("recorded Deep worker homes", () => { test.each([ - [false, false], - [false, true], - [true, false], - [true, true], - ])( - "retains archived worker usage: recorded=%s archived=%s", - async (recorded, archived) => { + ["recorded=false archived=false", false, false], + ["recorded=false archived=true", false, true], + ["recorded=true archived=false", true, false], + ["recorded=true archived=true", true, true], + ] as const)( + "retains archived worker usage: %s", + async (_label, recorded, archived) => { const home = await codexHome(); const workerHome = recorded ? await codexHome() : home; const at = "2026-09-01T00:00:02Z"; @@ -2864,23 +2864,23 @@ describe("recorded Deep worker homes", () => { }); test.each([ - ["identical", false], - ["identical", true], - ["prefix-first", false], - ["prefix-first", true], - ["prefix-last", false], - ["prefix-last", true], - ["truncated-first", false], - ["truncated-first", true], - ["truncated-last", false], - ["truncated-last", true], - ["tail-first", false], - ["tail-first", true], - ["tail-last", false], - ["tail-last", true], + ["identical, attribution: false", "identical", false], + ["identical, attribution: true", "identical", true], + ["prefix-first, attribution: false", "prefix-first", false], + ["prefix-first, attribution: true", "prefix-first", true], + ["prefix-last, attribution: false", "prefix-last", false], + ["prefix-last, attribution: true", "prefix-last", true], + ["truncated-first, attribution: false", "truncated-first", false], + ["truncated-first, attribution: true", "truncated-first", true], + ["truncated-last, attribution: false", "truncated-last", false], + ["truncated-last, attribution: true", "truncated-last", true], + ["tail-first, attribution: false", "tail-first", false], + ["tail-first, attribution: true", "tail-first", true], + ["tail-last, attribution: false", "tail-last", false], + ["tail-last, attribution: true", "tail-last", true], ] as const)( - "prices copied response records (%s, attribution: %s)", - async (copy, attributed) => { + "prices copied response records (%s)", + async (_label, copy, attributed) => { const home = await codexHome(); const recordedHome = await codexHome(); const scanDirectory = join(home, "scan"); @@ -2973,14 +2973,14 @@ describe("recorded Deep worker homes", () => { }); test.each([ - [150_000, 15_000, 100_000, 200_000], - [150_000, 15_000, 100_000, 20_000], - [100_000, 50_000, 110_000, 10_000], - [100_000, 50_000, 200_000, 10_000], - [100_000, 10_000, 100_000, 10_000], + ["150000/15000 vs 100000/200000", 150_000, 15_000, 100_000, 200_000], + ["150000/15000 vs 100000/20000", 150_000, 15_000, 100_000, 20_000], + ["100000/50000 vs 110000/10000", 100_000, 50_000, 110_000, 10_000], + ["100000/50000 vs 200000/10000", 100_000, 50_000, 200_000, 10_000], + ["100000/10000 vs 100000/10000", 100_000, 10_000, 100_000, 10_000], ] as const)( - "preserves receipt pricing across divergent counters %s/%s vs %s/%s", - async (input, output, counterInput, counterOutput) => { + "preserves receipt pricing across divergent counters %s", + async (_label, input, output, counterInput, counterOutput) => { const home = await codexHome(); const path = await writeSession(home, "worker", {}); const counts = (input: number, output: number) => ({ From 1e5c07c6a2d2afb73a2406dced30aa2e6a46cd8e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 12:46:54 +0000 Subject: [PATCH 213/251] fix(deep-scan): release completed profile launcher output --- .../mcp-app/tests/test_codex_profile.ts | 120 +++++++++++++++++- .../codex-security/scripts/codex_profile.mjs | 2 + 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_codex_profile.ts b/plugins/codex-security/mcp-app/tests/test_codex_profile.ts index 7f6cea4cf5..3edff5f5c8 100644 --- a/plugins/codex-security/mcp-app/tests/test_codex_profile.ts +++ b/plugins/codex-security/mcp-app/tests/test_codex_profile.ts @@ -1,7 +1,15 @@ import assert from "node:assert/strict"; import childProcess, { type SpawnOptions } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { syncBuiltinESMExports } from "node:module"; +import { + chmod, + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { createRequire, syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; import { mock, test } from "node:test"; @@ -182,6 +190,48 @@ async function fixture(mode = "success") { }; } +async function npmLauncherFixture() { + const f = await fixture("slow_completion"); + const codexRequire = createRequire(import.meta.resolve("@openai/codex-sdk")); + const packageRoot = path.dirname( + codexRequire.resolve("@openai/codex/package.json"), + ); + const launcherRoot = path.join(f.directory, "npm-codex"); + const targetTriple = new Map([ + ["linux-x64", "x86_64-unknown-linux-musl"], + ["linux-arm64", "aarch64-unknown-linux-musl"], + ["darwin-x64", "x86_64-apple-darwin"], + ["darwin-arm64", "aarch64-apple-darwin"], + ]).get(`${process.platform}-${process.arch}`); + assert.ok(targetTriple); + const launcher = path.join(launcherRoot, "bin", "codex.js"); + const native = path.join( + launcherRoot, + "vendor", + targetTriple, + "bin", + "codex", + ); + await mkdir(path.dirname(launcher), { recursive: true }); + await mkdir(path.dirname(native), { recursive: true }); + await copyFile( + path.join(packageRoot, "package.json"), + path.join(launcherRoot, "package.json"), + ); + await copyFile(path.join(packageRoot, "bin", "codex.js"), launcher); + await chmod(launcher, 0o755); + await writeFile( + native, + "#!/usr/bin/env node\n" + + ( + await readFile(path.join(f.directory, "native-fixture.mjs"), "utf8") + ).replace("process.argv.slice(1)", "process.argv.slice(2)"), + ); + await chmod(native, 0o755); + const { NODE_OPTIONS: _fixtureImport, ...env } = f.options.env; + return { ...f, options: { ...f.options, codexPathOverride: launcher, env } }; +} + test("native profile turns preserve settings, JSON events, schema cleanup, and resume identity", async () => { const f = await fixture(); const previousEnvironment = process.env.PROFILE_CLIENT_SHOULD_NOT_LEAK; @@ -412,12 +462,14 @@ test("native profile failures preserve upstream diagnostics and remove schema fi function captureChildClose() { let closed: Promise | undefined; let isClosed = false; + let pid: number | undefined; const originalSpawn = childProcess.spawn; const spawnSpy = mock.method( childProcess, "spawn", (command: string, args: readonly string[], options: SpawnOptions) => { const child = originalSpawn(command, args, options); + pid = child.pid; closed = new Promise((resolve) => child.once("close", () => { isClosed = true; @@ -430,6 +482,7 @@ function captureChildClose() { syncBuiltinESMExports(); return { isClosed: () => isClosed, + pid: () => pid, wait: () => { assert.ok(closed); return closed; @@ -465,6 +518,69 @@ for (const resumed of [false, true]) { }); } +for (const resumed of [false, true]) { + test( + `native profile ${resumed ? "resumed" : "fresh"} completion releases npm launcher inherited output`, + { skip: process.platform === "win32" }, + async () => { + const f = await npmLauncherFixture(); + const childClose = captureChildClose(); + let completion: Promise | undefined; + let timeout: ReturnType | undefined; + try { + const client = createCodexProfileClient(f.options); + const thread = resumed + ? client.resumeThread("existing-fixture") + : client.startThread(); + const { events } = await thread.runStreamed("synthetic prompt", { + outputSchema: { type: "object" }, + }); + completion = (async () => { + for await (const event of events) { + if (event.type === "turn.completed") break; + } + })(); + await Promise.race([ + completion, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject( + new Error( + "Completed profile turn did not release npm launcher output.", + ), + ); + }, 1_000); + }), + ]); + clearTimeout(timeout); + assert.equal(childClose.isClosed(), true); + assert.ok(childClose.pid()); + assert.throws(() => process.kill(childClose.pid()!, 0), { + code: "ESRCH", + }); + const record = await f.record(); + assert.equal(record.args.includes("resume"), resumed); + assert.equal(record.environment.CODEX_HOME, f.options.env.CODEX_HOME); + assert.equal(record.environment.CODEX_API_KEY, "synthetic-api-key"); + assert.doesNotThrow(() => process.kill(record.pid, 0)); + await assert.rejects(readFile(record.schemaPath), { code: "ENOENT" }); + } finally { + clearTimeout(timeout); + const record = await f.record().catch(() => undefined); + if (record?.pid) { + try { + process.kill(record.pid, "SIGKILL"); + } catch {} + } + await completion?.catch(() => {}); + await childClose.wait(); + childClose.restore(); + await f.cleanup(); + } + }, + ); +} + test("native profile abort and abandoned streams close the actual child", async () => { for (const abort of [true, false]) { const f = await fixture("wait"); diff --git a/plugins/codex-security/scripts/codex_profile.mjs b/plugins/codex-security/scripts/codex_profile.mjs index 821c38ff79..b59c963f82 100644 --- a/plugins/codex-security/scripts/codex_profile.mjs +++ b/plugins/codex-security/scripts/codex_profile.mjs @@ -240,6 +240,8 @@ async function* execute(options, args, input, signal, isCompleted) { lines.close(); if (child.exitCode === null && child.signalCode === null) child.kill(isCompleted() ? "SIGKILL" : undefined); + child.stdout.destroy(); + child.stderr.destroy(); await closed; } } From 3d63079a87fe1d3970f1ef46ae966e41bb615fc6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 13:46:14 +0000 Subject: [PATCH 214/251] fix(cost): enforce known Standard budget with pending child usage --- sdk/typescript/src/cost.ts | 43 ++++++----- sdk/typescript/tests-ts/api.test.ts | 69 +++++++++++++++++ sdk/typescript/tests-ts/cost.test.ts | 107 +++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 19 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 38c9f669ff..7f8857b91a 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -521,14 +521,12 @@ export class ScanCostTracker { incomplete = true; } let usage: ScanTokenUsage | null = null; + let missingUsage = false; for (const value of usages.values()) { if (value === null) { - if (this.#attribution) { - incomplete = true; - continue; - } - this.#snapshot = { usage: null, cost: null }; - return; + incomplete = true; + if (!this.#attribution) missingUsage = true; + continue; } usage = addTokenUsage(usage, value); } @@ -600,14 +598,21 @@ export class ScanCostTracker { ? { ...reconciled, coverage: "partial" } : reconciled; const cost = estimateScanCost(this.#options.model, measured); - this.#snapshot = { usage: measured, cost }; - this.#reportCost(cost, measured, { - ...usage, - modelUsage: [...liveModelUsage].map(([model, tokens]) => ({ - model, - ...tokens, - })), - }); + this.#snapshot = missingUsage + ? { usage: null, cost: null } + : { usage: measured, cost }; + this.#reportCost( + cost, + measured, + { + ...usage, + modelUsage: [...liveModelUsage].map(([model, tokens]) => ({ + model, + ...tokens, + })), + }, + missingUsage, + ); } #reportWorkerProgress(session: SessionUsage): void { @@ -654,13 +659,13 @@ export class ScanCostTracker { cost: ScanCost | null, usage: unknown, liveUsage?: unknown, + lowerBoundOnly = false, ): void { - if (cost === null) { + if (cost === null || lowerBoundOnly) { if (this.#options.onCostLowerBound === undefined) return; - const receiptCost = estimateScanCostLowerBound( - this.#options.model, - usage, - ); + const receiptCost = cost + ? { ...cost, coverage: "partial" as const } + : estimateScanCostLowerBound(this.#options.model, usage); const liveCost = estimateScanCostLowerBound( this.#options.model, liveUsage, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 1dde2069e6..f2fea5b1d1 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -5299,6 +5299,75 @@ describe("CodexSecurity orchestration", () => { }, ); + test("enforces known Standard cost while a delegated child has only metadata", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await Promise.all([ + mkdir(repository), + mkdir(codexHome), + mkdir(scanDir, { mode: 0o700 }), + ]); + const commands: string[] = []; + const runStreamed = mock( + async (_input: string, options: { signal: AbortSignal }) => { + async function* events(): AsyncGenerator { + await writeUsageSession(codexHome, "scan-thread", { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }); + const child = await writeUsageSession( + codexHome, + "worker-thread", + {}, + { + parent: "scan-thread", + parentField: "source", + }, + ); + // Native session metadata is persisted before the first usage record. + const metadata = (await readFile(child, "utf8")).split("\n")[0]!; + await writeFile(child, metadata + "\n"); + yield { type: "thread.started", thread_id: "scan-thread" }; + await (options.signal.aborted + ? undefined + : once(options.signal, "abort")); + throw new DOMException("aborted", "AbortError"); + } + return { events: events() }; + }, + ); + const client = TestClient.withDependencies({ + ...scanRuntimeDependencies(codexHome, scanDir), + runWorkbench: async (_options, args, input) => { + commands.push(args[0]!); + return mockWorkbench(args, input); + }, + createCodex: codexFactory(runStreamed), + }); + const keepEventLoopAlive = setTimeout(() => {}, 10_000); + try { + const failure = await client + .run(repository, { + mode: "standard", + maxCostUsd: 0.004, + signal: AbortSignal.timeout(5_000), + }) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ScanCostLimitExceededError); + expect(failure).toMatchObject({ + cost: { estimatedUsd: 0.00488, coverage: "partial" }, + }); + expect(commands).not.toContain("complete-scan"); + expect(runStreamed).toHaveBeenCalledTimes(1); + } finally { + clearTimeout(keepEventLoopAlive); + await client.close(); + } + }); + test("stops and records a scan as soon as its live cost exceeds the limit", async () => { const { repository, codexHome, scanDir } = await scanDirectories(); const commands: Array = []; diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 5af9480e9b..9b7eced086 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -660,6 +660,113 @@ describe("live scan cost tracking", () => { expect(resumed).toEqual([{ kind: "observed", worker: 1 }]); }); + test("retains unknown Standard usage while reporting known cost and later child usage", async () => { + const home = await codexHome(); + await writeSession(home, "scan-thread", { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }); + const worker = await writeSession( + home, + "worker-thread", + {}, + { + parent: "scan-thread", + }, + ); + await writeFile( + worker, + (await readFile(worker, "utf8")).split("\n")[0]! + "\n", + ); + const publicCosts: Readonly[] = []; + const lowerBounds: Readonly[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + onCost: (cost) => publicCosts.push(cost), + onCostLowerBound: (cost) => lowerBounds.push(cost), + }); + tracker.start("scan-thread"); + try { + expect(await tracker.refresh()).toEqual({ usage: null, cost: null }); + expect(publicCosts).toEqual([]); + expect(lowerBounds.at(-1)).toMatchObject({ + inputTokens: 1_250, + estimatedUsd: 0.00488, + coverage: "partial", + }); + await writeSession( + home, + "worker-thread", + { + input_tokens: 100, + output_tokens: 0, + }, + { parent: "scan-thread" }, + ); + expect(await tracker.refresh()).toMatchObject({ + usage: { input_tokens: 1_350 }, + cost: { inputTokens: 1_350, estimatedUsd: 0.00528 }, + }); + expect(publicCosts.at(-1)?.estimatedUsd).toBe(0.00528); + } finally { + await tracker.stop(); + } + }); + + test("prices known Standard receipts by their recorded model while child usage is missing", async () => { + const home = await codexHome(); + const parent = await writeSession(home, "scan-thread", {}); + await appendFile( + parent, + jsonLines([ + { + type: "token_usage_record", + payload: { + thread_id: "scan-thread", + response_id: "known-response", + model: "gpt-5.6-luna", + usage: { input_tokens: 1_000, output_tokens: 0 }, + }, + }, + ]) + "\n", + ); + const child = await writeSession( + home, + "worker-thread", + {}, + { parent: "scan-thread" }, + ); + await writeFile( + child, + (await readFile(child, "utf8")).split("\n")[0]! + "\n", + ); + const lowerBounds: Readonly[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + onCostLowerBound: (cost) => lowerBounds.push(cost), + }); + tracker.start("scan-thread"); + try { + expect(await tracker.refresh()).toEqual({ usage: null, cost: null }); + expect(lowerBounds).toHaveLength(1); + expect(lowerBounds[0]).toMatchObject({ + estimatedUsd: estimateScanCost("gpt-5.6-luna", { + input_tokens: 1_000, + output_tokens: 0, + })!.estimatedUsd, + coverage: "partial", + modelCosts: [{ model: "gpt-5.6-luna", inputTokens: 1_000 }], + }); + await tracker.refresh(); + expect(lowerBounds).toHaveLength(1); + } finally { + await tracker.stop(); + } + }); + test("counts the scan and delegated workers without including other scans", async () => { const home = await codexHome(); const parent = await writeSession(home, "scan-thread", { From 6ce07dc4426cd97578c676bbce1cf724e038f7d4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 17:35:54 +0000 Subject: [PATCH 215/251] Preserve discovery receipt origins across retries --- .../mcp-app/src/artifact-scan-draft.ts | 21 ++++- .../src/deep-scan/artifact-validation.ts | 8 +- .../tests/deep_scan_coverage_fixture.mjs | 82 ++++++++++++++++++- .../test_deep_scan_checkpoint_coverage.mjs | 34 ++++++++ .../scripts/workbench_saved_results.py | 6 +- .../tests/test_budget_coverage_provenance.py | 26 +++++- 6 files changed, 166 insertions(+), 11 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index 2bdf6f4527..3a31f342d5 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -2,7 +2,7 @@ import type { JsonObject } from "./types.js"; import { isRecord as isObject, isNonEmptyString } from "./record.js"; import { createHash, randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; -import { dirname, join, sep } from "node:path"; +import { basename, dirname, join, posix, sep } from "node:path"; import { isDeepStrictEqual } from "node:util"; import type * as z from "zod/v4"; import commonSchema from "../../schemas/definitions/artifact-common.schema.json"; @@ -1449,9 +1449,22 @@ async function readArchivedWorkerCheckpoints( Number(right.result) - Number(left.result) || right.name.localeCompare(left.name), ); - archived.push( - ...drafts.map((draft) => ({ ...draft, attempt: attempt.name })), - ); + const workerPrefix = `artifacts/deep_discovery/workers/${basename(workerRoot)}/`; + const activePrefix = `${workerPrefix}output/`; + const archivePrefix = `${workerPrefix}attempts/`; + for (const draft of drafts) { + // Receipts move with the archived attempt; immutable checkpoint bytes stay unchanged. + for (const surface of draft.input.coverage.surfaces as JsonObject[]) { + surface.receiptRefs = ( + (surface.receiptRefs as string[] | undefined) ?? [] + ).map((value) => { + const ref = posix.normalize(value); + if (ref.startsWith(archivePrefix)) return ref; + return `${archivePrefix}${attempt.name}/${ref.startsWith(activePrefix) ? ref.slice(activePrefix.length) : ref}`; + }); + } + archived.push({ ...draft, attempt: attempt.name }); + } } return archived; } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index 2cbb008518..e84a89d99d 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { join, posix } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { parsePersistedScanDraft, @@ -354,6 +354,7 @@ export function projectDiscoveryCoverage( worker: { id: string; attempt?: number }, artifactPrefix: string, ): ScanDraftInput["coverage"] { + const archivePrefix = `${posix.dirname(artifactPrefix)}/attempts/`; const provenance = { workerId: worker.id, ...(worker.attempt === undefined ? {} : { attempt: worker.attempt }), @@ -394,7 +395,10 @@ export function projectDiscoveryCoverage( ...project(surface), id: `${prefix}-surface-${index + 1}`, receiptRefs: ((surface.receiptRefs as string[] | undefined) ?? []).map( - (ref) => `${artifactPrefix}/${ref}`, + (ref) => + ref.startsWith(archivePrefix) || ref.startsWith(`${artifactPrefix}/`) + ? ref + : `${artifactPrefix}/${ref}`, ), })), explicitExclusions: ( diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs index 4de988117c..2010574d15 100644 --- a/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs +++ b/plugins/codex-security/mcp-app/tests/deep_scan_coverage_fixture.mjs @@ -18,7 +18,7 @@ const bundled = await build({ 'export { validateDiscoveryArtifacts } from "./src/deep-scan/artifact-validation.ts";', 'export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts";', 'export { createScanArtifactContext } from "./src/artifact-context.ts";', - 'export { recordCodexSecurityScanDraftViaWorkbench, saveScanDraftCheckpoint } from "./src/artifact-scan-draft.ts";', + 'export { recordCodexSecurityScanDraftViaWorkbench, recordCodexSecurityWorkerScanDraft, saveScanDraftCheckpoint } from "./src/artifact-scan-draft.ts";', 'export { recordCodexSecurityDeepReduction, getCodexSecurityDeepReducerInputs } from "./src/artifact-deep-reducer.ts";', ].join("\n"), resolveDir: path.join(pluginRoot, "mcp-app"), @@ -41,6 +41,7 @@ export async function publishCoverageFixture( splitSeededReducers = false, selectedRecovery = false, sourceProvenance, + discoveryReceiptRetry, } = {}, ) { const runtimePath = path.join(root, "fixture-runtime.mjs"); @@ -52,6 +53,7 @@ export async function publishCoverageFixture( WorkbenchDeepScanStore, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, + recordCodexSecurityWorkerScanDraft, recordCodexSecurityDeepReduction, getCodexSecurityDeepReducerInputs, saveScanDraftCheckpoint, @@ -404,6 +406,8 @@ export async function publishCoverageFixture( } } let discoveryCalls = 0; + let receiptRetryStarted = false; + const retryReceipts = new Map(); const executor = { async run(request) { assert.equal( @@ -417,14 +421,84 @@ export async function publishCoverageFixture( discoveryCalls++; const index = Number( - path.basename(path.dirname(request.promptPath)).split("-").at(-1), + path + .basename(path.dirname(request.artifactContext.root)) + .split("-") + .at(-1), ) - 1; - if (index === 0 && !request.resumeThreadId) + if (discoveryReceiptRetry && !receiptRetryStarted) { + receiptRetryStarted = true; + const artifactDir = request.artifactContext.root; + await mkdir(path.join(artifactDir, "artifacts"), { recursive: true }); + await writeFile( + path.join(artifactDir, "artifacts", "old-review.md"), + "Archived review evidence.\n", + ); + await recordCodexSecurityWorkerScanDraft( + { + ...request.artifactContext, + repoRoot: targetPath, + scanId: run.scanId, + }, + { + scanId: run.scanId, + complete: false, + findings: [], + coverage: { + completeness: "partial", + surfaces: [ + { + id: "archived-surface", + label: "Archived boundary", + disposition: "no_issue_found", + receiptRefs: ["artifacts/old-review.md"], + }, + ], + explicitExclusions: [], + deferred: [], + }, + }, + ); + // A returned partial draft fails validation and triggers the real archive/retry path. + return { threadId: thread, finalResponse: "Checkpoint recorded." }; + } + if (!discoveryReceiptRetry && index === 0 && !request.resumeThreadId) return { threadId: thread, finalResponse: "Continue the unfinished audit.", }; await writeDiscovery(request.artifactContext.root, index); + if (discoveryReceiptRetry) { + const artifactDir = request.artifactContext.root; + const archived = path.join( + path.dirname(artifactDir), + "attempts", + "attempt-01", + "artifacts", + "old-review.md", + ); + retryReceipts.set(archived, "Archived review evidence.\n"); + if (discoveryReceiptRetry === "collision") { + const current = path.join( + artifactDir, + "artifacts", + "old-review.md", + ); + await writeFile(current, "New retry evidence.\n"); + retryReceipts.set(current, "New retry evidence.\n"); + } + const resultPath = path.join(artifactDir, "result.json"); + const result = JSON.parse(await readFile(resultPath, "utf8")); + await recordCodexSecurityWorkerScanDraft( + { + ...request.artifactContext, + repoRoot: targetPath, + scanId: run.scanId, + }, + result, + ); + rawSources.set(resultPath, await readFile(resultPath, "utf8")); + } } else { if (immutableInputs) { const current = await store.get(run.scanId, threadId); @@ -581,6 +655,8 @@ export async function publishCoverageFixture( await runWorkbench(["complete-scan", "--scan-id", run.scanId]); for (const [file, bytes] of rawSources) assert.equal(await readFile(file, "utf8"), bytes); + for (const [file, bytes] of retryReceipts) + assert.equal(await readFile(file, "utf8"), bytes); return { scanDir: run.scanDir, threadId, terminal }; } diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_checkpoint_coverage.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_checkpoint_coverage.mjs index d28d432854..d575798032 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_checkpoint_coverage.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_checkpoint_coverage.mjs @@ -47,3 +47,37 @@ for (const continueAfterResume of [false, true]) { } }); } + +for (const retry of ["missing", "collision"]) { + test(`archived discovery receipt preserves its origin after retry (${retry})`, async () => { + const root = await mkdtemp(path.join(tmpdir(), "deep-retry-coverage-")); + try { + const fixture = path.join(root, "fixture"); + await mkdir(fixture, { mode: 0o700 }); + const { scanDir } = await publishCoverageFixture(fixture, "complete", { + discoveryReceiptRetry: retry, + }); + const coverage = JSON.parse( + await readFile(path.join(scanDir, "coverage.json"), "utf8"), + ); + const archived = coverage.surfaces.find( + (surface) => surface.label === "Archived boundary", + ); + assert.ok(archived, "accepted archived coverage reaches publication"); + assert.equal(archived.receiptRefs.length, 1); + assert.equal( + await readFile(path.join(scanDir, archived.receiptRefs[0]), "utf8"), + "Archived review evidence.\n", + ); + const current = coverage.surfaces.find( + (surface) => surface.label === "Archive route", + ); + assert.equal( + await readFile(path.join(scanDir, current.receiptRefs[0]), "utf8"), + "Synthetic review evidence.\n", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 53f164a97f..e246121225 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -2484,6 +2484,7 @@ def retain_unmerged_budget_coverage( provenance = {"workerId": worker["id"], "attempt": worker["attempt"]} prefix = f"{worker['id']}-attempt-{worker['attempt']}" artifact_prefix = Path(worker["artifact_dir"]).relative_to(scan_dir).as_posix() + archive_prefix = f"{Path(artifact_prefix).parent.as_posix()}/attempts/" surfaces = { item.get("id"): f"{prefix}-surface-{index + 1}" for index, item in enumerate(source.get("surfaces", [])) @@ -2536,7 +2537,10 @@ def retain(field: str, item: dict[str, Any]) -> None: if field == "surfaces": item["id"] = f"{prefix}-surface-{index + 1}" item["receiptRefs"] = [ - f"{artifact_prefix}/{ref}" for ref in item.get("receiptRefs", []) + ref + if ref.startswith((archive_prefix, f"{artifact_prefix}/")) + else f"{artifact_prefix}/{ref}" + for ref in item.get("receiptRefs", []) ] if field == "deferred" and "candidateId" in item: item["candidateId"] = f"{prefix}-candidate-{index + 1}" diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py index aadcdc3d1a..f2c2d58366 100644 --- a/plugins/codex-security/tests/test_budget_coverage_provenance.py +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -15,12 +15,16 @@ @pytest.mark.parametrize("explicit_ids", [True, False], ids=["named-surfaces", "omitted-ids"]) @pytest.mark.parametrize("replay", ["fresh", "legacy", "duplicated"]) +@pytest.mark.parametrize( + "receipt_origin", ["worker-relative", "archived-scan-relative", "current-scan-relative"] +) def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( workbench_api, workbench_db, publication_scan, explicit_ids, replay, + receipt_origin, tmp_path, monkeypatch, ): @@ -69,6 +73,23 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( receipt.parent.mkdir(exist_ok=True) receipts[receipt] = f"Independent review receipt {index}.\n".encode() receipt.write_bytes(receipts[receipt]) + if receipt_origin == "archived-scan-relative": + archived = output.parent / "attempts" / "attempt-01" + archived.parent.mkdir() + output.rename(archived) + output.mkdir() + receipts = {archived / path.relative_to(output): value for path, value in receipts.items()} + for surface in surfaces: + surface["receiptRefs"] = [ + (archived / ref).relative_to(scan.scan_dir).as_posix() + for ref in surface["receiptRefs"] + ] + elif receipt_origin == "current-scan-relative": + for surface in surfaces: + surface["receiptRefs"] = [ + (output / ref).relative_to(scan.scan_dir).as_posix() + for ref in surface["receiptRefs"] + ] deferred = { "reason": "Both independent validation tasks remain unfinished.", "provenance": source_provenance, @@ -227,7 +248,10 @@ def interrupt_before_seal(connection, *_args): **({"sourceId": original["id"]} if explicit_ids else {}), } assert retained["receiptRefs"] == [ - (output / ref).relative_to(scan.scan_dir).as_posix() for ref in original["receiptRefs"] + (output / ref).relative_to(scan.scan_dir).as_posix() + if receipt_origin == "worker-relative" + else ref + for ref in original["receiptRefs"] ] for path, receipt_contents in receipts.items(): assert path.read_bytes() == receipt_contents From d1870d3dd957d9e10b12f78cf841719fa4b53997 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 8 Oct 2026 18:51:52 +0000 Subject: [PATCH 216/251] Preserve native worker shutdown through the Codex launcher --- .../mcp-app/tests/test_codex_profile.ts | 2 +- .../mcp-app/tests/test_deep_scan_executor.ts | 11 ++--------- plugins/codex-security/scripts/codex_profile.mjs | 8 ++------ 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_codex_profile.ts b/plugins/codex-security/mcp-app/tests/test_codex_profile.ts index 3edff5f5c8..5a07e2d955 100644 --- a/plugins/codex-security/mcp-app/tests/test_codex_profile.ts +++ b/plugins/codex-security/mcp-app/tests/test_codex_profile.ts @@ -562,7 +562,7 @@ for (const resumed of [false, true]) { assert.equal(record.args.includes("resume"), resumed); assert.equal(record.environment.CODEX_HOME, f.options.env.CODEX_HOME); assert.equal(record.environment.CODEX_API_KEY, "synthetic-api-key"); - assert.doesNotThrow(() => process.kill(record.pid, 0)); + assert.throws(() => process.kill(record.pid, 0), { code: "ESRCH" }); await assert.rejects(readFile(record.schemaPath), { code: "ENOENT" }); } finally { clearTimeout(timeout); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 43ab96242b..f9241ff163 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -2539,12 +2539,7 @@ async function completedWorkerSettlesWithoutWaitingForProcessExit( const promptPath = path.join(fixture.root, "prompt.md"); const workingDirectory = path.join(fixture.root, "artifacts"); await mkdir(workingDirectory); - await writeFile( - promptPath, - kind === undefined - ? "COMPLETE_THEN_HANG\n" - : "COMPLETE_THEN_HANG_IGNORE_TERMINATION\n", - ); + await writeFile(promptPath, "COMPLETE_THEN_HANG\n"); execution = new CodexSdkWorkerExecutor({ parentSandbox: trustedParentSandbox, }).run({ @@ -2583,9 +2578,7 @@ async function completedWorkerSettlesWithoutWaitingForProcessExit( await new Promise((resolve) => setTimeout(resolve, 250)); assert.deepEqual(unexpectedErrors, []); - if (kind === undefined) { - assert.throws(() => process.kill(childPid, 0), { code: "ESRCH" }); - } + assert.throws(() => process.kill(childPid, 0), { code: "ESRCH" }); } finally { clearTimeout(timeout); controller.abort("completed worker fixture cleanup"); diff --git a/plugins/codex-security/scripts/codex_profile.mjs b/plugins/codex-security/scripts/codex_profile.mjs index b59c963f82..41bf24795c 100644 --- a/plugins/codex-security/scripts/codex_profile.mjs +++ b/plugins/codex-security/scripts/codex_profile.mjs @@ -78,7 +78,6 @@ class ProfileThread { async *events(input, turnOptions) { let schemaDirectory; - let completed = false; try { let schemaPath; if (turnOptions.outputSchema !== undefined) { @@ -106,7 +105,6 @@ class ProfileThread { args, input, turnOptions.signal, - () => completed, )) { let event; try { @@ -130,7 +128,6 @@ class ProfileThread { } if (event.type === "thread.started") this.id = event.thread_id; if (event.type === "turn.completed") { - completed = true; if (event.usage != null) event.usage.cache_write_input_tokens ??= 0; } yield event; @@ -201,7 +198,7 @@ function toml(value) { throw new Error("Codex config overrides must contain finite TOML values"); } -async function* execute(options, args, input, signal, isCompleted) { +async function* execute(options, args, input, signal) { const env = { ...(options.env ?? process.env) }; env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ||= "codex_sdk_ts"; if (options.apiKey) env.CODEX_API_KEY = options.apiKey; @@ -238,8 +235,7 @@ async function* execute(options, args, input, signal, isCompleted) { if (inputError) throw inputError; } finally { lines.close(); - if (child.exitCode === null && child.signalCode === null) - child.kill(isCompleted() ? "SIGKILL" : undefined); + if (child.exitCode === null && child.signalCode === null) child.kill(); child.stdout.destroy(); child.stderr.destroy(); await closed; From 3c31a5c80e6020644bdfac9b52c12b51140f50db Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 01:21:03 +0000 Subject: [PATCH 217/251] fix(scan): publish the draft validated for Deep Scan selection --- .../tests/test_deep_scan_stdio_lifecycle.ts | 9 ++++-- .../scripts/workbench_saved_results.py | 11 ++----- .../test_selected_publication_integrity.py | 32 +++++++++++++++++++ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts index be21a4d22e..e43eea6a5b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts @@ -2092,11 +2092,14 @@ async function writePythonWrapper(executablePath: string) { "if (args[1] === 'complete-scan' && process.env.FAKE_WORKBENCH_FINALIZER_LOG) {", " appendFileSync(process.env.FAKE_WORKBENCH_FINALIZER_LOG, JSON.stringify(args) + '\\n');", " const finalizerControl = process.env.FAKE_WORKBENCH_FINALIZER_CONTROL;", - " while (existsSync(finalizerControl) && readFileSync(finalizerControl, 'utf8') === 'wait') {", + " const readFinalizerControl = () => {", + " try { return readFileSync(finalizerControl, 'utf8'); }", + " catch (error) { if (error.code !== 'ENOENT') throw error; }", + " };", + " while ((finalizerMode = readFinalizerControl()) === 'wait') {", " await new Promise((resolve) => setTimeout(resolve, 25));", " }", - " if (existsSync(finalizerControl)) {", - " finalizerMode = readFileSync(finalizerControl, 'utf8');", + " if (finalizerMode !== undefined) {", " unlinkSync(finalizerControl);", " }", " if (finalizerMode === 'failure') { console.error('injected complete-scan failure'); process.exit(1); }", diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index e246121225..ae179b5a6b 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -3079,15 +3079,8 @@ def write_scan_draft(db: Any, connection: Any, args: Any) -> dict[str, Any]: raise SystemExit( "scan_draft_conflict: canonical scan results changed; reconcile the saved checkpoint again." ) - try: - relative = Path(args.draft_path).relative_to(scan_dir).as_posix() - except ValueError as exc: - raise SystemExit( - "Scan draft must be inside the registered scan drafts directory." - ) from exc - if not re.fullmatch(r"drafts/[0-9a-fA-F-]+\.json", relative): - raise SystemExit("Scan draft must be inside the registered scan drafts directory.") - draft = _read_scan_local_json(scan_dir, relative, "Staged scan draft") + if draft is None: + draft = _read_staged_scan_draft(scan_dir, args.draft_path) manifest, findings, coverage = draft["manifest"], draft["findings"], draft["coverage"] binding = db.workbench_completion_binding(scan, db.now()) # Save scan IDs without sealing the draft. diff --git a/plugins/codex-security/tests/test_selected_publication_integrity.py b/plugins/codex-security/tests/test_selected_publication_integrity.py index 42f1659ba7..53d8c2316b 100644 --- a/plugins/codex-security/tests/test_selected_publication_integrity.py +++ b/plugins/codex-security/tests/test_selected_publication_integrity.py @@ -4,6 +4,7 @@ import os import sqlite3 from argparse import Namespace +from pathlib import Path import pytest from test_accepted_publication_references import accept_reducer @@ -29,6 +30,37 @@ def publish_selected(api, connection, scan): return accepted, recorded +def test_publication_uses_the_staged_draft_checked_for_selection( + workbench_api, workbench_db, publication_scan, monkeypatch +): + scan = publication_scan() + _, accepted, coverage = accept_reducer(workbench_db, scan) + scan.coverage = coverage + saved_selection(workbench_db, scan, accepted) + staged = stage_publication( + scan, generation=3, result_path=accepted, title=scan.findings[0]["title"] + ) + saved = workbench_api["saved_results"] + require_publication = saved._require_current_deep_publication + + def replace_after_selection_check(*args): + require_publication(*args) + staged_path = Path(staged.draft_path) + replacement = json.loads(staged_path.read_bytes()) + replacement["findings"]["findings"][0]["title"] = "Substituted staged finding" + replacement_path = staged_path.with_suffix(".replacement") + replacement_path.write_text(json.dumps(replacement)) + replacement_path.replace(staged_path) + + monkeypatch.setattr(saved, "_require_current_deep_publication", replace_after_selection_check) + saved.write_scan_draft(workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged) + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + published = json.loads((scan.scan_dir / "findings.json").read_bytes()) + assert published["findings"][0]["title"] == scan.findings[0]["title"] + + def substitute(scan, accepted, defect): if defect == "accepted-result": path = accepted From 80b4eed5742d8ae0e9b97b5b067c4c946ee3835e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 01:42:09 +0000 Subject: [PATCH 218/251] fix(scan): preserve Codex home traversal when capturing worker settings --- .../src/deep-scan/recovery-settings.ts | 20 +++-- .../mcp-app/tests/deep_scan_home_case.ts | 83 +++++++++++++++++++ .../mcp-app/tests/test_deep_scan_executor.ts | 8 ++ 3 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/deep_scan_home_case.ts diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index 3b288e42cc..afd949e530 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; import { homedir } from "node:os"; -import { isAbsolute, join, win32 } from "node:path"; +import { isAbsolute, join, sep, win32 } from "node:path"; import type { CodexOptions } from "@openai/codex-sdk"; import { parse as parseToml } from "smol-toml"; import { scanPreflightCodexConfig } from "../../../../../sdk/typescript/src/preflight-config.js"; @@ -49,9 +49,16 @@ export async function captureDeepScanExecutionSettings( environment: NodeJS.ProcessEnv = process.env, parent?: { threadId: string; startedAt?: string }, ): Promise { - const codexHome = environment.CODEX_HOME || join(homedir(), ".codex"); + const configuredHome = environment.CODEX_HOME || join(homedir(), ".codex"); + const codexHome = + !isAbsolute(configuredHome) || + (process.platform === "win32" && + ["\\", "/"].includes(win32.parse(configuredHome).root)) + ? await fs.realpath(configuredHome) + : configuredHome; + // Preserve symlink/.. traversal in an absolute home instead of joining lexically. const configPath = - environment.CODEX_SECURITY_CONFIG_PATH ?? join(codexHome, "config.toml"); + environment.CODEX_SECURITY_CONFIG_PATH ?? `${codexHome}${sep}config.toml`; let config: JsonObject; try { config = parseToml(await fs.readFile(configPath, "utf8")) as JsonObject; @@ -83,12 +90,7 @@ export async function captureDeepScanExecutionSettings( process.arch, process.cwd(), ), - codexHome: - !isAbsolute(codexHome) || - (process.platform === "win32" && - ["\\", "/"].includes(win32.parse(codexHome).root)) - ? await fs.realpath(codexHome) - : codexHome, + codexHome, model: original.model ?? (selected.model as string | undefined) ?? native.model, reasoningEffort: diff --git a/plugins/codex-security/mcp-app/tests/deep_scan_home_case.ts b/plugins/codex-security/mcp-app/tests/deep_scan_home_case.ts new file mode 100644 index 0000000000..184d657d89 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/deep_scan_home_case.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { assertConfigOverrides } from "./assertions.ts"; +import { readJson } from "./support/json.ts"; +import type { DeepWorkerParentSandbox } from "../src/deep-scan/parent-sandbox.js"; + +type Fixture = { root: string; executablePath: string; markerPath: string }; + +export async function testHome( + Executor: typeof import("../src/deep-scan/executor.js").CodexSdkWorkerExecutor, + capture: typeof import("../src/deep-scan/recovery-settings.js").captureDeepScanExecutionSettings, + restore: typeof import("../src/deep-scan/recovery-settings.js").restoredDeepScanWorkerSettings, + withFixture: ( + action: ( + fixture: Fixture, + promptPath: string, + workingDirectory: string, + ) => Promise, + ) => Promise, + sandbox: DeepWorkerParentSandbox, +) { + return withFixture(async (fixture, promptPath, workingDirectory) => { + const home = path.join(fixture.root, "selected-home"); + const child = path.join(home, "child"); + const link = path.join(fixture.root, "home-link"); + await mkdir(child, { recursive: true }); + await symlink(child, link, "dir"); + await writeFile( + path.join(home, "config.toml"), + 'model_reasoning_summary = "none"\n', + ); + await writeFile( + path.join(fixture.root, "config.toml"), + 'model_reasoning_summary = "detailed"\n', + ); + await writeFile(promptPath, "fixture captured home settings\n"); + + for (const configuredHome of [ + `${path.relative(process.cwd(), link)}/..`, + `${link}/..`, + ]) { + const captured = await capture({}, sandbox, { + CODEX_CLI_PATH: fixture.executablePath, + CODEX_HOME: configuredHome, + }); + assert.equal(captured.reasoningSummary, "none"); + assert.equal(await realpath(captured.codexHome), await realpath(home)); + for (const saved of [captured, JSON.parse(JSON.stringify(captured))]) { + const executor = new Executor( + restore(saved, sandbox, () => ({ + ...process.env, + CODEX_HOME: fixture.root, + })), + ); + for (const kind of ["discovery", "dedup"] as const) { + for (const resumeThreadId of [undefined, "fixture-home-resume"]) { + await executor.run({ + promptPath, + workingDirectory, + kind, + resumeThreadId, + subagents: 0, + signal: new AbortController().signal, + }); + const invocation = await readJson(fixture.markerPath); + assert.equal( + await realpath(invocation.codexHome), + await realpath(home), + ); + assertConfigOverrides(invocation.argv, { + model_reasoning_summary: "none", + }); + assert.equal( + invocation.argv.includes("resume"), + resumeThreadId !== undefined, + ); + } + } + } + } + }); +} diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index f9241ff163..9acc88af82 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -1,4 +1,5 @@ import { rm } from "node:fs/promises"; +import { testHome } from "./deep_scan_home_case.ts"; import { readJson, writeJson } from "./support/json.ts"; import { assertConfigOverrides, @@ -117,6 +118,13 @@ try { await testRuntimePermissionProfileFallbackStopsAndDiscards(); await testWorkerLaunchesWithoutGlobalCodex(); await testPreflightBindsExecutableAndHomeBeforeChangingCwd(); + await testHome( + CodexSdkWorkerExecutor, + captureDeepScanExecutionSettings, + restoredDeepScanWorkerSettings, + withWorkerFixture, + trustedParentSandbox, + ); await testSdkInvocationAndThreadCapture(); await testBedrockCredentialsReachWorker(); await testArtifactServerUsesExtendedStartupTimeout(); From da43005c44e735b6d2ef62867b551caa5faa44a2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 02:25:33 +0000 Subject: [PATCH 219/251] test(scan): share the draft writer across admission fixtures --- .../tests/scan-draft-recovery-fixture.ts | 72 ++----------------- .../tests/scan-draft-writer-fixture.ts | 69 ++++++++++++++++++ .../tests-ts/api-audit-admission.test.ts | 1 + 3 files changed, 75 insertions(+), 67 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/scan-draft-writer-fixture.ts diff --git a/plugins/codex-security/mcp-app/tests/scan-draft-recovery-fixture.ts b/plugins/codex-security/mcp-app/tests/scan-draft-recovery-fixture.ts index 8f1822b9ea..e5569b90b8 100644 --- a/plugins/codex-security/mcp-app/tests/scan-draft-recovery-fixture.ts +++ b/plugins/codex-security/mcp-app/tests/scan-draft-recovery-fixture.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { temporaryDirectory } from "./support/temporary-directories.ts"; import { readJson } from "./support/json.ts"; import assert from "node:assert/strict"; @@ -8,76 +7,15 @@ import path from "node:path"; import type { TestContext } from "node:test"; import type { ScanDraftInput } from "../src/artifact-scan-draft.js"; import type { ArtifactContext } from "../src/artifact-io.js"; -import { importModule, importSource } from "./import-module.ts"; +import { importSource } from "./import-module.ts"; export const draftApi = await importSource("../src/artifact-scan-draft.ts", { absWorkingDir: import.meta.dirname, }); -const { - artifactDestination, - replaceArtifactJson, - replaceArtifactText, - saveThreatModelDocument, -} = await importModule({ - stdin: { - contents: - 'export * from "./artifact-io.ts"; export * from "./threat-model-document.ts";', - resolveDir: path.join(import.meta.dirname, "../src"), - }, -}); - -// Unit publication boundary; real lock/binding behavior is covered by the workbench integration suite. -export async function recordCodexSecurityScanDraft( - context: ArtifactContext, - input: unknown, -) { - return draftApi.recordCodexSecurityScanDraftViaWorkbench( - context, - input, - async (args: string[]) => { - const draft = await readJson(args[args.indexOf("--draft-path") + 1]); - const rawContents = await fs.readFile( - args[args.indexOf("--checkpoint-path") + 1], - "utf8", - ); - const checkpoint = JSON.parse(rawContents); - const rawName = - createHash("sha256").update(rawContents).digest("hex") + ".json"; - await replaceArtifactText( - await artifactDestination( - context, - ["checkpoints", rawName], - "raw checkpoint", - ), - rawContents, - ); - const { complete, scope, threatModel } = draft.manifest.scan; - await draftApi.saveScanDraftCheckpoint( - { ...context, layout: "worker" }, - { - scanId: checkpoint.scanId, - ...(complete === undefined ? {} : { complete }), - ...(scope === undefined ? {} : { scope }), - ...(threatModel === undefined ? {} : { threatModel }), - findings: draft.findings.findings, - coverage: draft.coverage, - }, - ); - for (const [key, name] of [ - ["findings", "findings.json"], - ["coverage", "coverage.json"], - ["manifest", "scan-manifest.json"], - ]) { - await replaceArtifactJson( - await artifactDestination(context, [name], "scan draft"), - draft[key], - ); - } - const warning = await saveThreatModelDocument(context, threatModel); - return warning === undefined ? {} : { warnings: [warning] }; - }, - ); -} +export const { recordCodexSecurityScanDraft } = await importSource( + "./scan-draft-writer-fixture.ts", + { absWorkingDir: import.meta.dirname }, +); export const scanId = "7b95abf2-dc04-47a9-9950-53b5c2057f49"; export const claimToken = "19bfba38-0913-4bd7-86ef-134e9a4d9a42"; diff --git a/plugins/codex-security/mcp-app/tests/scan-draft-writer-fixture.ts b/plugins/codex-security/mcp-app/tests/scan-draft-writer-fixture.ts new file mode 100644 index 0000000000..46d28a1c69 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/scan-draft-writer-fixture.ts @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import type { ArtifactContext } from "../src/artifact-io.js"; +import { + artifactDestination, + replaceArtifactJson, + replaceArtifactText, +} from "../src/artifact-io.ts"; +import { + recordCodexSecurityScanDraftViaWorkbench, + type ScanDraftInput, + saveScanDraftCheckpoint, +} from "../src/artifact-scan-draft.ts"; +import { saveThreatModelDocument } from "../src/threat-model-document.ts"; +import { readJson } from "./support/json.ts"; + +// Unit publication boundary; real lock/binding behavior is covered by the workbench integration suite. +export async function recordCodexSecurityScanDraft( + context: ArtifactContext, + input: ScanDraftInput, +) { + return recordCodexSecurityScanDraftViaWorkbench( + context, + input, + async (args: string[]) => { + const draft = await readJson(args[args.indexOf("--draft-path") + 1]); + const rawContents = await fs.readFile( + args[args.indexOf("--checkpoint-path") + 1], + "utf8", + ); + const checkpoint = JSON.parse(rawContents); + const rawName = + createHash("sha256").update(rawContents).digest("hex") + ".json"; + await replaceArtifactText( + await artifactDestination( + context, + ["checkpoints", rawName], + "raw checkpoint", + ), + rawContents, + ); + const { complete, scope, threatModel } = draft.manifest.scan; + const canonicalCheckpoint = { + scanId: checkpoint.scanId, + ...(complete === undefined ? {} : { complete }), + ...(scope === undefined ? {} : { scope }), + ...(threatModel === undefined ? {} : { threatModel }), + findings: draft.findings.findings, + coverage: draft.coverage, + }; + await saveScanDraftCheckpoint( + { ...context, layout: "worker" }, + canonicalCheckpoint, + ); + for (const [key, name] of [ + ["findings", "findings.json"], + ["coverage", "coverage.json"], + ["manifest", "scan-manifest.json"], + ]) { + await replaceArtifactJson( + await artifactDestination(context, [name], "scan draft"), + draft[key], + ); + } + const warning = await saveThreatModelDocument(context, threatModel); + return warning === undefined ? {} : { warnings: [warning] }; + }, + ); +} diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index 9362953b7b..723979979d 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -22,6 +22,7 @@ const bundle = await build({ new URL("../../../plugins/codex-security/mcp-app/", import.meta.url), ), contents: `export * from "./src/artifact-scan-draft.ts"; + export { recordCodexSecurityScanDraft } from "./tests/scan-draft-writer-fixture.ts"; export * from "./src/deep-scan/artifacts.ts"; export * from "./src/deep-scan/artifact-validation.ts"; export * from "./src/deep-scan/worker-runner.ts";`, From 6596ee134f0213d7ab2df60bfc1888ec1a3aedb6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 03:26:33 +0000 Subject: [PATCH 220/251] test: align integration fixtures with runtime contracts --- .../tests/test_deep_scan_compatibility.py | 5 ++++- sdk/typescript/tests-ts/build-plugin.test.ts | 3 +++ sdk/typescript/tests-ts/cli-scan-logs.test.ts | 2 +- sdk/typescript/tests-ts/cost.test.ts | 17 ++++++++++------- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py index 57f1a80901..b9b46f7266 100644 --- a/plugins/codex-security/tests/test_deep_scan_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_compatibility.py @@ -330,7 +330,10 @@ def test_reader_honors_original_context_when_present(tmp_path: Path, original: s columns = {row[1] for row in connection.execute("PRAGMA table_info(deep_scan_runs)")} if "discovery_user_context" not in columns: connection.execute("ALTER TABLE deep_scan_runs ADD COLUMN discovery_user_context TEXT") - connection.execute("UPDATE deep_scan_runs SET discovery_user_context = ?", (original,)) + connection.execute( + "UPDATE deep_scan_runs SET discovery_user_context = ?, discovery_user_context_json = NULL", + (original,), + ) connection.execute("UPDATE scans SET user_context = 'Later discussion'") observed = run_workbench( state, diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 07471cfcae..bb00b5d2ac 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -182,6 +182,7 @@ describe("bundled plugin build", () => { "package.json", "tsconfig.json", "main.ts", + "deep-scan-main.ts", "artifact-writer-main.ts", "helpers-main.ts", "server.ts", @@ -203,6 +204,8 @@ describe("bundled plugin build", () => { "scripts/reserved_artifact_paths.json", "scripts/codex_profile.mjs", "scripts/codex_profile.d.mts", + "scripts/codex_session.mjs", + "scripts/codex_session.d.mts", ]) { await cp(new URL(name, source), join(plugin, name), { recursive: true }); } diff --git a/sdk/typescript/tests-ts/cli-scan-logs.test.ts b/sdk/typescript/tests-ts/cli-scan-logs.test.ts index 176036b8da..393493f14a 100644 --- a/sdk/typescript/tests-ts/cli-scan-logs.test.ts +++ b/sdk/typescript/tests-ts/cli-scan-logs.test.ts @@ -185,7 +185,7 @@ async function attributedFixture(attributedOwner = false) { }; const logs = await readSavedScanLogs(scan, [home, originalHome]); const deps = dependencies({ - environment: { CODEX_SECURITY_STATE_DIR: state }, + environment: { CODEX_SECURITY_STATE_DIR: state, CODEX_HOME: home }, onWorkbench: () => ({ scan }), }); deps.createSecurity = () => { diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index fe03e3df44..7c888f6a58 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -502,23 +502,26 @@ describe("live scan cost tracking", () => { const tracker = costTracker(home, { maxCostUsd: 1, }); - const refresh = tracker.refresh.bind(tracker); - tracker.refresh = async () => { + const forcedReads: boolean[] = []; + tracker.setAttributionReader(async (force) => { + forcedReads.push(force === true); await new Promise((resolve) => releases.push(resolve)); - return refresh(); - }; + return null; + }); tracker.start("scan-thread"); await new Promise((resolve) => setTimeout(resolve, 350)); - expect(releases).toHaveLength(1); + expect(forcedReads).toEqual([false]); const stopped = tracker.stop(); - expect(releases).toHaveLength(2); + expect(releases).toHaveLength(1); releases[0]!(); + await waitFor(() => releases.length === 2); + expect(forcedReads).toEqual([false, true]); releases[1]!(); expect((await stopped).cost?.inputTokens).toBe(100); - expect(releases).toHaveLength(2); + expect(forcedReads).toEqual([false, true]); }); test("retries one coalesced poll after a failed refresh", async () => { From 4c0a6f3199972c08c54a03c80ffa3077fd3a807f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 04:47:35 +0000 Subject: [PATCH 221/251] Recover selected Deep Scan publication after startup failures --- .../codex-security/mcp-app/deep-scan-main.ts | 37 +- plugins/codex-security/mcp-app/server.ts | 43 +- .../mcp-app/src/artifact-context.ts | 24 +- .../mcp-app/src/deep-scan/coordinator.ts | 30 +- .../mcp-app/src/deep-scan/finalization.ts | 1 + .../src/deep-scan/recovery-settings.ts | 23 ++ .../mcp-app/tests/test_deep_scan_executor.ts | 1 - .../tests/test_deep_scan_handoff_races.ts | 4 +- .../test_deep_scan_recovery_settings.mjs | 53 +++ .../tests/test_deep_scan_store_integration.ts | 211 +++++++--- .../mcp-app/tests/test_workbench_database.ts | 4 +- .../codex-security/scripts/workbench_db.py | 20 +- .../scripts/workbench_scan_history.py | 37 +- .../scripts/workbench_scan_usage.py | 13 +- .../test_budget_selection_publication.py | 40 ++ .../tests/test_checkpoint_schema.py | 14 +- .../tests/test_deep_scan_usage_owner.py | 7 +- .../tests/test_workbench_scan_usage.py | 82 +++- .../scripts/fixtures/package-deep-codex.mjs | 39 ++ .../scripts/fixtures/package-deep-scan.mjs | 82 ++-- .../scripts/fixtures/package-deep-spawn.mjs | 14 +- sdk/typescript/src/api.ts | 114 +++++- sdk/typescript/src/cli.ts | 52 ++- sdk/typescript/src/selected-scan-recovery.ts | 378 ++++++++++++++++++ .../tests-ts/api-direct-deep-scan.test.ts | 8 + .../tests-ts/api-provider-isolation.test.ts | 30 +- .../tests-ts/deep-finalization.test.ts | 132 +++++- .../fixtures/bundle-worker-settings.mjs | 22 + sdk/typescript/tests-ts/scan-resume.test.ts | 59 +++ 29 files changed, 1341 insertions(+), 233 deletions(-) create mode 100644 sdk/typescript/src/selected-scan-recovery.ts create mode 100644 sdk/typescript/tests-ts/fixtures/bundle-worker-settings.mjs diff --git a/plugins/codex-security/mcp-app/deep-scan-main.ts b/plugins/codex-security/mcp-app/deep-scan-main.ts index b84a566b17..acf8f22b6e 100644 --- a/plugins/codex-security/mcp-app/deep-scan-main.ts +++ b/plugins/codex-security/mcp-app/deep-scan-main.ts @@ -6,7 +6,10 @@ import { startDeepScanEngine } from "./src/deep-scan/engine.js"; import { resolveDeepWorkerParentSandbox } from "./src/deep-scan/parent-sandbox.js"; import { DeepScanCoordinatorRegistry } from "./src/deep-scan/registry.js"; import { WorkbenchDeepScanStore } from "./src/deep-scan/store.js"; -import { captureDeepScanExecutionSettings } from "./src/deep-scan/recovery-settings.js"; +import { + beginDeepScanWithCapturedSettings, + captureDeepScanExecutionSettings, +} from "./src/deep-scan/recovery-settings.js"; /** Private SDK entry point. The child process isolates each scan's environment. */ export async function runCliDeepScan(): Promise { @@ -63,20 +66,24 @@ export async function runCliDeepScan(): Promise { }); if (controller.signal.aborted) throw new Error("Deep Scan canceled before coordinator startup."); - const executionSettings = await captureDeepScanExecutionSettings( - { model: input.model, reasoningEffort: input.reasoningEffort }, - parentSandbox, - process.env, - { threadId: input.threadId, startedAt: new Date().toISOString() }, - ).catch(() => null); - const run = await store.begin({ - executionSettings, - scanId: input.scanId, - threadId: input.threadId, - model: input.model, - reasoningEffort: input.reasoningEffort, - scanRoot: join(process.env.CODEX_SECURITY_STATE_DIR!, "scans"), - }); + const run = await beginDeepScanWithCapturedSettings( + () => + captureDeepScanExecutionSettings( + { model: input.model, reasoningEffort: input.reasoningEffort }, + parentSandbox, + process.env, + { threadId: input.threadId, startedAt: new Date().toISOString() }, + ), + (executionSettings) => + store.begin({ + executionSettings, + scanId: input.scanId, + threadId: input.threadId, + model: input.model, + reasoningEffort: input.reasoningEffort, + scanRoot: join(process.env.CODEX_SECURITY_STATE_DIR!, "scans"), + }), + ); if (controller.signal.aborted) throw new Error("Deep Scan canceled before coordinator startup."); let terminal = run; diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 88a17ee040..969242128a 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -27,7 +27,10 @@ import { DeepScanCoordinatorRegistry, AsyncLock, } from "./src/deep-scan/registry.js"; -import { captureDeepScanExecutionSettings } from "./src/deep-scan/recovery-settings.js"; +import { + beginDeepScanWithCapturedSettings, + captureDeepScanExecutionSettings, +} from "./src/deep-scan/recovery-settings.js"; import { startDeepScanEngine } from "./src/deep-scan/engine.js"; import { CODEX_SANDBOX_STATE_META_CAPABILITY, @@ -1006,23 +1009,27 @@ export function createCodexSecurityServer(): McpServer { .run(async () => { // A joining observer does not need a usable current home. A new run, // however, must save its original settings before creation can commit. - const executionSettings = await captureDeepScanExecutionSettings( - modelSettings, - parentSandbox, - process.env, - { threadId, startedAt: new Date().toISOString() }, - ).catch(() => null); - const begun = await deepScanStore.begin({ - executionSettings, - scanId, - targetPath, - scope: hasTarget ? (scope ?? ".") : undefined, - userContext: normalizedUserContext, - handoffClaimToken, - threadId, - ...modelSettings, - scanRoot: await scanRoot(), - }); + const begun = await beginDeepScanWithCapturedSettings( + () => + captureDeepScanExecutionSettings( + modelSettings, + parentSandbox, + process.env, + { threadId, startedAt: new Date().toISOString() }, + ), + async (executionSettings) => + deepScanStore.begin({ + executionSettings, + scanId, + targetPath, + scope: hasTarget ? (scope ?? ".") : undefined, + userContext: normalizedUserContext, + handoffClaimToken, + threadId, + ...modelSettings, + scanRoot: await scanRoot(), + }), + ); if (handoffClaimToken) { authenticatedArtifactClaims.set(begun.scanId, { claimToken: handoffClaimToken, diff --git a/plugins/codex-security/mcp-app/src/artifact-context.ts b/plugins/codex-security/mcp-app/src/artifact-context.ts index 22b87eacee..38d5253d4d 100644 --- a/plugins/codex-security/mcp-app/src/artifact-context.ts +++ b/plugins/codex-security/mcp-app/src/artifact-context.ts @@ -17,6 +17,8 @@ export type RunArtifactWorkbench = ( export interface ScanArtifactContextOptions { requireRunning?: boolean; requireClaim?: boolean; + /** Selected publication only uses the saved target contract, without reading its checkout. */ + requireCurrentTarget?: boolean; handoffClaimToken?: string; pluginRoot?: string; pythonCommand?: string; @@ -84,10 +86,13 @@ export async function createScanArtifactContext( rawRoot, "Codex Security scan artifact root", ), - repoRoot: await canonicalDirectory( - rawRepoRoot, - "Codex Security scan target root", - ), + repoRoot: + options.requireCurrentTarget === false + ? absoluteDirectory(rawRepoRoot, "Codex Security scan target root") + : await canonicalDirectory( + rawRepoRoot, + "Codex Security scan target root", + ), layout: "scan", scanId, ...defined("scope", optionalString(scan.scope)), @@ -120,10 +125,7 @@ export async function canonicalDirectory( value: string, label: string, ): Promise { - if (!value || !isAbsolute(value)) { - throw new Error(label + " must be an absolute directory."); - } - const requested = resolve(value); + const requested = absoluteDirectory(value, label); if (!(await fs.lstat(requested).catch(() => undefined))?.isDirectory()) { throw new Error(label + " is not a safe regular directory."); } @@ -134,6 +136,12 @@ export async function canonicalDirectory( } } +function absoluteDirectory(value: string, label: string): string { + if (!value || !isAbsolute(value)) + throw new Error(label + " must be an absolute directory."); + return resolve(value); +} + function requireString(value: unknown, message: string): string { const result = optionalString(value); if (!result) throw new Error(message); diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 0104023c39..ac9b13c617 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -509,7 +509,35 @@ export class DeepScanCoordinator { publish: async (...args) => { await this.options.onComplete?.(...args); }, - finish: (input) => this.options.store.finish(input), + finish: async (input) => { + try { + return await this.options.store.finish(input); + } catch (error) { + // The terminal mutation may commit even when every retry loses its + // response. Recover our committed run so parent finalization still runs. + const current = this.options.threadId + ? await this.options.store + .get(this.state.scanId, this.options.threadId) + .catch(() => undefined) + : undefined; + if ( + !this.publicationAbortController.signal.aborted && + current?.status === "succeeded" && + current.coordinatorGeneration === + this.state.coordinatorGeneration && + current.finalizationInput?.resultPath === + this.state.finalizationInput?.resultPath && + current.finalizationInput?.resultSha256 === + this.state.finalizationInput?.resultSha256 && + current.finalizationInput?.selectedAt === + this.state.finalizationInput?.selectedAt && + current.manifestPath === input.manifestPath && + current.terminalReason === input.reason + ) + return current; + throw error; + } + }, }); if (this.canceled || this.externallyFailed) return; this.parentPublicationPending = true; diff --git a/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts b/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts index 8e2fb6f4b6..6cd3d320e0 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts @@ -142,6 +142,7 @@ export async function resumeSelectedDeepScan(input: { { requireRunning: true, requireClaim: true, + requireCurrentTarget: false, handoffClaimToken: input.handoffClaimToken, pluginRoot: input.pluginRoot, }, diff --git a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts index d4e83c8d1b..02d0bc935a 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/recovery-settings.ts @@ -43,6 +43,29 @@ export interface DeepScanExecutionSettingsSnapshot { settings: DeepScanExecutionSettings; } +/** Existing runs can be joined without capturing a usable current runtime. */ +export async function beginDeepScanWithCapturedSettings( + capture: () => Promise, + begin: (settings: DeepScanExecutionSettings | null) => Promise, +): Promise { + let captureError: unknown; + const settings = await capture().catch((error: unknown) => { + captureError = error; + return null; + }); + try { + return await begin(settings); + } catch (error) { + if (settings !== null) throw error; + const detail = (failure: unknown) => + failure instanceof Error ? failure.message : String(failure); + throw new AggregateError( + [error, captureError], + `${detail(error)} Original execution settings could not be captured: ${detail(captureError)}`, + ); + } +} + export async function captureDeepScanExecutionSettings( original: Pick, parentSandbox: DeepWorkerParentSandbox, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 70111c6dda..9edd691105 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -1,4 +1,3 @@ -import { rm } from "node:fs/promises"; import { testWorkerCancellation } from "./deep_scan_cancellation_cases.ts"; import { testHome } from "./deep_scan_home_case.ts"; import { testReconstructedWorkers } from "./deep_scan_reconstructed_cases.ts"; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_handoff_races.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_handoff_races.ts index 43af8d9616..03d38012eb 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_handoff_races.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_handoff_races.ts @@ -247,7 +247,9 @@ try { } else { assert.equal( reads, - scenario === "terminal state" || userCanceled ? 1 : 3, + // A replacement observer re-reads terminal state, including the + // injected transient failure, before releasing the old coordinator. + userCanceled ? 1 : 3, ); if (acceptance) assert.equal(executions, 2); if (concurrent) { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs index 65f52dea71..526a417193 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_recovery_settings.mjs @@ -24,6 +24,7 @@ const bundle = await build({ write: false, }); const { + beginDeepScanWithCapturedSettings: beginWithCapturedSettings, captureDeepScanExecutionSettings: captureSettings, restoredDeepScanWorkerSettings: restoreSettings, loadDeepScanExecutionSettings: loadSettings, @@ -41,6 +42,58 @@ try { reasoningSummary: "detailed", serviceTier: "fast", }; + for (const source of ["missing", "invalid"]) { + const config = join(root, `${source}.toml`); + if (source === "invalid") await writeFile(config, "model = ["); + let captureFailure; + const capture = async () => { + try { + return await captureSettings( + {}, + { filesystemDenies: [] }, + { CODEX_HOME: root, CODEX_SECURITY_CONFIG_PATH: config }, + ); + } catch (error) { + captureFailure = error; + throw error; + } + }; + const startFailure = new Error( + "New runs require original execution settings.", + ); + await assert.rejects( + beginWithCapturedSettings(capture, async (captured) => { + assert.equal(captured, null); + throw startFailure; + }), + (error) => { + assert.ok(error instanceof AggregateError); + assert.deepEqual(error.errors, [startFailure, captureFailure]); + assert.ok(error.message.includes(startFailure.message)); + assert.ok(error.message.includes(captureFailure.message)); + return true; + }, + ); + const existing = { scanId: "existing-scan", status: "running" }; + assert.equal( + await beginWithCapturedSettings(capture, async (captured) => { + assert.equal(captured, null); + return existing; + }), + existing, + ); + } + const unrelatedFailure = new Error("Synthetic workbench failure."); + await assert.rejects( + beginWithCapturedSettings( + async () => settings, + async (captured) => { + assert.equal(captured, settings); + throw unrelatedFailure; + }, + ), + (error) => error === unrelatedFailure, + ); const globSandbox = (depth) => ({ filesystemDenies: ["/fixture/**/*.secret"], ...(depth === undefined ? {} : { globScanMaxDepth: depth }), diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts index a6c811b0c1..d1800efe6b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts @@ -9,7 +9,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { importModule } from "./import-module.ts"; -import type { ScanDraftInput } from "../src/artifact-scan-draft.js"; +import type { + DeepScanPublication, + ScanDraftInput, +} from "../src/artifact-scan-draft.js"; import { DeepScanCoordinator, FakeExecutor, @@ -22,11 +25,13 @@ const workbenchPath = path.join(pluginRoot, "scripts", "workbench_db.py"); const { WorkbenchDeepScanStore, createScanArtifactContext, + captureDeepScanExecutionSettings, recordCodexSecurityScanDraftViaWorkbench, } = await importModule({ stdin: { contents: `export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts"; export { createScanArtifactContext } from "./src/artifact-context.ts"; +export { captureDeepScanExecutionSettings } from "./src/deep-scan/recovery-settings.ts"; export { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.ts";`, resolveDir: mcpAppRoot, }, @@ -55,44 +60,63 @@ async function testCoordinatorCommitResponseRecovery( const attempts = new Map(); let failureWrites = 0; let committedManifest: string | undefined; - const store = new WorkbenchDeepScanStore(async (args: string[]) => { - const result = await runWorkbench(args); - if (args[0] === "fail-deep-scan") failureWrites += 1; - if (args[0] === "finish-deep-scan") { - committedManifest ??= await readFile( - args[args.indexOf("--manifest-path") + 1], - "utf8", + const store = new WorkbenchDeepScanStore( + async ( + args: string[], + input?: string, + selectFinalization = false, + withExecutionSettings = false, + signal?: AbortSignal, + releaseCoordinator = false, + ) => { + const result = await runWorkbench( + args, + input, + selectFinalization, + withExecutionSettings, + signal, + releaseCoordinator, ); - } - if ( - args[0] !== "commit-deep-scan-dedup" && - args[0] !== "finish-deep-scan" && - !( - args[0] === "upsert-deep-scan-worker" && - args[args.indexOf("--kind") + 1] === "discovery" && - args[args.indexOf("--status") + 1] === "succeeded" + // Selection commits before a parent manifest exists. This case injects lost + // replies after reducer acceptance and final publication, not selection. + if (selectFinalization) return result; + if (args[0] === "fail-deep-scan") failureWrites += 1; + if (args[0] === "finish-deep-scan") { + committedManifest ??= await readFile( + args[args.indexOf("--manifest-path") + 1], + "utf8", + ); + } + if ( + args[0] !== "commit-deep-scan-dedup" && + args[0] !== "finish-deep-scan" && + !( + args[0] === "upsert-deep-scan-worker" && + args[args.indexOf("--kind") + 1] === "discovery" && + args[args.indexOf("--status") + 1] === "succeeded" + ) ) - ) + return result; + const calls = attempts.get(args[0]) ?? []; + calls.push([...args]); + attempts.set(args[0], calls); + if ( + args[0] === "commit-deep-scan-dedup" && + (calls.length === 1 || exhaustCommitRetries) + ) { + throw Object.assign( + new Error("workbench response timed out after commit"), + { + code: "ETIMEDOUT", + }, + ); + } + // Reproduce truncated stdout after the actual SQLite mutation committed. + if (calls.length === 1 || args[0] === exhaustOperation) + return JSON.parse('{"deepScan":'); return result; - const calls = attempts.get(args[0]) ?? []; - calls.push([...args]); - attempts.set(args[0], calls); - if ( - args[0] === "commit-deep-scan-dedup" && - (calls.length === 1 || exhaustCommitRetries) - ) { - throw Object.assign( - new Error("workbench response timed out after commit"), - { - code: "ETIMEDOUT", - }, - ); - } - // Reproduce truncated stdout after the actual SQLite mutation committed. - if (calls.length === 1 || args[0] === exhaustOperation) - return JSON.parse('{"deepScan":'); - return result; - }); + }, + ); try { await mkdir(path.join(environment.CODEX_HOME, "codex-security"), { recursive: true, @@ -103,6 +127,11 @@ async function testCoordinatorCommitResponseRecovery( ); const threadId = "commit-response-owner"; const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, threadId, scanRoot: path.join(fixtureRoot, "scans"), @@ -118,7 +147,11 @@ async function testCoordinatorCommitResponseRecovery( threadId, executor: new FakeExecutor(), heartbeatIntervalMs: 60_000, - onComplete: async (draft: ScanDraftInput, signal: AbortSignal) => { + onComplete: async ( + draft: ScanDraftInput, + signal: AbortSignal, + publication: DeepScanPublication, + ) => { const context = await createScanArtifactContext( run.scanId, runWorkbench, @@ -132,6 +165,7 @@ async function testCoordinatorCommitResponseRecovery( draft, runWorkbench, signal, + publication, ); }, }); @@ -204,12 +238,37 @@ async function createWorkbenchFixture(prefix: string) { function createWorkbenchRunner(environment: NodeJS.ProcessEnv, bounded = true) { const python = process.env.PYTHON?.trim() || "python3"; - return async (args: string[]) => { - const { stdout } = await execFileAsync(python, [workbenchPath, ...args], { + return async ( + args: string[], + input?: string, + selectFinalization = false, + withExecutionSettings = false, + signal?: AbortSignal, + releaseCoordinator = false, + ) => { + const invocation = releaseCoordinator + ? "release_coordinator=True" + : selectFinalization + ? "select_finalization=True" + : withExecutionSettings + ? "with_execution_settings=True" + : undefined; + const pythonArgs = invocation + ? [ + "-c", + `import runpy, sys; script = sys.argv.pop(1); runpy.run_path(script)['main'](${invocation})`, + workbenchPath, + ...args, + ] + : [workbenchPath, ...args]; + const execution = execFileAsync(python, pythonArgs, { cwd: pluginRoot, env: environment, + signal, ...(bounded ? { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 } : {}), }); + execution.child.stdin?.end(input); + const { stdout } = await execution; return JSON.parse(stdout); }; } @@ -221,6 +280,11 @@ async function testFreeformFailureMessagesAgainstRealWorkbench() { const store = new WorkbenchDeepScanStore(createWorkbenchRunner(environment)); try { const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, threadId: "error-owner", scanRoot: path.join(fixtureRoot, "scans"), @@ -255,6 +319,11 @@ async function testRecoveredPublicationRejectsLateFailure() { try { const store = new WorkbenchDeepScanStore(runWorkbench); const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, scope: ".", threadId: "publication-failure-owner", @@ -324,6 +393,11 @@ async function testNoopStoppedRefreshRetainsPublicationFailure() { try { const store = new WorkbenchDeepScanStore(runWorkbench); const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, scope: ".", threadId: "noop-publication-owner", @@ -393,6 +467,11 @@ async function testConcurrentParentDraftsPreserveBothCheckpoints() { }; try { const run = await new WorkbenchDeepScanStore(rawRunWorkbench).begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, scope: ".", threadId: "concurrent-draft-owner", @@ -450,6 +529,11 @@ async function testLateParentDraftPreservesCheckpointWithoutOverwritingTerminalS const runWorkbench = createWorkbenchRunner(environment); try { const run = await new WorkbenchDeepScanStore(runWorkbench).begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, scope: ".", threadId: "checkpoint-owner", @@ -523,18 +607,23 @@ async function testReducerCommitAndFinishAgainstRealWorkbench( const python = process.env.PYTHON?.trim() || "python3"; const finishCalls: string[][] = []; const executeWorkbench = createWorkbenchRunner(environment); - const runWorkbench = mock.fn(async (args: string[]) => { - const result = await executeWorkbench(args); - if (args[0] === "finish-deep-scan") { - finishCalls.push([...args]); - if (finishCalls.length === 1) - throw Object.assign( - new Error("Synthetic lost committed finish response"), - { code: "ETIMEDOUT" }, - ); - } - return result; - }); + const runWorkbench = mock.fn( + async ( + args: string[], + ...rest: [string?, boolean?, boolean?, AbortSignal?, boolean?] + ) => { + const result = await executeWorkbench(args, ...rest); + if (args[0] === "finish-deep-scan") { + finishCalls.push([...args]); + if (finishCalls.length === 1) + throw Object.assign( + new Error("Synthetic lost committed finish response"), + { code: "ETIMEDOUT" }, + ); + } + return result; + }, + ); const store = new WorkbenchDeepScanStore(runWorkbench); try { @@ -551,6 +640,11 @@ max_time_hours = 2.5 ); const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, scope: ".", threadId, @@ -565,6 +659,11 @@ max_time_hours = 2.5 assert.equal(owned.run.coordinatorGeneration, 2); const observer = new WorkbenchDeepScanStore(runWorkbench); const joined = await observer.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), scanId: run.scanId, threadId, scanRoot, @@ -764,6 +863,11 @@ connection.rollback()`, assert.equal(finished.manifestPath, manifestPath); const continued = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, scope: ".", threadId: "deep-scan-store-continuation-thread", @@ -821,6 +925,11 @@ async function testExpiredDeadlineWithoutCompletedDiscoveryAgainstRealWorkbench( ); const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, scope: ".", threadId, diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_database.ts b/plugins/codex-security/mcp-app/tests/test_workbench_database.ts index 1d120487f9..5b742556f7 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_database.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_database.ts @@ -796,8 +796,8 @@ test("original Deep history preserves saved inputs and applies local embedding m applyMigrations(database); assert.deepEqual( - database.prepare("SELECT * FROM deep_scan_runs").get(), - original, + { ...database.prepare("SELECT * FROM deep_scan_runs").get() }, + { ...original, discovery_user_context_json: null }, ); assert.deepEqual( database.prepare("SELECT * FROM local_finding_embeddings").all(), diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 14f7138737..6f45ba1fd4 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1801,7 +1801,9 @@ def set_scan_cost_limit(connection: sqlite3.Connection, args: argparse.Namespace return {"scanId": scan["id"], "maxCostUsd": limit} -def parse_scan_recipe(value: str, repository: Path) -> dict[str, Any]: +def parse_scan_recipe( + value: str, repository: Path, *, validate_current_target: bool = True +) -> dict[str, Any]: try: recipe = json.loads(value, parse_constant=reject_non_finite_json) except (TypeError, UnicodeError, ValueError) as exc: @@ -1811,7 +1813,12 @@ def parse_scan_recipe(value: str, repository: Path) -> dict[str, Any]: requested_repository = recipe.get("repository") if ( not isinstance(requested_repository, str) - or require_target(requested_repository) != repository + or ( + require_target(requested_repository) + if validate_current_target + else Path(requested_repository) + ) + != repository ): raise SystemExit("Scan launch recipe repository must match the scanned repository.") if recipe.get("mode") not in {"standard", "deep"}: @@ -1840,8 +1847,13 @@ def parse_scan_recipe(value: str, repository: Path) -> dict[str, Any]: or candidate.is_absolute() or ".." in candidate.parts or "\\" in path - or not (repository / candidate).exists() - or not (repository / candidate).resolve().is_relative_to(repository) + or ( + validate_current_target + and ( + not (repository / candidate).exists() + or not (repository / candidate).resolve().is_relative_to(repository) + ) + ) ): raise SystemExit("Scan launch recipe target paths must exist inside the repository.") if target["kind"] in {"refs", "working_tree"}: diff --git a/plugins/codex-security/scripts/workbench_scan_history.py b/plugins/codex-security/scripts/workbench_scan_history.py index 88b53ff184..755a0ba6d4 100644 --- a/plugins/codex-security/scripts/workbench_scan_history.py +++ b/plugins/codex-security/scripts/workbench_scan_history.py @@ -90,20 +90,28 @@ def cli_scan_resume( ) ): raise SystemExit("This Deep Scan has stopped and cannot resume.") - try: - repository = require_scan_target_identity(scan) - except SystemExit as exc: - raise SystemExit( - "Cannot resume: the original checkout is missing or was replaced." - ) from exc - if scan_target_identity(repository, None) != ( - scan["target_revision"], - scan["target_snapshot_digest"], - scan["target_device"], - scan["target_inode"], - ): - raise SystemExit("Cannot resume: the original checkout revision or contents changed.") - recipe = wb.parse_scan_recipe(scan["recipe_json"], repository) + selected = run is not None and run["finalization_input_json"] is not None + if selected: + # Publication uses the frozen selection and saved contract. The normal + # completion command reports checkout drift without restarting analysis. + repository = Path(scan["target_path"]) + else: + try: + repository = require_scan_target_identity(scan) + except SystemExit as exc: + raise SystemExit( + "Cannot resume: the original checkout is missing or was replaced." + ) from exc + if scan_target_identity(repository, None) != ( + scan["target_revision"], + scan["target_snapshot_digest"], + scan["target_device"], + scan["target_inode"], + ): + raise SystemExit("Cannot resume: the original checkout revision or contents changed.") + recipe = wb.parse_scan_recipe( + scan["recipe_json"], repository, validate_current_target=not selected + ) scan_dir = wb.require_canonical_scan_directory(Path(scan["scan_dir"])) progress = connection.execute( "SELECT scope_file_count FROM scan_progress WHERE scan_id = ?", (scan["id"],) @@ -114,6 +122,7 @@ def cli_scan_resume( "scanDir": str(scan_dir), "scanId": scan["id"], "scopeFileCount": progress["scope_file_count"], + "selectedFinalization": selected, "startedAt": scan["started_at"], "targetId": scan["target_id"], "targetRevision": scan["target_revision"], diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index e80822ea2f..24251b4402 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -4,6 +4,7 @@ import argparse import json +import math import os import re import sqlite3 @@ -729,6 +730,7 @@ def _read_rollout_usage( warnings: set[str] = set() previous = _empty_token_usage() boundary_reached = False + session_started_at: datetime | None = None usage_observed = False current_turn_id: str | None = None current_model: str | None = None @@ -764,6 +766,7 @@ def _read_rollout_usage( if event.get("type") != "session_meta" or not isinstance(payload, dict): warnings.add("thread_identity_mismatch") return total, warnings + session_started_at = _timestamp(payload.get("timestamp")) recorded_id = payload.get("id") or payload.get("session_id") if recorded_id != session.thread_id: warnings.add("thread_identity_mismatch") @@ -787,7 +790,7 @@ def _read_rollout_usage( ): current_turn_id = payload.get("turn_id") if not boundary_reached: - if _is_owned_task_start(session.thread_id, event, payload): + if _is_owned_task_start(session.thread_id, event, payload, session_started_at): task_started_at = _timestamp(event.get("timestamp")) if task_started_at is None: warnings.add("thread_ownership_unavailable") @@ -929,6 +932,7 @@ def _is_owned_task_start( thread_id: str, event: Mapping[str, Any], payload: Mapping[str, Any], + session_started_at: datetime | None, ) -> bool: if event.get("type") != "event_msg" or payload.get("type") != "task_started": return False @@ -939,7 +943,12 @@ def _is_owned_task_start( thread_order = _uuid7_order(thread_id) turn_order = _uuid7_order(turn_id) if thread_order is None: - return True + task_started_at = payload.get("started_at") + return ( + type(task_started_at) in {int, float} + and session_started_at is not None + and task_started_at >= math.floor(session_started_at.timestamp()) + ) return turn_order is not None and turn_order >= thread_order diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index cddbcbea44..255c41caf0 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -2,6 +2,7 @@ import hashlib import json +import shutil import sqlite3 import subprocess import sys @@ -868,3 +869,42 @@ def commit(self): completed = workbench_api["complete_budget_exhausted_scan"](connection, args) assert completed["scan"]["scanId"] == scan.scan_id assert connection.execute("SELECT status FROM scans").fetchone()[0] == "complete" + + +@pytest.mark.parametrize("selected", [False, True]) +@pytest.mark.parametrize("drift", ["edited", "moved", "deleted"]) +def test_selected_resume_retains_saved_scope_when_checkout_changes( + workbench_api, workbench_db, publication_scan, selected, drift +): + scan = publication_scan(scope="subdir") + thread_id = "a23e657b-c14c-4da7-bd20-baa9e7579390" + workbench_api["set_scan_thread"]( + workbench_db, Namespace(scan_id=scan.scan_id, thread_id=thread_id) + ) + if selected: + _, accepted, _ = accept_reducer(workbench_db, scan) + saved_selection(workbench_db, scan, accepted) + saved_scan = workbench_api["require_scan"](workbench_db, scan.scan_id) + repository = Path(saved_scan["target_path"]) + if drift == "edited": + (repository / "subdir" / "extract.py").write_text("# Changed after selection\n") + elif drift == "moved": + repository.rename(repository.with_name("moved")) + else: + shutil.rmtree(repository) + args = ( + workbench_api["_WORKBENCH_DB_CONTEXT"], + workbench_db, + saved_scan, + workbench_api["require_workspace"](workbench_db, saved_scan["workspace_id"]), + ) + if not selected: + with pytest.raises(SystemExit, match="Cannot resume"): + workbench_api["scan_history"].cli_scan_resume(*args) + return + resumed = workbench_api["scan_history"].cli_scan_resume(*args) + assert resumed["selectedFinalization"] is True + assert resumed["threadId"] == thread_id + assert resumed["recipe"]["repository"] == str(repository) + assert resumed["recipe"]["target"] == {"kind": "paths", "paths": ["subdir"]} + assert resumed["targetRevision"] == saved_scan["target_revision"] diff --git a/plugins/codex-security/tests/test_checkpoint_schema.py b/plugins/codex-security/tests/test_checkpoint_schema.py index ab4e5f9ff9..be44a6426c 100644 --- a/plugins/codex-security/tests/test_checkpoint_schema.py +++ b/plugins/codex-security/tests/test_checkpoint_schema.py @@ -10,6 +10,11 @@ @pytest.mark.parametrize("upgrade", [False, True], ids=["fresh", "upgrade"]) def test_frozen_checkpoint_head_migration_preserves_scan_state(workbench_api, upgrade): migrations = workbench_api["MIGRATIONS"] + checkpoint_version = next( + version + for version, name, _ in migrations + if name == "freeze stopped scan checkpoint selections" + ) timestamp = "2026-07-01T00:00:00Z" def migrate(connection, selected): @@ -22,7 +27,9 @@ def migrate(connection, selected): connection.execute("PRAGMA foreign_keys = ON") migrate( connection, - tuple(item for item in migrations if item[0] != 50) if upgrade else migrations, + tuple(item for item in migrations if item[0] != checkpoint_version) + if upgrade + else migrations, ) connection.execute( "INSERT INTO workspaces (id, created_at, updated_at) VALUES (?, ?, ?)", @@ -76,7 +83,8 @@ def migrate(connection, selected): assert [ tuple(row) for row in connection.execute( - "SELECT version, name FROM schema_migrations WHERE version = 50" + "SELECT version, name FROM schema_migrations WHERE version = ?", + (checkpoint_version,), ) - ] == [(50, "freeze stopped scan checkpoint selections")] + ] == [(checkpoint_version, "freeze stopped scan checkpoint selections")] assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] diff --git a/plugins/codex-security/tests/test_deep_scan_usage_owner.py b/plugins/codex-security/tests/test_deep_scan_usage_owner.py index d848bc9ab7..9d714ace8f 100644 --- a/plugins/codex-security/tests/test_deep_scan_usage_owner.py +++ b/plugins/codex-security/tests/test_deep_scan_usage_owner.py @@ -138,10 +138,13 @@ def test_migrated_owner_accounting_survives_first_worker_attempt( )["deepScan"] database = state / "workbench.sqlite3" if migrated: - # Migration 51 adds a nullable owner to pre-existing runs, without an original turn binding. + # The usage-owner migration adds a nullable owner without an original turn binding. with sqlite3.connect(database) as connection: connection.execute("ALTER TABLE deep_scan_runs DROP COLUMN usage_owner_json") - connection.execute("DELETE FROM schema_migrations WHERE version = 51") + connection.execute( + "DELETE FROM schema_migrations WHERE name = ?", + ("bind original deep scan parent usage turn",), + ) run_workbench( state, "get-deep-scan", diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index fc265b6705..a0a338d006 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -168,17 +168,34 @@ def _rollout( source: Any = "cli" if parent_thread_id is not None: source = {"subagent": {"thread_spawn": {"parent_thread_id": parent_thread_id}}} + own_started_at = ( + task_started_at.isoformat().replace("+00:00", "Z") + if task_started_at is not None + else next((item["timestamp"] for item in events if "timestamp" in item), None) + ) records: list[dict[str, Any]] = [ { "type": "session_meta", - "payload": {"id": recorded_thread_id or thread_id, "source": source}, + "payload": { + "id": recorded_thread_id or thread_id, + "source": source, + "timestamp": own_started_at, + }, }, *(copied_events or []), ] if parent_thread_id is not None and include_task_start: event: dict[str, Any] = { "type": "event_msg", - "payload": {"type": "task_started", "turn_id": f"turn-{thread_id}"}, + "payload": { + "type": "task_started", + "turn_id": f"turn-{thread_id}", + "started_at": int( + datetime.fromisoformat(own_started_at.replace("Z", "+00:00")).timestamp() + ) + if own_started_at is not None + else None, + }, } if include_task_start_timestamp: event["timestamp"] = ( @@ -1484,3 +1501,64 @@ def test_owned_legacy_model_survives_unrelated_first_receipt( assert counts == _counts(1000, 0, 10) assert warnings == set() assert models == {"gpt-5.6-sol": _counts(1000, 0, 10)} + + +@pytest.mark.parametrize("thread_id", ["legacy-worker", "12345678-1234-4234-8234-123456789abc"]) +def test_legacy_worker_usage_excludes_replayed_parent_turns( + tmp_path: Path, workbench_api, thread_id: str +) -> None: + reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + created = start + timedelta(seconds=10, microseconds=500_000) + rollout = _rollout( + tmp_path, + thread_id, + [_token_event(created + timedelta(seconds=1), 130, 13)], + parent_thread_id="parent", + task_started_at=created, + copied_events=[ + _event( + created, + "event_msg", + { + "type": "task_started", + "turn_id": "parent-turn", + "started_at": int(start.timestamp()), + }, + ), + _token_event(created, 100, 10), + ], + ) + total, warnings = reader._read_rollout_usage( + reader.RolloutSession(thread_id, "parent", rollout), started_at=start, completed_at=None + ) + assert total == _counts(30, 0, 3) + assert warnings == set() + + +@pytest.mark.parametrize( + "missing", ["session timestamp", "task started_at", "boolean task started_at"] +) +def test_legacy_worker_usage_requires_turn_ownership_timestamps( + tmp_path: Path, workbench_api, missing: str +) -> None: + reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + rollout = _rollout( + tmp_path, "legacy-worker", [_token_event(start, 100, 10)], parent_thread_id="parent" + ) + records = [json.loads(line) for line in rollout.read_text().splitlines()] + if missing == "session timestamp": + records[0]["payload"].pop("timestamp") + elif missing == "task started_at": + records[1]["payload"].pop("started_at") + else: + records[1]["payload"]["started_at"] = True + rollout.write_text("".join(json.dumps(record) + "\n" for record in records)) + total, warnings = reader._read_rollout_usage( + reader.RolloutSession("legacy-worker", "parent", rollout), + started_at=start, + completed_at=None, + ) + assert total == _counts(0, 0, 0) + assert warnings == {"thread_ownership_unavailable"} diff --git a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs index 53c8b1ac87..6948ec7f1d 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-codex.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-codex.mjs @@ -35,6 +35,45 @@ async function run() { case "initialize": result = { userAgent: "package-fixture" }; break; + case "thread/start": + result = { + thread: { + id: process.env.PACKAGE_DEEP_PARENT_THREAD, + path: process.env.PACKAGE_DEEP_TRACE + ".session.jsonl", + }, + model: "gpt-5.5", + reasoningEffort: "high", + }; + break; + case "thread/inject_items": + assert.equal( + message.params.threadId, + process.env.PACKAGE_DEEP_PARENT_THREAD, + ); + await writeFile( + process.env.PACKAGE_DEEP_TRACE + ".session.jsonl", + JSON.stringify({ + type: "turn_context", + payload: { + permission_profile: { + type: "managed", + file_system: { + type: "restricted", + entries: [ + { + path: { type: "special", value: { kind: "root" } }, + access: "read", + }, + ], + }, + network: "restricted", + }, + }, + }) + "\n", + { mode: 0o600 }, + ); + result = {}; + break; case "config/read": result = { config: { diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index f00e9773aa..b66efc36ce 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -59,8 +59,10 @@ try { ? "package-codex.exe" : "package-deep-codex.mjs", ); - if (process.platform === "win32") + if (process.platform === "win32") { await copyFile(process.execPath, executable); + await import(pathToFileURL(join(root, "package-deep-spawn.mjs")).href); + } await chmod(executable, 0o700); await runInstalledSdk(installedPlugin, executable); @@ -369,9 +371,11 @@ async function runInstalledSdk(pluginRoot, executable) { await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), ); const owner = "package-sdk-owner"; + f.env.PACKAGE_DEEP_PARENT_THREAD = owner; const postScanPrompt = "Explain the completed synthetic scan."; const prompts = []; let threadCount = 0; + let resumedThreads = 0; let manifestBeforeFollowUp; let scanId; const client = new sdk.CodexSecurity( @@ -391,60 +395,45 @@ async function runInstalledSdk(pluginRoot, executable) { version: manifest.version, }, }), - // Replace only the parent model's tool choice. The installed SDK registers - // and finalizes the scan; the packaged MCP runs the real Deep lifecycle. - createCodex({ env, apiKey }) { + // The native control session starts the real packaged engine. Only the + // post-scan model response is synthetic, after authoritative completion. + createCodex({ env }) { return { startThread() { threadCount += 1; return { - id: owner, + id: null, + async runStreamed() { + throw new Error( + "The direct Deep Scan must not start a parent model turn.", + ); + }, + }; + }, + resumeThread(threadId) { + assert.equal(threadId, owner); + resumedThreads += 1; + return { + id: threadId, async runStreamed(prompt) { prompts.push(prompt); + assert.equal(prompt, postScanPrompt); + manifestBeforeFollowUp = await readFile( + join(env.CODEX_SECURITY_SCAN_DIR, "scan-manifest.json"), + "utf8", + ); + const completed = JSON.parse(manifestBeforeFollowUp); + assert.equal(completed.scan.status, "completed"); + assert.ok(completed.scan.sealedAt); return { events: (async function* () { yield { type: "thread.started", thread_id: owner }; - if (prompts.length > 1) { - assert.equal(prompt, postScanPrompt); - manifestBeforeFollowUp = await readFile( - join(env.CODEX_SECURITY_SCAN_DIR, "scan-manifest.json"), - "utf8", - ); - const completed = JSON.parse(manifestBeforeFollowUp); - assert.equal(completed.scan.status, "completed"); - assert.ok(completed.scan.sealedAt); - yield { - type: "turn.completed", - usage: { - input_tokens: 100_000, - cached_input_tokens: 0, - output_tokens: 100_000, - }, - }; - return; - } - scanId = env.CODEX_SECURITY_SCAN_ID; - // The pinned SDK maps its apiKey option to this child variable. - const rpc = await server(f, { - ...env, - ...(apiKey ? { CODEX_API_KEY: apiKey } : {}), - }); - try { - const result = await rpc.call( - "start_codex_security_deep_scan", - { scanId }, - metadata(f, owner), - ); - await assertDraft(result.manifestPath); - } finally { - await rpc.close(); - } yield { type: "turn.completed", usage: { - input_tokens: 1, + input_tokens: 100_000, cached_input_tokens: 0, - output_tokens: 1, + output_tokens: 100_000, }, }; })(), @@ -459,6 +448,9 @@ async function runInstalledSdk(pluginRoot, executable) { try { const result = await client.run(f.target, { mode: "deep", + onScanRegistered: (scan) => { + scanId = scan.scanId; + }, auth: "api-key", workers: 1, subagents: 0, @@ -468,8 +460,8 @@ async function runInstalledSdk(pluginRoot, executable) { outputDir: join(f.directory, "output"), }); assert.equal(threadCount, 1); - assert.equal(prompts.length, 2); - assert.equal(prompts[1], postScanPrompt); + assert.equal(resumedThreads, 1); + assert.deepEqual(prompts, [postScanPrompt]); assert.equal(result.threadId, owner); assert.equal(result.manifest.scan.status, "completed"); assert.ok(result.manifest.scan.sealedAt); @@ -489,7 +481,7 @@ async function runInstalledSdk(pluginRoot, executable) { await client.close(); } await assertSavedState(f, scanId, owner); - await assertExecutions(f, scanId, 4); + await assertExecutions(f, scanId, 5); } async function assertSavedState(f, scanId, owner) { diff --git a/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs b/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs index e594405586..ada6bcf0c6 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-spawn.mjs @@ -6,15 +6,21 @@ import { fileURLToPath } from "node:url"; // Windows cannot execute the POSIX fixture's shebang. Preserve the selected // native executable and its options, inserting only the deterministic protocol // script, as in the worker launch tests. Every other child runs unchanged. -const executable = win32.toNamespacedPath(process.env.PACKAGE_DEEP_EXECUTABLE); const script = fileURLToPath( new URL("package-deep-codex.mjs", import.meta.url), ); const spawn = childProcess.spawn; -childProcess.spawn = (command, args, options) => - spawn( +childProcess.spawn = (command, args, options) => { + const executable = + options?.env?.PACKAGE_DEEP_EXECUTABLE ?? + process.env.PACKAGE_DEEP_EXECUTABLE; + return spawn( command, - win32.toNamespacedPath(command) === executable ? [script, ...args] : args, + executable && + win32.toNamespacedPath(command) === win32.toNamespacedPath(executable) + ? [script, ...args] + : args, options, ); +}; syncBuiltinESMExports(); diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 8280f64904..247eafe1bf 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -4,6 +4,7 @@ import { scanPreflightCodexConfig } from "./preflight-config.js"; import { captureOriginalReasoningSummary } from "./reasoning-summary.js"; export { scanPreflightCodexConfig } from "./preflight-config.js"; import { resumeSelectedDeepScan } from "./deep-scan-finalization.js"; +import { recoverSelectedScanStartup } from "./selected-scan-recovery.js"; import { auditEvidence, runAcceptedAudit, @@ -145,6 +146,7 @@ import { CodexSecurityError, ConfigurationError, IncompleteScanError, + InvalidTargetError, OutputDirectoryError, OutputDirectoryNotEmptyError, errorMessage, @@ -1263,6 +1265,58 @@ export class CodexSecurity { this.#dependencies.prepareScanArtifactRestorer ?? prepareScanArtifactRestorer; const workbench = this.#dependencies.runWorkbench ?? runWorkbench; + let recoveryTarget: NormalizedTarget | undefined; + const recoverStartup = async ( + startupError: unknown, + ): Promise => { + const recovered = await recoverSelectedScanStartup({ + repository, + normalizedTarget: recoveryTarget, + options, + config: this.config, + environment: this.#dependencies.environment, + signal, + startupError, + workbench, + python: this.#dependencies.resolvePluginPython ?? resolvePluginPython, + warn: notifyObserver(options, "onWarning"), + registered: (scan, command, threadId) => { + activeScan = { id: scan.scanId, options: command, mode: "deep" }; + observedScanThreadId = threadId; + selectedDeepFinalization = true; + scanDir = scan.scanDir; + notifyObserver(options, "onOutputDirReady")(scan.scanDir); + notifyObserver(options, "onScanRegistered")(scan); + }, + cost: (cost) => + notifyObserver(options, "onCost")(cost, options.maxCostUsd), + collect: async (context) => + collectResult( + { + ...context.turnResult, + finalResponse: ( + await readScanFile( + context.scanDir, + "report.md", + "report.md", + signal, + ) + ).toString("utf8"), + }, + context.threadId, + context.scanDir, + context.pluginRoot, + context.expectation, + signal, + true, + context.python, + context.expectation.repository, + ), + }); + if (recovered === null) throw startupError; + activeScan = null; + return recovered; + }; const recoverCompletedScan = async ( commandOptions: WorkbenchCommandOptions, scanId: string, @@ -1289,16 +1343,26 @@ export class CodexSecurity { // Workflows reuse the prepared prompts and deep settings, but validate the // output only when starting new work; a completed workflow may already own it. - const inputs = - preparedInputs === undefined - ? await this.#prepareLocalInputs(repository, options, signal) - : { - ...preparedInputs, - outputDir: await prepareScanOutputDir( - options, - preparedInputs.protectedRoots, - ), - }; + let inputs: LocalScanInputs; + try { + inputs = + preparedInputs === undefined + ? await this.#prepareLocalInputs(repository, options, signal) + : { + ...preparedInputs, + outputDir: await prepareScanOutputDir( + options, + preparedInputs.protectedRoots, + ), + }; + } catch (error) { + if ( + options.resumeScanId !== undefined && + error instanceof InvalidTargetError + ) + return await recoverStartup(error); + throw error; + } const { repository: repo, target: normalized, @@ -1309,6 +1373,7 @@ export class CodexSecurity { deepScanConfiguration, prompts, } = inputs; + recoveryTarget = normalized; options = { ...options, ...prompts }; checkOpen(); let temporaryRoot: string | undefined; @@ -1332,13 +1397,20 @@ export class CodexSecurity { } checkOpen(); - const session = await this.#prepareSession( - { protectedRoot }, - options, - signal, - temporaryRoot, - mode === "deep", - ); + let session: PreparedSession; + try { + session = await this.#prepareSession( + { protectedRoot }, + options, + signal, + temporaryRoot, + mode === "deep", + ); + } catch (error) { + if (options.resumeScanId !== undefined) + return await recoverStartup(error); + throw error; + } const { runtime, runtimeHome, @@ -1809,8 +1881,10 @@ export class CodexSecurity { savedSession === null || savedSession.workingDirectory !== scanDir ) { - throw new CodexSecurityError( - `The original Codex session for scan ${scanId} is unavailable. Restore its session logs in the original Codex Security state directory before resuming.`, + return await recoverStartup( + new CodexSecurityError( + `The original Codex session for scan ${scanId} is unavailable. Restore its session logs in the original Codex Security state directory before resuming.`, + ), ); } if (typeof registration["sealedProducerVersion"] === "string") { @@ -1862,6 +1936,8 @@ export class CodexSecurity { } const targetRevision = registeredRevision === "unversioned" ? null : registeredRevision; + if (registration["selectedFinalization"] === true) + expectation.repositoryRevision = targetRevision; const registeredFileCount = registration["scopeFileCount"]; scopeFileCount = typeof registeredFileCount === "number" && diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 28f7dfc675..5428ce5eef 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -2266,6 +2266,7 @@ export async function main( output: scanOutputSchema("SCAN_FAILED", "SCAN_RESUME_UNAVAILABLE"), async run({ args, error: incurError, format, options }) { let scanArguments: ScanArguments; + let selectedFinalization = false; try { const saved = await dependencies.runWorkbench([ "get-cli-scan-resume", @@ -2280,6 +2281,7 @@ export async function main( "The workbench returned invalid scan resume context.", ); } + selectedFinalization = saved["selectedFinalization"] === true; scanArguments = await prepareScanArgumentsFromRecipe( saved["recipe"], saved["scanId"], @@ -2290,6 +2292,7 @@ export async function main( : undefined, }, dependencies.currentDirectory(), + selectedFinalization, ); scanArguments.resumeScanId = saved["scanId"]; scanArguments.outputDir = resolveCliPath( @@ -2311,7 +2314,12 @@ export async function main( "SCAN_RESUME_UNAVAILABLE", ); } - const outcome = await runScan(scanArguments, errorOutput, dependencies); + const outcome = await runScan( + scanArguments, + errorOutput, + dependencies, + !selectedFinalization, + ); return finishScan(outcome, format, incurError); }, }) @@ -4651,20 +4659,24 @@ export async function main( ); return undefined; } - const session = - typeof saved["threadId"] === "string" - ? await findScanSession( - codexSecurityCredentialHome( - dependencies.environment, - ), - saved["threadId"], - ) - : null; - if (session?.workingDirectory !== scanDir) { - errorOutput.write( - `codex-security: ${scan.scanId}: Original session logs are unavailable. Preserving this attempt and starting a new one.\n`, - ); - return undefined; + const selectedFinalization = + saved["selectedFinalization"] === true; + if (!selectedFinalization) { + const session = + typeof saved["threadId"] === "string" + ? await findScanSession( + codexSecurityCredentialHome( + dependencies.environment, + ), + saved["threadId"], + ) + : null; + if (session?.workingDirectory !== scanDir) { + errorOutput.write( + `codex-security: ${scan.scanId}: Original session logs are unavailable. Preserving this attempt and starting a new one.\n`, + ); + return undefined; + } } const recipe = await prepareScanArgumentsFromRecipe( saved["recipe"], @@ -4676,6 +4688,7 @@ export async function main( : undefined, }, currentDirectory, + selectedFinalization, ); const security = dependencies.createSecurity({ pluginPath: options.pluginPath, @@ -6272,6 +6285,7 @@ async function prepareScanArgumentsFromRecipe( "scanPrompt" | "scanPromptFile" | "validationPromptFile" >, directory: string, + selectedFinalization = false, ): Promise { if (recipe === undefined || !isJsonObject(recipe)) { throw new CodexSecurityError( @@ -6279,6 +6293,7 @@ async function prepareScanArgumentsFromRecipe( ); } if ( + !selectedFinalization && recipe["requiresScanPrompt"] === true && scanPrompt === undefined && scanPromptFile === undefined @@ -6288,6 +6303,7 @@ async function prepareScanArgumentsFromRecipe( ); } if ( + !selectedFinalization && recipe["validationMode"] === "custom" && validationPromptFile === undefined ) { @@ -6431,7 +6447,11 @@ async function prepareScanArgumentsFromRecipe( repository, directory, ); - if (recipe["requiresScanPrompt"] === true && !prompts.scanPrompt?.trim()) { + if ( + !selectedFinalization && + recipe["requiresScanPrompt"] === true && + !prompts.scanPrompt?.trim() + ) { throw new CodexSecurityError( "This scan used additional instructions. The --scan-prompt-file must not be empty.", ); diff --git a/sdk/typescript/src/selected-scan-recovery.ts b/sdk/typescript/src/selected-scan-recovery.ts new file mode 100644 index 0000000000..a7ba7dc25e --- /dev/null +++ b/sdk/typescript/src/selected-scan-recovery.ts @@ -0,0 +1,378 @@ +import { mkdtemp, realpath } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, resolve, sep } from "node:path"; +import type { ScanOptions } from "./api.js"; +import type { CodexSecurityConfig, JsonObject } from "./config.js"; +import { DEFAULT_CODEX_CONFIG, scanModelConfiguration } from "./config.js"; +import type { ScanExpectation } from "./contract.js"; +import { ScanCostTracker, type ScanCost } from "./cost.js"; +import { resumeSelectedDeepScan } from "./deep-scan-finalization.js"; +import { + CodexSecurityError, + ScanCostLimitExceededError, + errorMessage, +} from "./errors.js"; +import { isRecord } from "./record.js"; +import { + expandHome, + cleanupSdkDirectory, + canonicalConfigPath, + codexSecurityCredentialHome, + codexSecurityStateDirectory, + pluginMetadata, + requireOutputOutsideRepository, + resolvePluginPath, + resolvePluginPython, + runWorkbench, + validateOutputDir, + type ProcessEnvironment, + type WorkbenchCommandOptions, +} from "./runtime.js"; +import { type ScanExecutionAttribution } from "./scan-sessions.js"; +import { + DiffTarget, + enclosingGitWorktreeRoot, + resolveRepositoryPath, + type NormalizedTarget, +} from "./targets.js"; +import type { TurnResultMetadata } from "./result.js"; + +/** Recover publication only when a saved selection outlived its startup inputs. */ +export async function recoverSelectedScanStartup(input: { + repository: string; + normalizedTarget?: NormalizedTarget; + options: ScanOptions; + config: CodexSecurityConfig; + environment: ProcessEnvironment; + signal: AbortSignal; + startupError: unknown; + workbench: typeof runWorkbench; + python: typeof resolvePluginPython; + warn: (message: string, details?: { kind: "target_changed" }) => void; + registered: ( + scan: { + scanId: string; + scanDir: string; + startedAt?: string; + }, + command: WorkbenchCommandOptions, + threadId: string, + ) => void; + cost: (cost: Readonly) => void; + collect: (context: { + scanDir: string; + threadId: string; + pluginRoot: string; + python: string; + expectation: ScanExpectation; + turnResult: TurnResultMetadata; + }) => Promise; +}): Promise { + const { options, signal } = input; + if ( + options.resumeScanId === undefined || + options.mode !== "deep" || + !options.outputDir || + options.archiveExisting || + options.parentScanId !== undefined || + options.workflowId !== undefined || + options.mock + ) + return null; + signal.throwIfAborted(); + const repository = resolveRepositoryPath(input.repository); + const boundRepository = await realpath(repository).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return repository; + throw error; + }, + ); + const protectedRoot = + (await enclosingGitWorktreeRoot(boundRepository, signal)) ?? + boundRepository; + const scanDir = await validateOutputDir(options.outputDir, true); + if (scanDir === null) return null; + requireOutputOutsideRepository(protectedRoot, scanDir); + const temporaryRoot = await realpath(tmpdir()); + requireOutputOutsideRepository(protectedRoot, temporaryRoot, "temporary"); + const workspace = await mkdtemp( + join(temporaryRoot, "codex-security-recovery-"), + ); + try { + const pluginRoot = await resolvePluginPath( + input.config.pluginPath, + workspace, + signal, + ); + const plugin = await pluginMetadata(pluginRoot); + const python = await input.python({ + configuredPath: input.config.pythonPath, + environment: input.environment, + protectedRoot, + signal, + }); + const stateDirectory = codexSecurityStateDirectory(input.environment); + requireOutputOutsideRepository( + protectedRoot, + await canonicalConfigPath(stateDirectory), + ); + const command: WorkbenchCommandOptions = { + python, + pluginRoot, + protectedRoot, + signal, + environment: { + ...input.environment, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }, + }; + const saved = await input.workbench(command, [ + "get-cli-scan-resume", + "--scan-id", + options.resumeScanId, + ]); + if (saved["selectedFinalization"] !== true) return null; + const recipe = saved["recipe"]; + const target = isRecord(recipe) ? recipe["target"] : null; + const threadId = saved["threadId"]; + if ( + saved["scanId"] !== options.resumeScanId || + saved["scanDir"] !== scanDir || + !isRecord(recipe) || + recipe["repository"] !== boundRepository || + !isRecord(target) || + typeof threadId !== "string" || + !threadId || + typeof saved["targetRevision"] !== "string" + ) { + throw new CodexSecurityError( + "The workbench returned mismatched scan resume context.", + ); + } + // Match the caller's saved scope without consulting files that publication no longer reads. + const requested = options.target ?? "repository"; + const paths = Array.isArray(requested) + ? [ + ...new Set( + requested.map( + (path) => + relative( + boundRepository, + resolve(boundRepository, expandHome(path)), + ) + .split(sep) + .join("/") || ".", + ), + ), + ] + : []; + const matches = + input.normalizedTarget !== undefined + ? JSON.stringify(input.normalizedTarget) === JSON.stringify(target) + : requested === "repository" + ? target["kind"] === "repository" + : requested instanceof DiffTarget + ? target["kind"] === requested.kind && + [target["base"], target["baseRef"]].includes(requested.base) && + (requested.kind === "working_tree" || + [target["head"], target["headRef"]].includes(requested.head)) + : Array.isArray(requested) && + target["kind"] === "paths" && + JSON.stringify(paths) === JSON.stringify(target["paths"]); + if (!matches) + throw new CodexSecurityError( + "The workbench returned mismatched scan resume context.", + ); + if ( + options.expectedPluginVersion !== undefined && + options.expectedPluginVersion !== plugin.version + ) + throw new CodexSecurityError( + "The selected plugin version does not match the expected plugin version.", + ); + const deep = await input.workbench(command, [ + "get-deep-scan", + "--scan-id", + options.resumeScanId, + "--thread-id", + threadId, + ]); + const run = deep["deepScan"]; + if ( + !isRecord(run) || + !isRecord(run["finalizationInput"]) || + !["running", "succeeded"].includes(String(run["status"])) + ) + throw new CodexSecurityError( + "The saved Deep Scan selection is unavailable.", + ); + const model = scanModelConfiguration({ + ...DEFAULT_CODEX_CONFIG, + ...(isRecord(recipe["config"]) ? (recipe["config"] as JsonObject) : {}), + }).model; + input.registered( + { + scanId: options.resumeScanId, + scanDir, + ...(typeof saved["startedAt"] === "string" + ? { startedAt: saved["startedAt"] } + : {}), + }, + command, + threadId, + ); + let lowerBound: Readonly | null = null; + const tracker = new ScanCostTracker({ + codexHome: codexSecurityCredentialHome(input.environment), + model, + scanDirectory: scanDir, + ...(options.maxCostUsd === undefined + ? {} + : { + onCostLowerBound: (cost: Readonly) => { + lowerBound = cost; + }, + }), + }); + tracker.setAttributionReader(async () => { + const context = await input.workbench(command, [ + "get-scan", + "--scan-id", + options.resumeScanId!, + ]); + const scan = context["scan"]; + return isRecord(scan) && isRecord(scan["executionAttribution"]) + ? (scan["executionAttribution"] as unknown as ScanExecutionAttribution) + : undefined; + }); + tracker.start(threadId); + const snapshot = await tracker.stop().catch((error: unknown) => { + if (options.maxCostUsd !== undefined) throw error; + input.warn(`Could not track scan cost: ${errorMessage(error)}`); + return { usage: null, cost: null }; + }); + signal.throwIfAborted(); + if (snapshot.cost !== null) input.cost(snapshot.cost); + await resumeSelectedDeepScan({ + scanId: options.resumeScanId, + threadId, + pluginRoot, + signal, + runWorkbench: (args) => input.workbench(command, args), + }); + const budgetCost = snapshot.cost ?? lowerBound; + const budgetError = + options.maxCostUsd !== undefined && + budgetCost !== null && + budgetCost.estimatedUsd > options.maxCostUsd + ? new ScanCostLimitExceededError( + options.maxCostUsd, + budgetCost, + scanDir, + ) + : null; + const completionArgs = + budgetError === null + ? [ + "complete-scan", + "--scan-id", + options.resumeScanId, + ...(snapshot.cost === null + ? [] + : ["--cost-json", JSON.stringify(snapshot.cost)]), + ] + : [ + "complete-budget-exhausted-scan", + "--scan-id", + options.resumeScanId, + "--cost-json", + JSON.stringify(snapshot.cost ?? { lowerBound: budgetCost }), + `--message=${budgetError.message}`, + ]; + const preparation = + budgetError === null + ? await input.workbench(command, [ + "prepare-scan-completion", + "--scan-id", + options.resumeScanId, + ]) + : undefined; + const completion = await input + .workbench(command, completionArgs) + .catch(async (error: unknown) => { + const context = await input.workbench(command, [ + "get-scan", + "--scan-id", + options.resumeScanId!, + ]); + const scan = context["scan"]; + if ( + !isRecord(scan) || + !isRecord(scan["progress"]) || + scan["progress"]["status"] !== "complete" + ) + throw error; + return input.workbench(command, [ + "complete-scan", + "--scan-id", + options.resumeScanId!, + ]); + }); + const targetWarnings = new Set([ + ...(Array.isArray(preparation?.["targetWarnings"]) + ? preparation["targetWarnings"] + : []), + ...(Array.isArray(completion["targetWarnings"]) + ? completion["targetWarnings"] + : []), + ]); + const completed = completion["scan"]; + if (isRecord(completed) && Array.isArray(completed["warnings"])) { + for (const warning of completed["warnings"]) + if (typeof warning === "string") + input.warn( + warning, + targetWarnings.has(warning) + ? { kind: "target_changed" } + : undefined, + ); + } + if ( + options.maxCostUsd !== undefined && + (snapshot.cost === null || snapshot.cost.coverage === "partial") + ) + input.warn( + "Scan completed, but its cost limit could not be verified because model pricing or token usage is unavailable.", + ); + if (options.postScanPrompt?.trim() || options.postScanPromptFile) + input.warn( + `Could not run post-scan instructions: ${errorMessage(input.startupError)}`, + ); + return await input.collect({ + scanDir, + threadId, + pluginRoot, + python, + expectation: { + repository: recipe["repository"] as string, + repositoryRevision: + saved["targetRevision"] === "unversioned" + ? null + : saved["targetRevision"], + target: target as unknown as NormalizedTarget, + mode: "deep", + pluginVersion: + typeof saved["sealedProducerVersion"] === "string" + ? saved["sealedProducerVersion"] + : plugin.version, + }, + turnResult: { status: "completed", model, usage: snapshot.usage }, + }); + } finally { + await cleanupSdkDirectory(workspace).catch((error: unknown) => + input.warn( + `Could not clean up scan recovery files: ${errorMessage(error)}`, + ), + ); + } +} diff --git a/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts b/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts index 45b4512aa4..5988bf581e 100644 --- a/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts +++ b/sdk/typescript/tests-ts/api-direct-deep-scan.test.ts @@ -1,3 +1,4 @@ +import { runTestInSubprocess } from "./support/test-subprocess.js"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { hash } from "node:crypto"; import { execFile } from "node:child_process"; @@ -220,6 +221,13 @@ test.each(["configured", "managed"] as const)( ); test("Node retains the direct Deep Scan capability", async () => { + if ( + runTestInSubprocess( + import.meta.path, + "Node retains the direct Deep Scan capability", + ) + ) + return; const root = await temporaryDirectory(); const source = new URL("../src/deep-scan.ts", import.meta.url); const built = await Bun.build({ diff --git a/sdk/typescript/tests-ts/api-provider-isolation.test.ts b/sdk/typescript/tests-ts/api-provider-isolation.test.ts index ce74a6b961..e978421021 100644 --- a/sdk/typescript/tests-ts/api-provider-isolation.test.ts +++ b/sdk/typescript/tests-ts/api-provider-isolation.test.ts @@ -1,10 +1,11 @@ import { codexFactory } from "./support/api-events.js"; import { afterEach, expect, spyOn, test } from "bun:test"; -import { build } from "esbuild"; +import { promisify } from "node:util"; +import { nodeCommand } from "./support/shell.js"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { resolveCodexProfile, type JsonObject } from "../src/config.js"; import * as childProcess from "node:child_process"; -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { once } from "node:events"; import { existsSync } from "node:fs"; import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises"; @@ -148,23 +149,16 @@ async function bundleWorkerSettings(root: string) { import.meta.url, ), ); - const bundled = await build({ - bundle: true, - format: "esm", - platform: "node", - write: false, - define: { "import.meta.url": JSON.stringify(pathToFileURL(executor).href) }, - stdin: { - contents: - (await readFile(executor, "utf8")) + - "\nexport { workerRuntimeSettings };", - loader: "ts", - resolveDir: dirname(executor), - sourcefile: executor, - }, - }); const bundledPath = join(root, "worker-settings.mjs"); - await writeFile(bundledPath, bundled.outputFiles[0]!.contents); + // Keep compilation independent of the long-lived Bun suite's subprocess mocks + // and esbuild service; the native worker configuration under test is unchanged. + await promisify(execFile)(nodeCommand().command, [ + fileURLToPath( + new URL("./fixtures/bundle-worker-settings.mjs", import.meta.url), + ), + executor, + bundledPath, + ]); return bundledPath; } diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index ecb5953338..7c54d4219c 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawn } from "node:child_process"; -import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, expect, test } from "bun:test"; @@ -114,6 +114,12 @@ const outcomes = [ "completed-owner-suffix", "completed-owner-explicit", "restart", + "restart-missing-logs", + "restart-deleted-target", + "restart-changed-target", + "restart-corrupt-selection", + "restart-unavailable-runtime", + "restart-canceled-runtime", "canceled-before-publication", "canceled-during-publication", "canceled-during-resumed-publication", @@ -139,6 +145,17 @@ const cases: { }[] = [ ...outcomes.map((outcome) => ({ outcome })), { outcome: "restart", statusReadFault: true }, + { outcome: "restart-unavailable-runtime", initialResumeUsage: true }, + { + outcome: "restart-unavailable-runtime", + initialResumeUsage: true, + unpricedUsage: true, + }, + { + outcome: "restart-unavailable-runtime", + initialResumeUsage: true, + budgetCompletionFault: "lost", + }, ...( [ "budget-during-publication", @@ -198,7 +215,11 @@ for (const { statusReadFault, } of cases) { const resumedStop = outcome.includes("-resumed-"); - const restart = outcome === "restart" || resumedStop; + const restart = outcome.startsWith("restart") || resumedStop; + const unavailableStartup = + outcome.startsWith("restart-") && outcome !== "restart-changed-target"; + const recoveryBudget = + outcome === "restart-unavailable-runtime" && initialResumeUsage === true; const closed = outcome.startsWith("closed-"); const budgeted = outcome.startsWith("budget-"); const canceledFollowUp = outcome.endsWith("followup-canceled"); @@ -211,12 +232,14 @@ for (const { const root = await temporaryDirectory(); const repository = join(root, "repository"); const scanDir = join(root, "scan"); - const codexHome = join(root, "codex-home"); const stateDir = join(root, "state"); + const codexHome = unavailableStartup + ? join(stateDir, "codex-home") + : join(root, "codex-home"); await Promise.all([ mkdir(repository), mkdir(scanDir, { mode: 0o700 }), - mkdir(codexHome), + mkdir(codexHome, { recursive: true }), ]); await writeFile(join(repository, "extract.py"), "# Synthetic source\n"); const environment = { @@ -320,6 +343,8 @@ for (const { : ""), ); let closePromise: Promise | undefined; + let resumedStartup = false; + const warnings: string[] = []; const makeClient = () => new TestClient( {}, @@ -327,6 +352,8 @@ for (const { environment, prepareScanArtifactRestorer, prepareRuntime: async () => { + if (resumedStartup && outcome.endsWith("runtime")) + throw new Error("Synthetic original runtime unavailable"); const runtime = preparedRuntime(codexHome); const manifest = JSON.parse( await readFile( @@ -452,7 +479,8 @@ for (const { if ( args[0] === "write-scan-draft" && (outcome === "canceled-during-publication" || - outcome === "canceled-during-resumed-publication") + outcome === "canceled-during-resumed-publication" || + outcome === "restart-canceled-runtime") ) { const published = await runWorkbench(options, [ "get-deep-scan", @@ -710,7 +738,7 @@ for (const { client.run(repository, { mode: "deep", postScanPrompt: resumedStop ? undefined : followUp, - ...(budgeted ? { maxCostUsd: 0.004 } : {}), + ...(budgeted || recoveryBudget ? { maxCostUsd: 0.004 } : {}), }), ).rejects.toThrow( statusReadFault @@ -730,6 +758,21 @@ for (const { }); expect(commands).not.toContain("fail-scan"); await client.close(); + if (outcome === "restart-deleted-target") + await rm(repository, { recursive: true }); + if ( + outcome === "restart-missing-logs" || + outcome === "restart-corrupt-selection" + ) + await rm(usagePath); + if (outcome === "restart-changed-target") + await writeFile( + join(repository, "extract.py"), + "# Changed after selection\n", + ); + if (outcome === "restart-corrupt-selection") + await appendFile(selectedPath, "\n"); + resumedStartup = true; client = makeClient(); if (initialResumeUsage) await recordBudgetUsage(); } @@ -1010,14 +1053,68 @@ for (const { }); return; } + if (outcome === "restart-canceled-runtime") { + await expect( + client.run(repository, { + mode: "deep", + resumeScanId: scanId, + outputDir: scanDir, + signal: cancellation.signal, + }), + ).rejects.toBeInstanceOf(ScanInterruptedError); + const stopped = await runWorkbench( + { ...workbenchOptions!, signal: undefined }, + ["get-scan", "--scan-id", scanId], + ); + expect(stopped["scan"]).toMatchObject({ + progress: { status: "canceled" }, + }); + expect( + commands.filter((command) => command === "cancel-scan"), + ).toHaveLength(1); + expect(commands).not.toContain("fail-scan"); + expect(await readFile(selectedPath)).toEqual(selectedBytes!); + return; + } + if (outcome === "restart-corrupt-selection") { + await expect( + client.run(repository, { + mode: "deep", + resumeScanId: scanId, + outputDir: scanDir, + }), + ).rejects.toThrow("changed after acceptance"); + const pending = await runWorkbench(workbenchOptions!, [ + "get-scan", + "--scan-id", + scanId, + ]); + expect(pending["scan"]).toMatchObject({ + progress: { status: "running" }, + }); + expect(commands).not.toContain("complete-scan"); + expect(commands).not.toContain("fail-scan"); + expect(modelInputs.filter((input) => input !== followUp)).toHaveLength( + 1, + ); + return; + } const result = await client.run(repository, { mode: "deep", + ...(recoveryBudget ? { maxCostUsd: 0.004 } : {}), + onWarning: (warning) => warnings.push(warning), signal: cancellation.signal, postScanPrompt: outcome === "published-before-cancellation" ? undefined : followUp, ...(restart ? { resumeScanId: scanId, outputDir: scanDir } : {}), }); expect(result.threadId).toBe(threadId); + if (recoveryBudget) + expect(commands).toContain("complete-budget-exhausted-scan"); + if (outcome === "restart-changed-target") + expect( + warnings.some((warning) => /changed|revision/.test(warning)), + ).toBe(true); if (outcome === "lost-completion-response") { expect( commands.filter((command) => command === "complete-scan"), @@ -1027,6 +1124,10 @@ for (const { expect(nativeOwnerStatus).toBe("running"); expect(result.cost?.inputTokens).toBe(2_500); expect(result.cost?.outputTokens).toBe(60); + } else if (recoveryBudget && !unpricedUsage) { + expect(result.cost?.inputTokens).toBe(1_250); + expect(result.cost?.outputTokens).toBe(30); + expect(result.cost?.estimatedUsd).toBeGreaterThan(0.004); } else { // The synthetic accepted workers have no native usage receipts. expect(result.cost).toBeNull(); @@ -1038,8 +1139,25 @@ for (const { // postScanPrompt retains its existing behavior on each caller invocation. expect(modelInputs.filter((input) => input !== followUp).length).toBe(1); expect(modelInputs.filter((input) => input === followUp).length).toBe( - outcome === "published-before-cancellation" ? 0 : restart ? 2 : 1, + outcome === "published-before-cancellation" + ? 0 + : unavailableStartup + ? 1 + : restart + ? 2 + : 1, ); + if (unavailableStartup) { + expect( + warnings.some((warning) => + warning.includes("Could not run post-scan instructions"), + ), + ).toBe(true); + expect(await readFile(selectedPath)).toEqual(selectedBytes!); + expect(result.turnResult.finalResponse).toContain( + "Validate the resolved destination", + ); + } const completed = await runWorkbench( { ...workbenchOptions!, signal: undefined }, ["get-scan", "--scan-id", scanId], diff --git a/sdk/typescript/tests-ts/fixtures/bundle-worker-settings.mjs b/sdk/typescript/tests-ts/fixtures/bundle-worker-settings.mjs new file mode 100644 index 0000000000..b13942be8b --- /dev/null +++ b/sdk/typescript/tests-ts/fixtures/bundle-worker-settings.mjs @@ -0,0 +1,22 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const [executor, output] = process.argv.slice(2); +const bundled = await build({ + bundle: true, + format: "esm", + platform: "node", + write: false, + define: { "import.meta.url": JSON.stringify(pathToFileURL(executor).href) }, + stdin: { + contents: + (await readFile(executor, "utf8")) + + "\nexport { workerRuntimeSettings };", + loader: "ts", + resolveDir: dirname(executor), + sourcefile: executor, + }, +}); +await writeFile(output, bundled.outputFiles[0].contents); diff --git a/sdk/typescript/tests-ts/scan-resume.test.ts b/sdk/typescript/tests-ts/scan-resume.test.ts index f5d96444de..9bccf54d81 100644 --- a/sdk/typescript/tests-ts/scan-resume.test.ts +++ b/sdk/typescript/tests-ts/scan-resume.test.ts @@ -1154,6 +1154,65 @@ test("bulk Deep resume stages campaign knowledge after its source is removed", a ).toMatchObject({ knowledgeBasePaths: [document] }); }); +test.each([false, true])( + "CLI restores selected finalization without current inputs or session logs (bulk: %p)", + async (bulk) => { + const f = await interruptedScan("deep", bulk, { + postScanPrompt: "Keep the saved follow-up behavior.", + }); + await rm(f.sessionPath); + await rm(f.repository, { recursive: true }); + const { stdout, stderr, runCli } = createCliTest(main); + const calls: { repository: string; options: ScanOptions }[] = []; + const deps = dependencies({ + environment: f.environment, + currentDirectory: f.root, + onTurn(repository, options) { + calls.push({ repository, options }); + }, + }); + const code = await runCli( + bulk + ? ["bulk-scan", f.input, "--output-dir", f.root, "--recover", "--json"] + : ["scans", "resume", f.scanId, "--json"], + { + ...deps, + runWorkbench: async (args, input) => + args[0] === "get-cli-scan-resume" + ? { + ...f.registration, + scanId: f.scanId, + scanDir: f.scanDir, + threadId: f.threadId, + selectedFinalization: true, + userContext: null, + recipe: { ...f.recipe, validationMode: "custom" }, + } + : f.command(args, input), + }, + ); + expect(code, stderr.text()).toBe(0); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + repository: f.repository, + options: { + resumeScanId: f.scanId, + outputDir: f.scanDir, + postScanPrompt: "Keep the saved follow-up behavior.", + }, + }); + expect(calls[0]!.options.scanPrompt).toBeUndefined(); + expect(calls[0]!.options.validationPrompt).toBeUndefined(); + if (bulk) { + const result = JSON.parse(stdout.text()); + const receipts = await readJsonLines(result.resultsPath); + expect(receipts).toHaveLength(2); + expect(receipts[1]).toMatchObject({ attempt: 1, outputDir: f.scanDir }); + expect(stderr.text()).not.toContain("starting a new one"); + } + }, +); + test("missing session logs do not create another session or fail the original scan", async () => { const f = await interruptedScan(); await rm(f.sessionPath); From 419a8b1e5a6f03290f8bf608510233c228b1ea20 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 05:06:08 +0000 Subject: [PATCH 222/251] Resolve recorded homes before searching saved session logs --- sdk/typescript/src/scan-sessions.ts | 11 ++- .../tests-ts/scan-logs-node.test.ts | 74 ++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/scan-sessions.ts b/sdk/typescript/src/scan-sessions.ts index 91ecd90c45..ae94a08eb6 100644 --- a/sdk/typescript/src/scan-sessions.ts +++ b/sdk/typescript/src/scan-sessions.ts @@ -1,4 +1,4 @@ -import { stat } from "node:fs/promises"; +import { realpath, stat } from "node:fs/promises"; import { isAbsolute, join, relative, sep } from "node:path"; import { isRecord } from "./record.js"; @@ -6,7 +6,14 @@ export async function recordedScanCodexHome( _scanDirectory: string, attribution?: ScanExecutionAttribution | null, ): Promise { - return attribution?.workerCodexHome ?? undefined; + const home = attribution?.workerCodexHome; + if (home == null) return undefined; + try { + return await realpath(home); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } } export interface ScanExecutionAttribution { diff --git a/sdk/typescript/tests-ts/scan-logs-node.test.ts b/sdk/typescript/tests-ts/scan-logs-node.test.ts index 7cc91ce75e..08963b5321 100644 --- a/sdk/typescript/tests-ts/scan-logs-node.test.ts +++ b/sdk/typescript/tests-ts/scan-logs-node.test.ts @@ -1,6 +1,7 @@ +import { runTestInSubprocess } from "./support/test-subprocess.js"; import { execFile } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { zstdCompressSync } from "node:zlib"; @@ -93,3 +94,72 @@ test.each(["native", "without zstd"])( ]); }, ); + +for (const directory of ["sessions", "archived_sessions"]) { + const name = `Node reads recorded homes through directory links in ${directory}`; + test(name, async () => { + if (runTestInSubprocess(import.meta.path, name)) return; + const root = await temporaryDirectory(); + const current = join(root, "current"); + const original = join(root, "original"); + await mkdir(current); + await mkdir(join(original, "child"), { recursive: true }); + await mkdir(join(original, directory)); + const link = join(current, "original-home-link"); + await symlink( + join(original, "child"), + link, + process.platform === "win32" ? "junction" : "dir", + ); + const recorded = `${link}${sep}..`; + const timestamp = "2026-08-11T12:01:00.000Z"; + const event = { + type: "response_item", + timestamp, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "saved original activity" }], + }, + }; + await writeFile( + join(original, directory, "owner.jsonl"), + [ + { type: "session_meta", payload: { id: "owner" } }, + { type: "turn_context", timestamp, payload: { turn_id: "scan-turn" } }, + event, + ] + .map((value) => JSON.stringify(value) + "\n") + .join(""), + ); + const built = await Bun.build({ + entrypoints: [ + fileURLToPath(new URL("../src/scan-logs.ts", import.meta.url)), + ], + target: "node", + format: "esm", + }); + expect(built.success).toBe(true); + const modulePath = join(root, "scan-logs.mjs"); + await writeFile(modulePath, await built.outputs[0]!.text()); + const { stdout } = await promisify(execFile)("node", [ + "--input-type=module", + "--eval", + ` + import assert from "node:assert/strict"; + const { readSavedScanLogs } = await import(${JSON.stringify(pathToFileURL(modulePath).href)}); + const attribution = { formatVersion: 1, workerCodexHome: ${JSON.stringify(recorded)}, executionThreadIds: [], + owner: { threadId: "owner", turnId: "scan-turn", startedAt: ${JSON.stringify(timestamp)} }, + startedAt: ${JSON.stringify(timestamp)}, completedAt: ${JSON.stringify(timestamp)}, + }; + const logs = await readSavedScanLogs({ scanId: "scan-1", mode: "deep", scanDir: ${JSON.stringify(root)}, continuationThreadId: "owner", executionAttribution: attribution }, ${JSON.stringify(current)}); + assert.equal(logs.sessions.length, 1); + assert.equal(logs.sessions[0].path, ${JSON.stringify(join(original, directory, "owner.jsonl"))}); + assert.deepEqual(logs.events.at(-1).event, ${JSON.stringify(event)}); + assert.equal(attribution.workerCodexHome, ${JSON.stringify(recorded)}); + console.log("original logs recovered"); + `, + ]); + expect(stdout.trim()).toBe("original logs recovered"); + }); +} From 3b5726eef7aba66d70d7d4fab7e7f6e44b29f74d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 05:23:22 +0000 Subject: [PATCH 223/251] fix(package): include selected recovery runtime in artifact contract --- sdk/typescript/scripts/check-package.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 5a1064b7c3..19f121a223 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -212,6 +212,7 @@ const allowedFiles = new Set([ "security-policy-cli", "suggest-owners", "scan-sessions", + "selected-scan-recovery", "server/api", "deduplication/codex-review", "deduplication/checkpointed-review", From fe938310151263c527f004c4c4c2585757e53499 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 05:45:34 +0000 Subject: [PATCH 224/251] fix(deep-scan): replay native selections without the original checkout --- .../mcp-app/src/deep-scan/engine.ts | 3 + .../tests/test_deep_scan_store_integration.ts | 119 +++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/engine.ts b/plugins/codex-security/mcp-app/src/deep-scan/engine.ts index dc3f685ed1..f3d078cd50 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/engine.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/engine.ts @@ -101,6 +101,9 @@ export function startDeepScanEngine(options: { { requireRunning: true, requireClaim: true, + // V2 coordinators validate their durable selection before publishing. + requireCurrentTarget: + run.workflowVersion !== "deep-security-scan/v2", handoffClaimToken, pluginRoot, }, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts index d1800efe6b..cde9f7a7f6 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts @@ -5,7 +5,7 @@ import { mock } from "node:test"; import { execFile, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { once } from "node:events"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { importModule } from "./import-module.ts"; @@ -15,6 +15,7 @@ import type { } from "../src/artifact-scan-draft.js"; import { DeepScanCoordinator, + DeepScanCoordinatorRegistry, FakeExecutor, } from "./deep_scan_coordinator_fixture.ts"; @@ -27,16 +28,29 @@ const { createScanArtifactContext, captureDeepScanExecutionSettings, recordCodexSecurityScanDraftViaWorkbench, + startDeepScanEngine, } = await importModule({ stdin: { contents: `export { WorkbenchDeepScanStore } from "./src/deep-scan/store.ts"; export { createScanArtifactContext } from "./src/artifact-context.ts"; export { captureDeepScanExecutionSettings } from "./src/deep-scan/recovery-settings.ts"; -export { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.ts";`, +export { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.ts"; +export { startDeepScanEngine } from "./src/deep-scan/engine.ts";`, resolveDir: mcpAppRoot, }, + loader: { ".md": "text" }, + define: { + "import.meta.url": JSON.stringify( + new URL("../src/deep-scan/engine.ts", import.meta.url).href, + ), + }, + banner: { + js: `import { createRequire as fixtureRequire } from "node:module"; const require = fixtureRequire(${JSON.stringify(import.meta.url)});`, + }, }); +await testNativeSelectedFinalizationWithoutCheckout("deleted"); +await testNativeSelectedFinalizationWithoutCheckout("moved"); await testFreeformFailureMessagesAgainstRealWorkbench(); await testReducerCommitAndFinishAgainstRealWorkbench(); await testReducerCommitAndFinishAgainstRealWorkbench(true); @@ -49,6 +63,107 @@ await testCoordinatorCommitResponseRecovery(); await testCoordinatorCommitResponseRecovery("commit-deep-scan-dedup"); await testCoordinatorCommitResponseRecovery("finish-deep-scan"); +async function testNativeSelectedFinalizationWithoutCheckout( + targetChange: "deleted" | "moved", +) { + const { fixtureRoot, targetPath, environment } = await createWorkbenchFixture( + "deep-native-selected-recovery-", + ); + const runWorkbench = createWorkbenchRunner(environment); + const store = new WorkbenchDeepScanStore(runWorkbench); + const threadId = "selected-recovery-owner"; + try { + await mkdir(path.join(environment.CODEX_HOME, "codex-security"), { + recursive: true, + }); + await writeFile( + path.join(environment.CODEX_HOME, "codex-security", "config.toml"), + "[deep_scan]\nworkers = 1\nstop_after_no_new = 1\nmax_discovery_runs = 1\n", + ); + const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), + targetPath, + threadId, + scanRoot: path.join(fixtureRoot, "scans"), + }); + const claim = await store.claimCoordinator({ + scanId: run.scanId, + threadId, + }); + const executor = new FakeExecutor(); + const coordinator = new DeepScanCoordinator({ + run: claim.run, + store, + pluginRoot, + threadId, + executor, + onComplete: async () => { + throw new Error("Synthetic interruption before selected publication"); + }, + }); + coordinator.start(); + await assert.rejects( + coordinator.wait(undefined, 30_000), + /Synthetic interruption before selected publication/, + ); + const selected = await store.get(run.scanId, threadId); + assert.equal(selected.status, "running"); + assert.ok(selected.finalizationInput); + const savedTarget = ( + await runWorkbench(["get-scan", "--scan-id", run.scanId]) + ).scan.contract; + const workerIds = selected.persistedWorkers.map( + (worker: { id: string }) => worker.id, + ); + await store.releaseCoordinator(run.scanId); + if (targetChange === "moved") + await rename(targetPath, `${targetPath}-moved`); + else await rm(targetPath, { recursive: true }); + + let finalized = 0; + const resumed = await startDeepScanEngine({ + run: selected, + store, + registry: new DeepScanCoordinatorRegistry(), + runWorkbench, + pluginRoot, + pythonCommand: process.env.PYTHON || "python3", + parentSandbox: { filesystemDenies: [] }, + threadId, + onFinalized: async () => { + finalized += 1; + }, + }); + const terminal = await resumed.coordinator.wait(undefined, 30_000); + assert.equal(terminal?.status, "succeeded"); + assert.equal(finalized, 1); + const persisted = await store.get(run.scanId, threadId); + assert.deepEqual(persisted.finalizationInput, selected.finalizationInput); + assert.deepEqual( + persisted.persistedWorkers.map((worker: { id: string }) => worker.id), + workerIds, + ); + assert.equal(executor.discoveryCalls, 1); + assert.equal(executor.dedupCalls, 1); + const findings = await readJson(run.scanDir, "findings.json"); + assert.ok(Array.isArray(findings.findings)); + const manifest = await readJson(run.scanDir, "scan-manifest.json"); + assert.equal(persisted.targetPath, targetPath); + assert.deepEqual(manifest.scan.target, { + kind: "directory_snapshot", + targetId: savedTarget.target.targetId, + displayName: savedTarget.target.displayName, + snapshotDigest: savedTarget.target.requiredSnapshotDigest, + }); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +} + async function testCoordinatorCommitResponseRecovery( exhaustOperation?: "commit-deep-scan-dedup" | "finish-deep-scan", ) { From 492bfa7f392468e2ea3193e069c529befe446718 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 06:06:04 +0000 Subject: [PATCH 225/251] fix(resume): reach selected recovery when the knowledge checkout is missing --- sdk/typescript/src/cli.ts | 26 +++++++++--- sdk/typescript/tests-ts/scan-resume.test.ts | 47 +++++++++++++++++++-- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 2757ab0bfe..2ee166190b 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -2308,11 +2308,27 @@ export async function main( saved["scanDir"], ); if (scanArguments.knowledgeBasePaths.length > 0) { - scanArguments.knowledgeBaseSnapshot = await restoreScanKnowledge( - scanArguments.outputDir, - scanArguments.repository!, - (saved["recipe"] as JsonObject)["scanInputs"], - ); + try { + scanArguments.knowledgeBaseSnapshot = await restoreScanKnowledge( + scanArguments.outputDir, + scanArguments.repository!, + (saved["recipe"] as JsonObject)["scanInputs"], + ); + } catch (error) { + const cause = error instanceof Error ? error.cause : undefined; + // A missing original checkout can use selected artifact recovery. + if ( + !selectedFinalization || + typeof cause !== "object" || + cause === null || + !("code" in cause) || + cause.code !== "ENOENT" || + !("path" in cause) || + cause.path !== scanArguments.repository + ) { + throw error; + } + } } scanArguments.parentScanId = undefined; // Resume uses the installed engine with the saved recipe and checkpoints. diff --git a/sdk/typescript/tests-ts/scan-resume.test.ts b/sdk/typescript/tests-ts/scan-resume.test.ts index 6cd1e844c0..d37c090a29 100644 --- a/sdk/typescript/tests-ts/scan-resume.test.ts +++ b/sdk/typescript/tests-ts/scan-resume.test.ts @@ -1281,14 +1281,38 @@ test("bulk Deep resume stages campaign knowledge after its source is removed", a ).toMatchObject({ knowledgeBasePaths: [document] }); }); -test.each([false, true])( - "CLI restores selected finalization without current inputs or session logs (bulk: %p)", - async (bulk) => { +test.each([ + [false, "none", "deleted"], + [true, "none", "deleted"], + [false, "saved", "deleted"], + [false, "saved", "moved"], + [false, "saved", "available"], + [false, "modified", "available"], + [false, "missing", "available"], + [false, "missing", "deleted"], +] as const)( + "CLI restores selected finalization without current inputs or session logs (bulk: %p, knowledge: %s, checkout: %s)", + async (bulk, knowledge, checkout) => { + const document = join(await temporaryDirectory(), "architecture.md"); + await writeFile(document, "Original architecture."); const f = await interruptedScan("deep", bulk, { postScanPrompt: "Keep the saved follow-up behavior.", + ...(knowledge === "none" ? {} : { knowledgeBasePaths: [document] }), }); + await writeFile(document, "Edited source context must not be loaded."); + if (knowledge === "modified") { + const snapshotPath = join(f.scanDir, ".scan-knowledge.json"); + const snapshot = JSON.parse(await readFile(snapshotPath, "utf8")); + snapshot.documents["0-architecture.md.txt"] = "Modified saved context."; + await writeFile(snapshotPath, JSON.stringify(snapshot)); + } else if (knowledge === "missing") { + await rm(join(f.scanDir, ".scan-knowledge.json")); + } await rm(f.sessionPath); - await rm(f.repository, { recursive: true }); + if (checkout === "moved") + await rename(f.repository, `${f.repository}-moved`); + else if (checkout === "deleted") + await rm(f.repository, { recursive: true }); const { stdout, stderr, runCli } = createCliTest(main); const calls: { repository: string; options: ScanOptions }[] = []; const deps = dependencies({ @@ -1318,8 +1342,23 @@ test.each([false, true])( : f.command(args, input), }, ); + if (knowledge === "modified" || knowledge === "missing") { + expect(code, stderr.text()).toBe(2); + expect(calls).toHaveLength(0); + expect(stderr.text()).toContain( + knowledge === "modified" ? "snapshot changed" : ".scan-knowledge.json", + ); + return; + } expect(code, stderr.text()).toBe(0); expect(calls).toHaveLength(1); + if (knowledge === "saved" && checkout === "available") { + expect(calls[0]!.options.knowledgeBaseSnapshot?.documents).toEqual({ + "0-architecture.md.txt": "Original architecture.", + }); + } else { + expect(calls[0]!.options.knowledgeBaseSnapshot).toBeUndefined(); + } expect(calls[0]).toMatchObject({ repository: f.repository, options: { From d63123e8d8ea4be58422d198b9c0f3c9f47b5f2b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 06:30:56 +0000 Subject: [PATCH 226/251] fix(cost): preserve incomplete receipt epochs after compaction --- .../scripts/workbench_scan_usage.py | 13 +- .../tests/test_workbench_scan_usage.py | 99 +++++++++ sdk/typescript/src/cost.ts | 20 +- .../scan-usage-reconciliation.test.ts | 193 ++++++++++++++++++ 4 files changed, 323 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 24251b4402..53d7e1060f 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -738,6 +738,7 @@ def _read_rollout_usage( response_usage_observed = False response_tokens = 0 expected_response_tokens = 0 + response_epoch_owned = False local_models: dict[str | None, dict[str, int]] = {} with session.path.open("rb") as source: @@ -806,6 +807,15 @@ def _read_rollout_usage( if inherited_usage is not None: previous = inherited_usage continue + if event.get("type") == "compacted": + # Later receipt counters describe a new compaction epoch and cannot + # account for missing receipts from the earlier one. + if response_epoch_owned and expected_response_tokens > response_tokens: + warnings.add("token_receipts_incomplete") + response_tokens = 0 + expected_response_tokens = 0 + response_epoch_owned = False + continue if event.get("type") == "token_usage_record": response_id = payload.get("response_id") usage = _token_snapshot({"info": {"total_token_usage": payload.get("usage")}}) @@ -838,6 +848,7 @@ def _read_rollout_usage( and payload.get("turn_id", current_turn_id) != owner_turn_id ): continue + response_epoch_owned = True if not response_usage_observed: response_usage_observed = True total = _empty_token_usage() @@ -899,7 +910,7 @@ def _read_rollout_usage( _add_token_usage(local_models.setdefault(None, _empty_token_usage()), remainder) if response_usage_observed: warnings.discard("token_counter_regressed") - if expected_response_tokens > response_tokens: + if response_epoch_owned and expected_response_tokens > response_tokens: warnings.add("token_receipts_incomplete") if model_usage is not None: for model, usage in local_models.items(): diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index a0a338d006..971bb3e414 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -1329,6 +1329,105 @@ def usage(value): assert warnings == set() +@pytest.mark.parametrize("missing_receipt", [False, True]) +def test_completion_retains_child_receipt_gap_across_compaction( + tmp_path: Path, missing_receipt: bool +) -> None: + fixture = _start_scan(tmp_path) + counted = fixture.started_at + timedelta(microseconds=1) + + def receipt(thread_id: str, response: str, tokens: int, cumulative: int): + return _event( + counted, + "token_usage_record", + { + "response_id": response, + "thread_id": thread_id, + "model": "gpt-5.6-sol", + "usage": {"input_tokens": tokens, "output_tokens": 0, "total_tokens": tokens}, + "thread_token_usage": { + "input_tokens": cumulative, + "output_tokens": 0, + "total_tokens": cumulative, + }, + }, + ) + + parent = _rollout(tmp_path, "scan-parent", [receipt("scan-parent", "parent", 10, 10)]) + first = receipt("scan-worker", "first", 50 if missing_receipt else 100, 100) + second = receipt("scan-worker", "second", 100, 100) + worker = _rollout( + tmp_path, + "scan-worker", + [ + first, + _event(counted, "compacted", {"message": "Synthetic summary"}), + first, + second, + second, + ], + parent_thread_id="scan-parent", + ) + _state_graph( + fixture.environment, + {"scan-parent": parent, "scan-worker": worker}, + [("scan-parent", "scan-worker")], + ) + usage = _complete_scan(fixture)["scan"]["usage"] + expected = _counts(160 if missing_receipt else 210, 0, 0) + assert usage["totalTokens"] == expected["totalTokens"] + assert usage["modelUsage"] == [{"model": "gpt-5.6-sol", **expected}] + assert usage["coverage"] == ("partial" if missing_receipt else "complete") + assert usage.get("warnings", []) == (["token_receipts_incomplete"] if missing_receipt else []) + + +@pytest.mark.parametrize("prior_turn", [False, True]) +@pytest.mark.parametrize("outside_after", [False, True]) +def test_compaction_before_owned_receipts_does_not_taint_scan_coverage( + tmp_path: Path, workbench_api, prior_turn: bool, outside_after: bool +) -> None: + reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + + def receipt(response: str, tokens: int, timestamp: datetime, turn: str): + return _event( + timestamp, + "token_usage_record", + { + "response_id": response, + "thread_id": "parent", + "turn_id": turn, + "model": "gpt-5.6-sol", + "usage": {"input_tokens": tokens, "output_tokens": 0, "total_tokens": tokens}, + "thread_token_usage": { + "input_tokens": 100, + "output_tokens": 0, + "total_tokens": 100, + }, + }, + ) + + previous = start if prior_turn else start - timedelta(seconds=1) + if outside_after: + previous = start + timedelta(seconds=1 if prior_turn else 10) + outside = receipt("prior", 50, previous, "other-turn" if prior_turn else "scan-turn") + current = receipt("current", 100, start, "scan-turn") + compacted = _event(previous, "compacted", {"message": "Synthetic previous summary"}) + rollout = _rollout( + tmp_path, + "parent", + [current, compacted, outside] if outside_after else [outside, compacted, current], + ) + counts, warnings = reader._read_rollout_usage( + reader.RolloutSession("parent", None, rollout), + started_at=start, + completed_at=start + timedelta(seconds=5), + owner_turn_id="scan-turn", + ) + assert counts == _counts(100, 0, 0) + assert warnings == set() + + def test_exact_receipts_replace_overlapping_legacy_counter(tmp_path: Path, workbench_api) -> None: reader = sys.modules["workbench_scan_usage"] start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 168e131680..46af573b5f 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -73,6 +73,8 @@ interface SessionUsage { responseUsageObserved: boolean; responseTokens: number; expectedResponseTokens: number; + responseEpochUsageObserved: boolean; + incompleteResponseEpoch: boolean; counterRegressed: boolean; calls: Map; activities: { index: number; activity: ScanActivity }[]; @@ -133,6 +135,8 @@ function createSessionUsage(): SessionUsage { responseUsageObserved: false, responseTokens: 0, expectedResponseTokens: 0, + responseEpochUsageObserved: false, + incompleteResponseEpoch: false, counterRegressed: false, calls: new Map(), activities: [], @@ -522,7 +526,9 @@ export class ScanCostTracker { } if ( (session.counterRegressed && !session.responseUsageObserved) || - session.expectedResponseTokens > session.responseTokens + session.incompleteResponseEpoch || + (session.responseEpochUsageObserved && + session.expectedResponseTokens > session.responseTokens) ) incomplete = true; if (session.pendingLine.length > 0 && !this.#receipts.get(threadId)) @@ -848,6 +854,17 @@ function readSessionEvent( } return; } + if (event["type"] === "compacted") { + // Later counter epochs cannot account for missing earlier scan receipts. + if ( + session.responseEpochUsageObserved && + session.expectedResponseTokens > session.responseTokens + ) + session.incompleteResponseEpoch = true; + session.responseTokens = 0; + session.expectedResponseTokens = 0; + session.responseEpochUsageObserved = false; + } if ( (event["type"] === "turn_context" || payload["type"] === "task_started") && typeof payload["turn_id"] === "string" @@ -893,6 +910,7 @@ function readSessionEvent( ) ) return; + session.responseEpochUsageObserved = true; if (!session.responseUsageObserved) { // Exact receipts include compaction and survive counter resets. Keep the // legacy counter as an independent lower bound, never add it to receipts. diff --git a/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts b/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts index 13bc7e95c9..38b9d5370a 100644 --- a/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts +++ b/sdk/typescript/tests-ts/scan-usage-reconciliation.test.ts @@ -599,3 +599,196 @@ test("late exact receipts replace an overlapping legacy counter without adding i await rm(home, { recursive: true, force: true }); } }); + +test.each([ + [false, false], + [false, true], + [true, false], + [true, true], +] as const)( + "preserves receipt gaps across compaction (attributed: %p, repaired before reset: %p)", + async (attributed, repaired) => { + const home = await mkdtemp(join(tmpdir(), "usage-compaction-gap-")); + const trackers: ScanCostTracker[] = []; + const usage = (tokens: number) => ({ + input_tokens: tokens, + output_tokens: 0, + }); + const receipt = ( + thread: string, + id: string, + tokens: number, + cumulative: number, + ) => ({ + type: "token_usage_record", + timestamp: "2026-09-01T00:00:02Z", + payload: { + thread_id: thread, + turn_id: `${thread}-turn`, + response_id: id, + model: "gpt-5.6-sol", + usage: usage(tokens), + thread_token_usage: usage(cumulative), + }, + }); + const compacted = { + type: "compacted", + payload: { message: "Synthetic summary" }, + }; + const lines = (events: unknown[]) => + events.map((event) => JSON.stringify(event)).join("\n") + "\n"; + const startTracker = () => { + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + maxCostUsd: 0.0007, + }); + if (attributed) + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: ["child"], + owner: { + threadId: "parent", + turnId: "parent-turn", + startedAt: "2026-09-01T00:00:00Z", + }, + startedAt: "2026-09-01T00:00:00Z", + completedAt: null, + })); + trackers.push(tracker); + tracker.start("parent"); + tracker.recordUsage(usage(10)); + return tracker; + }; + try { + await mkdir(join(home, "sessions")); + await writeFile( + join(home, "sessions", "parent.jsonl"), + lines([ + { type: "session_meta", payload: { id: "parent" } }, + receipt("parent", "parent-receipt", 10, 10), + ]), + ); + const childPath = join(home, "sessions", "child.jsonl"); + const first = receipt("child", "before-compaction", 50, 100); + await writeFile( + childPath, + lines([ + { + type: "session_meta", + payload: { id: "child", parent_thread_id: "parent" }, + }, + first, + ]), + ); + const tracker = startTracker(); + expect((await tracker.refresh()).cost).toMatchObject({ + inputTokens: 60, + coverage: "partial", + }); + if (repaired) { + await appendFile( + childPath, + lines([receipt("child", "delayed-before-compaction", 50, 50)]), + ); + expect((await tracker.refresh()).cost?.coverage).toBeUndefined(); + } + const second = receipt("child", "after-compaction", 100, 100); + await appendFile(childPath, lines([compacted, first, second])); + const snapshot = await tracker.refresh(); + expect(snapshot.cost?.inputTokens).toBe(repaired ? 210 : 160); + expect(snapshot.cost?.coverage).toBe(repaired ? undefined : "partial"); + if (repaired) expect(snapshot.usage).not.toHaveProperty("coverage"); + else expect(snapshot.usage).toMatchObject({ coverage: "partial" }); + if (!repaired) { + expect(snapshot.cost!.estimatedUsd).toBeLessThan(0.0007); + expect( + estimateScanCost("gpt-5.6-sol", usage(210))!.estimatedUsd, + ).toBeGreaterThan(0.0007); + } + await appendFile( + childPath, + lines([compacted, second, receipt("child", "later", 20, 20)]), + ); + const final = await tracker.stop(); + expect(final.cost?.inputTokens).toBe(repaired ? 230 : 180); + expect(final.cost?.coverage).toBe(repaired ? undefined : "partial"); + const reloaded = await startTracker().stop(); + expect(reloaded.usage).toEqual(final.usage); + expect(reloaded.cost).toEqual(final.cost); + } finally { + await Promise.all(trackers.map((tracker) => tracker.stop())); + await rm(home, { recursive: true, force: true }); + } + }, +); + +test.each([ + ["before", "time"], + ["before", "turn"], + ["after", "time"], + ["after", "turn"], +] as const)( + "unowned receipt epochs do not taint scan coverage (%s, %s)", + async (position, exclusion) => { + const home = await mkdtemp(join(tmpdir(), "usage-compaction-scope-")); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + const at = (second: string) => `2026-09-01T00:00:${second}Z`; + const receipt = ( + id: string, + tokens: number, + turn: string, + timestamp: string, + ) => ({ + type: "token_usage_record", + timestamp, + payload: { + thread_id: "worker", + turn_id: turn, + response_id: id, + model: "gpt-5.6-sol", + usage: { input_tokens: tokens, output_tokens: 0 }, + thread_token_usage: { input_tokens: 100, output_tokens: 0 }, + }, + }); + const owned = receipt("owned", 100, "scan-turn", at("02")); + const ignored = receipt( + "ignored", + 50, + exclusion === "turn" ? "other-turn" : "scan-turn", + exclusion === "time" ? at(position === "before" ? "00" : "11") : at("02"), + ); + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: [], + owner: { threadId: "worker", turnId: "scan-turn", startedAt: at("01") }, + startedAt: at("01"), + completedAt: at("10"), + })); + try { + await mkdir(join(home, "sessions")); + await writeFile( + join(home, "sessions", "worker.jsonl"), + [ + { type: "session_meta", payload: { id: "worker" } }, + position === "before" ? ignored : owned, + { type: "compacted", payload: { message: "Synthetic summary" } }, + position === "before" ? owned : ignored, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + ); + tracker.start("worker"); + const snapshot = await tracker.stop(); + expect(snapshot.cost?.inputTokens).toBe(100); + expect(snapshot.cost?.coverage).toBeUndefined(); + expect(snapshot.usage).not.toHaveProperty("coverage"); + } finally { + await tracker.stop(); + await rm(home, { recursive: true, force: true }); + } + }, +); From 1c319c8da89cbe1a991ec46f10d386e38adc0cee Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 06:50:21 +0000 Subject: [PATCH 227/251] fix(cost): retain model pricing for final cache corrections --- sdk/typescript/src/cost.ts | 29 +++++- sdk/typescript/tests-ts/cost.test.ts | 147 +++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 46af573b5f..e517c1b99c 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -554,7 +554,10 @@ export class ScanCostTracker { for (const [threadId, value] of usages) { if (value === null) continue; const session = usageSessions.get(threadId); - for (const [model, tokens] of session?.modelUsage ?? []) { + for (const [model, tokens] of reconcileModelUsage( + value, + session?.modelUsage, + ) ?? []) { if (model !== null) observedModel = true; modelUsage.set( model, @@ -581,9 +584,10 @@ export class ScanCostTracker { (counter?.counterUsage?.total_tokens ?? -1) > (session?.usage?.total_tokens ?? -1); const liveTokens = live ? counter?.counterUsage : session?.usage; - const liveModels = live - ? counter?.counterModelUsage - : session?.modelUsage; + const liveModels = reconcileModelUsage( + value, + live ? counter?.counterModelUsage : session?.modelUsage, + ); for (const [model, tokens] of liveModels ?? []) liveModelUsage.set( model, @@ -1258,6 +1262,23 @@ function addTokenUsage( }; } +function reconcileModelUsage( + usage: ScanTokenUsage, + models: ReadonlyMap | undefined, +): ReadonlyMap | undefined { + if (models?.size !== 1) return models; + const [model, observed] = models.entries().next().value!; + if ( + model === null || + observed.input_tokens !== usage.input_tokens || + observed.output_tokens !== usage.output_tokens + ) + return models; + // A final receipt can classify existing input as cache reads or writes. + // When all tokens have one observed model, the classification belongs to it. + return new Map([[model, usage]]); +} + function subtractTokenUsage( usage: ScanTokenUsage, inherited: ScanTokenUsage, diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 7c888f6a58..619a4553e0 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -429,6 +429,153 @@ describe("live scan cost tracking", () => { }, ); + test.each([false, true])( + "reconciles final cache categories for an observed model (missing child: %p)", + async (missingChild) => { + const home = await codexHome(); + const usage = { + input_tokens: 1_000_000, + cached_input_tokens: 0, + output_tokens: 10_000, + }; + const finalUsage = { ...usage, cache_write_input_tokens: 120_000 }; + const parent = await writeSession(home, "scan-thread", usage); + const records = (await readFile(parent, "utf8")).trimEnd().split("\n"); + records.splice( + 1, + 0, + JSON.stringify({ + type: "turn_context", + payload: { model: "gpt-6-astra", turn_id: "scan-turn" }, + }), + ); + await writeFile(parent, `${records.join("\n")}\n`); + if (missingChild) { + await writeFile( + join(home, "sessions", "missing-worker.jsonl"), + `${JSON.stringify({ + type: "session_meta", + payload: { id: "missing-worker", parent_thread_id: "scan-thread" }, + })}\n`, + ); + } + const initialCost = estimateScanCost("gpt-6-astra", usage)!; + const correctedCost = estimateScanCost("gpt-6-astra", finalUsage)!; + const budget = + (initialCost.estimatedUsd + correctedCost.estimatedUsd) / 2; + expect(initialCost.estimatedUsd).toBeLessThan(budget); + expect(correctedCost.estimatedUsd).toBeGreaterThan(budget); + const published: Readonly[] = []; + const lowerBounds: Readonly[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-6-astra", + maxCostUsd: budget, + onCost: (cost) => published.push(cost), + onCostLowerBound: (cost) => lowerBounds.push(cost), + }); + try { + tracker.start("scan-thread"); + await tracker.refresh(); + const before = missingChild ? lowerBounds : published; + expect(before.at(-1)?.estimatedUsd).toBe(initialCost.estimatedUsd); + const completed = await tracker.stop(finalUsage); + const emitted = missingChild ? lowerBounds : published; + expect(emitted.at(-1)).toMatchObject({ + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheWriteInputTokens: finalUsage.cache_write_input_tokens, + estimatedUsd: correctedCost.estimatedUsd, + }); + expect(emitted.at(-1)?.estimatedUsd).toBeGreaterThan(budget); + if (missingChild) { + expect(completed.cost).toBeNull(); + expect(completed.usage).toBeNull(); + expect(published).toHaveLength(0); + } else { + expect(completed.cost?.modelCosts).toHaveLength(1); + expect(completed.cost?.modelCosts?.[0]).toMatchObject({ + model: "gpt-6-astra", + cacheWriteInputTokens: finalUsage.cache_write_input_tokens, + estimatedUsd: correctedCost.estimatedUsd, + }); + } + const count = emitted.length; + expect(await tracker.refresh()).toEqual(completed); + expect(emitted).toHaveLength(count); + } finally { + await tracker.stop(); + } + }, + ); + + test("does not assign a final cache correction across observed models", async () => { + const home = await codexHome(); + const parent = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + const records = (await readFile(parent, "utf8")).trimEnd().split("\n"); + records.splice( + 1, + 0, + JSON.stringify({ + type: "turn_context", + payload: { model: "gpt-5.6-sol", turn_id: "first-turn" }, + }), + ); + records.push( + JSON.stringify({ + type: "turn_context", + payload: { model: "gpt-6-astra", turn_id: "second-turn" }, + }), + JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: { input_tokens: 200, output_tokens: 20 } }, + }, + }), + ); + await writeFile(parent, `${records.join("\n")}\n`); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-6-astra", + }); + try { + tracker.start("scan-thread"); + const running = await tracker.refresh(); + expect(running.cost?.modelCosts?.map((cost) => cost.model)).toEqual([ + "gpt-5.6-sol", + "gpt-6-astra", + ]); + const completed = await tracker.stop({ + input_tokens: 200, + output_tokens: 20, + cache_write_input_tokens: 20, + }); + expect(completed.cost).toBeNull(); + expect(completed.usage).toMatchObject({ + input_tokens: 200, + cache_write_input_tokens: 20, + modelUsage: [ + { + model: "gpt-5.6-sol", + input_tokens: 100, + cache_write_input_tokens: 0, + }, + { + model: "gpt-6-astra", + input_tokens: 100, + cache_write_input_tokens: 0, + }, + ], + }); + } finally { + await tracker.stop(); + } + }); + test("retains reported write charges when another worker omits cache writes", async () => { const home = await codexHome(); await writeSession(home, "scan-thread", { From ac81512e79ab9d6c49519b48faa2503bdc702d0a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 07:15:46 +0000 Subject: [PATCH 228/251] fix(deep-scan): preserve selection after lost persistence responses --- .../mcp-app/src/deep-scan/coordinator.ts | 11 +- .../tests/test_deep_scan_store_integration.ts | 181 ++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index ac9b13c617..6db472e671 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -23,6 +23,7 @@ import { } from "../artifact-scan-draft.js"; import type { DeepScanArtifacts } from "./artifacts.js"; import { DeepScanWorkerRunner } from "./worker-runner.js"; +import { isTransientPersistenceError } from "./store.js"; import type { AcceptedDiscovery, DedupOutcome, @@ -284,6 +285,7 @@ export class DeepScanCoordinator { } private async run(): Promise { + let selectingFinalization = false; try { await ensureDeepScanDirectories(this.artifacts); if (this.canceled || this.externallyFailed) return; @@ -300,6 +302,7 @@ export class DeepScanCoordinator { throw new Error( "The Deep Scan store cannot select finalization input.", ); + selectingFinalization = true; this.state = await this.options.store.selectFinalization({ scanId: this.state.scanId, reason: schedulerResult.reason, @@ -307,6 +310,7 @@ export class DeepScanCoordinator { resultPath: schedulerResult.resultPath, omittedWorkerIds: schedulerResult.omittedWorkerIds, }); + selectingFinalization = false; await this.completeSelectedFinalization(); return; } @@ -361,7 +365,12 @@ export class DeepScanCoordinator { if (await this.stopAfterOwnershipChange(this.options.threadId, error)) { return; } - if (this.state.finalizationInput) { + if ( + this.state.finalizationInput || + (selectingFinalization && isTransientPersistenceError(error)) + ) { + // A lost selection response may hide a committed input even when the + // ownership read also fails. Leave that selection attempt resumable. // Publication can be retried from the committed input without model work. this.log({ event: "coordinator_publication_pending", diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts index cde9f7a7f6..760d1cb4b7 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts @@ -49,6 +49,8 @@ export { startDeepScanEngine } from "./src/deep-scan/engine.ts";`, }, }); +await testSelectionResponseAndReadFailure(true); +await testSelectionResponseAndReadFailure(false); await testNativeSelectedFinalizationWithoutCheckout("deleted"); await testNativeSelectedFinalizationWithoutCheckout("moved"); await testFreeformFailureMessagesAgainstRealWorkbench(); @@ -63,6 +65,185 @@ await testCoordinatorCommitResponseRecovery(); await testCoordinatorCommitResponseRecovery("commit-deep-scan-dedup"); await testCoordinatorCommitResponseRecovery("finish-deep-scan"); +async function testSelectionResponseAndReadFailure(validationFailure: boolean) { + const { fixtureRoot, targetPath, environment } = await createWorkbenchFixture( + "deep-selected-response-", + ); + const threadId = "selection-response-owner"; + const runWorkbench = createWorkbenchRunner(environment); + const readStore = new WorkbenchDeepScanStore(runWorkbench); + const lostReply = Object.assign(new Error("Synthetic selection reply lost"), { + code: "ETIMEDOUT", + }); + let selectionWrites = 0; + let workerWrites = 0; + let failureWrites = 0; + let failedStatusReads = 0; + let committedSelection: unknown; + let prematurePublication = 0; + const store = new WorkbenchDeepScanStore( + async ( + args: string[], + input?: string, + selectFinalization = false, + withExecutionSettings = false, + signal?: AbortSignal, + releaseCoordinator = false, + ) => { + if (args[0] === "fail-deep-scan") failureWrites += 1; + if (args[0] === "upsert-deep-scan-worker") workerWrites += 1; + if ( + !validationFailure && + args[0] === "get-deep-scan" && + selectionWrites > 0 && + failedStatusReads === 0 + ) { + failedStatusReads += 1; + throw Object.assign( + new Error("Synthetic selected-state read unavailable"), + { code: "ETIMEDOUT" }, + ); + } + if (selectFinalization) { + selectionWrites += 1; + if (validationFailure) { + const invalid = [...args]; + invalid[invalid.indexOf("--terminal-reason") + 1] = + "invalid-synthetic-reason"; + return await runWorkbench( + invalid, + input, + true, + withExecutionSettings, + signal, + releaseCoordinator, + ); + } + } + const result = await runWorkbench( + args, + input, + selectFinalization, + withExecutionSettings, + signal, + releaseCoordinator, + ); + if (selectFinalization) { + const saved = await readStore.get( + args[args.indexOf("--scan-id") + 1], + threadId, + ); + committedSelection ??= saved.finalizationInput; + assert.deepEqual(saved.finalizationInput, committedSelection); + throw lostReply; + } + return result; + }, + ); + try { + await mkdir(path.join(environment.CODEX_HOME, "codex-security"), { + recursive: true, + }); + await writeFile( + path.join(environment.CODEX_HOME, "codex-security", "config.toml"), + "[deep_scan]\nworkers = 1\nstop_after_no_new = 1\nmax_discovery_runs = 1\n", + ); + const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), + targetPath, + threadId, + scanRoot: path.join(fixtureRoot, "scans"), + }); + const claim = await store.claimCoordinator({ + scanId: run.scanId, + threadId, + }); + const executor = new FakeExecutor(); + const coordinator = new DeepScanCoordinator({ + run: claim.run, + store, + pluginRoot, + threadId, + executor, + heartbeatIntervalMs: 60_000, + onComplete: async () => { + prematurePublication += 1; + throw new Error( + "Unexpected publication before selection acknowledgement", + ); + }, + }); + coordinator.start(); + if (validationFailure) { + const terminal = await coordinator.wait(undefined, 30_000); + assert.equal(terminal?.status, "failed"); + assert.match(terminal?.error ?? "", /invalid-synthetic-reason/); + assert.equal(failureWrites, 1); + assert.equal(prematurePublication, 0); + assert.ok(!(await readStore.get(run.scanId, threadId)).finalizationInput); + return; + } + await assert.rejects( + coordinator.wait(undefined, 30_000), + (error: Error) => { + assert.match(error.message, /Synthetic selection reply lost/); + assert.equal(error.cause, lostReply); + return true; + }, + ); + assert.equal(failedStatusReads, 1); + assert.equal(failureWrites, 0); + assert.equal(prematurePublication, 0); + const selected = await store.get(run.scanId, threadId); + assert.equal(selected.status, "running"); + assert.ok(selected.finalizationInput); + assert.deepEqual(selected.finalizationInput, committedSelection); + const parent = await runWorkbench(["get-scan", "--scan-id", run.scanId]); + assert.equal(parent.scan.progress.status, "running"); + const workerIds = selected.persistedWorkers.map( + (worker: { id: string }) => worker.id, + ); + const writesBeforeResume = { selectionWrites, workerWrites }; + await store.releaseCoordinator(run.scanId); + let finalized = 0; + const resumed = await startDeepScanEngine({ + run: selected, + store, + registry: new DeepScanCoordinatorRegistry(), + runWorkbench, + pluginRoot, + pythonCommand: process.env.PYTHON || "python3", + parentSandbox: { filesystemDenies: [] }, + threadId, + onFinalized: async () => { + finalized += 1; + }, + }); + const terminal = await resumed.coordinator.wait(undefined, 30_000); + assert.equal(terminal?.status, "succeeded"); + assert.equal(finalized, 1); + const persisted = await store.get(run.scanId, threadId); + assert.deepEqual(persisted.finalizationInput, committedSelection); + assert.deepEqual( + persisted.persistedWorkers.map((worker: { id: string }) => worker.id), + workerIds, + ); + assert.deepEqual({ selectionWrites, workerWrites }, writesBeforeResume); + assert.equal(executor.discoveryCalls, 1); + assert.equal(executor.dedupCalls, 1); + assert.equal(failureWrites, 0); + assert.ok( + Array.isArray((await readJson(run.scanDir, "findings.json")).findings), + ); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } +} + async function testNativeSelectedFinalizationWithoutCheckout( targetChange: "deleted" | "moved", ) { From b135a744b3443a5cda44195cad11ac35954ea858 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 07:45:05 +0000 Subject: [PATCH 229/251] fix(deep-scan): retain selected resumes and recorded worker usage --- .../scripts/workbench_scan_usage.py | 1 + .../tests/test_workbench_scan_usage.py | 31 +++++- sdk/typescript/src/api.ts | 16 ++- sdk/typescript/src/cost.ts | 29 +++-- sdk/typescript/tests-ts/cost.test.ts | 100 ++++++++++++++++++ .../tests-ts/deep-finalization.test.ts | 81 +++++++++++++- 6 files changed, 234 insertions(+), 24 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 53d7e1060f..c62c6964cd 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -633,6 +633,7 @@ def _discover_rollout_sessions( def _discover_recorded_worker_sessions(codex_home: Path, roots: set[str]) -> list[RolloutSession]: + codex_home = codex_home.resolve() recorded: dict[str, list[RolloutSession]] = {} children: dict[str, set[str]] = {} for candidate in sorted( diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 971bb3e414..8305f34918 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -701,6 +701,8 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N "current-mismatched", "external-sqlite", "external-archived", + "external-home-alias", + "external-archived-home-alias", "external-shared-home", "external-missing-copy", "external-missing-child", @@ -743,6 +745,10 @@ def complete(index: int) -> dict[str, Any]: selected_home = current_home if worker_home == "current" else root / "original-home" if worker_home == "external-shared-home": selected_home = tmp_path / "shared-original-home" + if worker_home in {"external-home-alias", "external-archived-home-alias"}: + actual_home = root / "actual-home" + actual_home.mkdir() + selected_home.symlink_to(actual_home, target_is_directory=True) process = subprocess.run( [ sys.executable, @@ -904,6 +910,8 @@ def complete(index: int) -> dict[str, Any]: elif worker_home in { "external-sqlite", "external-archived", + "external-home-alias", + "external-archived-home-alias", "external-shared-home", "external-missing-copy", "external-missing-child", @@ -922,7 +930,7 @@ def complete(index: int) -> dict[str, Any]: # index lives elsewhere and recovery chooses a different index. sessions = ( selected_home - / ("archived_sessions" if worker_home == "external-archived" else "sessions") + / ("archived_sessions" if "archived" in worker_home else "sessions") / "2026" / "01" / "01" @@ -1035,6 +1043,27 @@ def complete(index: int) -> dict[str, Any]: } +def test_recorded_home_alias_keeps_individual_rollout_links_excluded( + tmp_path: Path, workbench_api +) -> None: + reader = sys.modules["workbench_scan_usage"] + home = tmp_path / "home" + sessions = home / "sessions" + sessions.mkdir(parents=True) + alias = tmp_path / "alias" + alias.symlink_to(home, target_is_directory=True) + canonical = _rollout(tmp_path, "worker", []) + path = sessions / "worker.jsonl" + canonical.rename(path) + linked = _rollout(tmp_path, "linked-worker", []) + (sessions / "linked-worker.jsonl").symlink_to(linked) + (sessions / "linked-directory").symlink_to(linked.parent, target_is_directory=True) + + discovered = reader._discover_recorded_worker_sessions(alias, {"worker", "linked-worker"}) + assert [(session.thread_id, session.path) for session in discovered] == [("worker", path)] + assert reader._discover_recorded_worker_sessions(tmp_path / "missing-home", {"worker"}) == [] + + def test_completion_preserves_explicit_legacy_cost(tmp_path: Path) -> None: fixture = _start_scan(tmp_path) counted = fixture.started_at + timedelta(microseconds=1) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index daa9d8c2af..52be86023f 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2234,7 +2234,7 @@ export class CodexSecurity { if (postScanPrompt?.trim()) { runPostScan = () => thread.runStreamed(postScanPrompt, turnOptions); } - const recoverSelectedCompletion = async () => { + const recoverSelectedCompletion = async (required = false) => { const threadId = observedScanThreadId ?? thread.id; if (mode !== "deep" || !threadId || signal.aborted) return null; const saved = await workbench(workbenchOptions, [ @@ -2243,8 +2243,9 @@ export class CodexSecurity { scanId, "--thread-id", threadId, - ]).catch(() => { + ]).catch((error: unknown) => { deepFinalizationStatusUnavailable = true; + if (required) throw error; return null; }); if (saved !== null) deepFinalizationStatusUnavailable = false; @@ -2253,8 +2254,13 @@ export class CodexSecurity { !isRecord(deep) || !isRecord(deep["finalizationInput"]) || (deep["status"] !== "running" && deep["status"] !== "succeeded") - ) + ) { + if (required) + throw new CodexSecurityError( + "The saved Deep Scan selection is unavailable for finalization.", + ); return null; + } selectedDeepFinalization = true; await resumeSelectedDeepScan({ scanId, @@ -2272,7 +2278,9 @@ export class CodexSecurity { }; }; const savedCompletion = resumeThreadId - ? await recoverSelectedCompletion() + ? await recoverSelectedCompletion( + registration["selectedFinalization"] === true, + ) : null; const directRunner = this.#dependencies.runDeepScan ?? runDeepScan; const directPreflightCommand = directDeepScan diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index e517c1b99c..91095f098f 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -406,7 +406,6 @@ export class ScanCostTracker { } } const usageSessions = new Map(); - const counterSessions = new Map(); for (const [path, tracked] of this.#sessions) { const threadId = tracked.threadId; if (threadId === null || !included.has(threadId)) continue; @@ -472,18 +471,23 @@ export class ScanCostTracker { } this.#reportWorkerProgress(session); } - // A copied prefix must not supply model usage for a more complete log. + // Choose one copy for both usage and model pricing. Equal token totals + // can still precede later cache-category corrections in the full log. const previous = usageSessions.get(threadId); if ( previous === undefined || (session.usage?.total_tokens ?? -1) > (previous.usage?.total_tokens ?? -1) || (session.usage?.total_tokens === previous.usage?.total_tokens && - session.pendingLine.length === 0 && - previous.pendingLine.length > 0) + (session.eventIndex > previous.eventIndex || + (session.eventIndex === previous.eventIndex && + session.pendingLine.length === 0 && + previous.pendingLine.length > 0))) ) { usageSessions.set(threadId, session); } + } + for (const [threadId, session] of usageSessions) { if ( session.counterUsage && session.counterUsage.total_tokens > @@ -491,12 +495,6 @@ export class ScanCostTracker { ) { usages.set(threadId, session.counterUsage); } - if ( - session.counterUsage && - session.counterUsage.total_tokens > - (counterSessions.get(threadId)?.counterUsage?.total_tokens ?? -1) - ) - counterSessions.set(threadId, session); const receipt = usages.get(threadId); if ( session.usage !== null && @@ -508,10 +506,8 @@ export class ScanCostTracker { usages.set(threadId, session.usage); } if (!usages.has(threadId)) usages.set(threadId, null); - } - for (const [threadId, session] of usageSessions) { let selected = usages.get(threadId); - const counter = counterSessions.get(threadId)?.counterUsage; + const counter = session.counterUsage; if (selected && counter) selected = addTokenUsage( selected, @@ -579,14 +575,13 @@ export class ScanCostTracker { addTokenUsage(modelUsage.get(model) ?? null, remainder), ); } - const counter = counterSessions.get(threadId); const live = - (counter?.counterUsage?.total_tokens ?? -1) > + (session?.counterUsage?.total_tokens ?? -1) > (session?.usage?.total_tokens ?? -1); - const liveTokens = live ? counter?.counterUsage : session?.usage; + const liveTokens = live ? session?.counterUsage : session?.usage; const liveModels = reconcileModelUsage( value, - live ? counter?.counterModelUsage : session?.modelUsage, + live ? session?.counterModelUsage : session?.modelUsage, ); for (const [model, tokens] of liveModels ?? []) liveModelUsage.set( diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 619a4553e0..6eec91b611 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -3259,6 +3259,106 @@ describe("recorded Deep worker homes", () => { ); }); +test.each( + [false, true].flatMap((fullInArchive) => + [false, true].flatMap((receipt) => + [false, true].map((attributed) => ({ + fullInArchive, + receipt, + attributed, + })), + ), + ), +)( + "keeps corrected usage from the complete rollout copy: %j", + async ({ fullInArchive, receipt, attributed }) => { + const home = await codexHome(); + const at = "2026-09-01T00:00:02Z"; + const usage = (cached: number) => ({ + input_tokens: 1_000_000, + cached_input_tokens: cached, + cache_write_input_tokens: 0, + output_tokens: 0, + }); + const counter = (cached: number) => ({ + type: "event_msg", + timestamp: at, + payload: { + type: "token_count", + info: { total_token_usage: usage(cached) }, + }, + }); + const prefix = + jsonLines([ + { type: "session_meta", payload: { id: "worker" } }, + { + type: "turn_context", + timestamp: at, + payload: { model: "gpt-5.6-sol", turn_id: "turn" }, + }, + counter(0), + ]) + "\n"; + const correction = receipt + ? { + type: "token_usage_record", + timestamp: at, + payload: { + thread_id: "worker", + turn_id: "turn", + response_id: "corrected-receipt", + model: "gpt-5.6-sol", + usage: usage(900_000), + thread_token_usage: usage(900_000), + }, + } + : counter(900_000); + const active = join(home, "sessions"); + const archive = join(home, "archived_sessions"); + await mkdir(active); + await mkdir(archive); + const fullPath = join(fullInArchive ? archive : active, "full.jsonl"); + const prefixPath = join(fullInArchive ? active : archive, "prefix.jsonl"); + await writeFile(fullPath, prefix + JSON.stringify(correction) + "\n"); + const observed: number[] = []; + const createTracker = () => { + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + maxCostUsd: 2, + onCost: (cost) => observed.push(cost.estimatedUsd), + onCostLowerBound: (cost) => observed.push(cost.estimatedUsd), + }); + if (attributed) + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: [], + owner: { threadId: "worker", turnId: "turn", startedAt: at }, + startedAt: at, + completedAt: null, + })); + tracker.start("worker"); + return tracker; + }; + const tracker = createTracker(); + let reloaded: ScanCostTracker | undefined; + try { + const baseline = await tracker.refresh(); + expect(baseline.cost?.estimatedUsd).toBeCloseTo(0.76, 10); + await writeFile(prefixPath, prefix); + expect((await tracker.refresh()).cost).toEqual(baseline.cost); + reloaded = createTracker(); + const cold = await reloaded.stop(usage(0)); + expect(cold.cost).toEqual(baseline.cost); + expect(cold.usage).toMatchObject(usage(900_000)); + expect(observed.length).toBeGreaterThan(0); + expect(observed.every((cost) => cost < 2)).toBe(true); + } finally { + await tracker.stop(); + await reloaded?.stop(); + } + }, +); + test.each([ ["150000/15000 vs 100000/200000", 150_000, 15_000, 100_000, 200_000], ["150000/15000 vs 100000/20000", 150_000, 15_000, 100_000, 20_000], diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index 7c54d4219c..630973ecfd 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -141,10 +141,14 @@ const cases: { initialResumeUsage?: boolean; unpricedUsage?: boolean; statusReadFault?: boolean; + resumeStatusFault?: "transport" | "missing-selection" | "stopped"; cancellationFault?: "status-read" | "deep-state-read" | "cancel-response"; }[] = [ ...outcomes.map((outcome) => ({ outcome })), { outcome: "restart", statusReadFault: true }, + { outcome: "restart", resumeStatusFault: "transport" }, + { outcome: "restart", resumeStatusFault: "missing-selection" }, + { outcome: "restart", resumeStatusFault: "stopped" }, { outcome: "restart-unavailable-runtime", initialResumeUsage: true }, { outcome: "restart-unavailable-runtime", @@ -213,6 +217,7 @@ for (const { initialResumeUsage, unpricedUsage, statusReadFault, + resumeStatusFault, } of cases) { const resumedStop = outcome.includes("-resumed-"); const restart = outcome.startsWith("restart") || resumedStop; @@ -227,7 +232,7 @@ for (const { const name = outcome === "followup-canceled" ? "SDK preserves a selected aggregate when its follow-up is canceled" - : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${statusReadFault ? " (selection status unavailable)" : ""}${initialResumeUsage ? " (initial resume cost)" : ""}${unpricedUsage ? " (unpriced remainder)" : ""}`; + : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${statusReadFault ? " (selection status unavailable)" : ""}${resumeStatusFault ? ` (resume status ${resumeStatusFault})` : ""}${initialResumeUsage ? " (initial resume cost)" : ""}${unpricedUsage ? " (unpriced remainder)" : ""}`; const runCase = async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -255,6 +260,10 @@ for (const { let workbenchOptions: Parameters[0]; let publicationFails = restart && !statusReadFault; let selectionReadLost = false; + let resumeStatusInjected = false; + const resumeStatusError = new Error( + "Synthetic selected resume status unavailable", + ); let completionReceiptLost = loseCompletionResponse; let budgetReceiptLost = budgetCompletionFault === "lost" || @@ -375,6 +384,29 @@ for (const { if (args[0] === "get-cli-scan-resume") originalResumeSignal = options.signal; commands.push(args[0]!); + if ( + args[0] === "get-deep-scan" && + resumedStartup && + resumeStatusFault && + !resumeStatusInjected + ) { + resumeStatusInjected = true; + if (resumeStatusFault === "transport") throw resumeStatusError; + if (resumeStatusFault === "missing-selection") { + const result = await runWorkbench(options, args, input); + delete (result["deepScan"] as Record)[ + "finalizationInput" + ]; + return result; + } + await runWorkbench(options, [ + "cancel-scan", + "--scan-id", + scanId, + "--thread-id", + threadId, + ]); + } if ( args[0] === "get-deep-scan" && statusReadFault && @@ -776,6 +808,49 @@ for (const { client = makeClient(); if (initialResumeUsage) await recordBudgetUsage(); } + if (resumeStatusFault) { + const previousModelCalls = modelInputs.length; + const failure = await client + .run(repository, { + mode: "deep", + resumeScanId: scanId, + outputDir: scanDir, + postScanPrompt: followUp, + }) + .catch((error: unknown) => error); + expect(resumeStatusInjected).toBe(true); + // Failure keeps the explicitly requested follow-up, but cannot restart scanning. + expect(modelInputs.slice(previousModelCalls)).toEqual([followUp]); + expect(modelInputs.filter((input) => input !== followUp)).toHaveLength( + 1, + ); + if (resumeStatusFault === "transport") + expect(failure).toBe(resumeStatusError); + else expect(failure).toBeInstanceOf(Error); + expect(commands).not.toContain("fail-scan"); + const persisted = await runWorkbench(workbenchOptions!, [ + "get-scan", + "--scan-id", + scanId, + ]); + expect(persisted["scan"]).toMatchObject({ + progress: { + status: resumeStatusFault === "stopped" ? "canceled" : "running", + }, + }); + const deep = await runWorkbench(workbenchOptions!, [ + "get-deep-scan", + "--scan-id", + scanId, + "--thread-id", + threadId, + ]); + expect( + (deep["deepScan"] as Record)["finalizationInput"], + ).toEqual(originalFinalizationInput); + expect(await readFile(selectedPath)).toEqual(selectedBytes!); + if (resumeStatusFault === "stopped") return; + } if (budgeted || closed) { const running = client.run(repository, { mode: "deep", @@ -1144,7 +1219,9 @@ for (const { : unavailableStartup ? 1 : restart - ? 2 + ? resumeStatusFault + ? 3 + : 2 : 1, ); if (unavailableStartup) { From 5a279e89e10af8ea5e5d9b7a918d946bc1299be6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 08:10:32 +0000 Subject: [PATCH 230/251] fix(deep-scan): retain complete copied usage receipts --- .../scripts/workbench_scan_usage.py | 43 ++++++++++---- .../tests/test_workbench_scan_usage.py | 57 +++++++++++++++++++ 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index c62c6964cd..8cfdc482d2 100644 --- a/plugins/codex-security/scripts/workbench_scan_usage.py +++ b/plugins/codex-security/scripts/workbench_scan_usage.py @@ -304,7 +304,7 @@ def _read_rollout_copies_usage( for session in copies: local_models: dict[str | None, dict[str, int]] = {} try: - usage, warnings = _read_rollout_usage( + usage, warnings, parsed_events = _read_rollout_usage_with_extent( session, started_at=started_at, completed_at=completed_at, @@ -313,7 +313,7 @@ def _read_rollout_copies_usage( ) except (OSError, UnicodeError, ValueError): continue - readings.append((usage, warnings, local_models)) + readings.append((usage, warnings, local_models, parsed_events)) if not readings: raise ValueError("No readable rollout copy.") attributable = [ @@ -329,11 +329,13 @@ def _read_rollout_copies_usage( ) ] # Restored indexes can reference a prefix and its complete continuation. - # Keep totals and model attribution from the same copy, counting it once. - usage, warnings, selected_models = max( + # Equal totals can precede later cache corrections or model receipts. Prefer + # the parsed continuation, keeping all accounting from that one copy. + usage, warnings, selected_models, _ = max( attributable or readings, key=lambda reading: ( reading[0]["totalTokens"], + reading[3], "rollout_record_incomplete" not in reading[1], ), ) @@ -726,6 +728,25 @@ def _read_rollout_usage( owner_turn_id: str | None = None, model_usage: dict[str | None, dict[str, int]] | None = None, ) -> tuple[dict[str, int], set[str]]: + usage, warnings, _ = _read_rollout_usage_with_extent( + session, + started_at=started_at, + completed_at=completed_at, + owner_turn_id=owner_turn_id, + model_usage=model_usage, + ) + return usage, warnings + + +def _read_rollout_usage_with_extent( + session: RolloutSession, + *, + started_at: datetime, + completed_at: datetime | None, + owner_turn_id: str | None = None, + model_usage: dict[str | None, dict[str, int]] | None = None, +) -> tuple[dict[str, int], set[str], int]: + parsed_events = 0 total = _empty_token_usage() counter_total = _empty_token_usage() warnings: set[str] = set() @@ -760,6 +781,8 @@ def _read_rollout_usage( warnings.add("rollout_record_invalid") continue payload = event.get("payload") + if isinstance(payload, dict): + parsed_events += 1 if event.get("type") in {"session_meta", "turn_context"} and isinstance(payload, dict): model = payload.get("model") if isinstance(model, str) and model: @@ -767,17 +790,17 @@ def _read_rollout_usage( if line_number == 1: if event.get("type") != "session_meta" or not isinstance(payload, dict): warnings.add("thread_identity_mismatch") - return total, warnings + return total, warnings, parsed_events session_started_at = _timestamp(payload.get("timestamp")) recorded_id = payload.get("id") or payload.get("session_id") if recorded_id != session.thread_id: warnings.add("thread_identity_mismatch") - return total, warnings + return total, warnings, parsed_events recorded_parent = _session_parent_thread_id(payload) if session.parent_thread_id is not None: if recorded_parent != session.parent_thread_id: warnings.add("thread_identity_mismatch") - return total, warnings + return total, warnings, parsed_events boundary_reached = ( session.parent_thread_id is None and not recorded_parent @@ -796,12 +819,12 @@ def _read_rollout_usage( task_started_at = _timestamp(event.get("timestamp")) if task_started_at is None: warnings.add("thread_ownership_unavailable") - return total, warnings + return total, warnings, parsed_events if task_started_at < started_at or ( completed_at is not None and task_started_at > completed_at ): warnings.add("thread_outside_scan_window") - return total, warnings + return total, warnings, parsed_events boundary_reached = True elif event.get("type") == "event_msg" and payload.get("type") == "token_count": inherited_usage = _token_snapshot(payload) @@ -920,7 +943,7 @@ def _read_rollout_usage( warnings.add("thread_ownership_unavailable") elif not usage_observed: warnings.add("token_usage_unavailable") - return total, warnings + return total, warnings, parsed_events def _session_parent_thread_id(payload: Mapping[str, Any]) -> str | None: diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 8305f34918..567a86d2c8 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -1130,6 +1130,63 @@ def test_failed_scan_preserves_legacy_failure_behavior(tmp_path: Path) -> None: assert "usage" not in failed +@pytest.mark.parametrize("complete_first", [False, True]) +@pytest.mark.parametrize("tail", ["complete", "partial", "invalid"]) +def test_rollout_copies_prefer_parsed_cache_and_model_corrections( + tmp_path: Path, workbench_api, complete_first: bool, tail: str +) -> None: + reader = sys.modules["workbench_scan_usage"] + start = datetime.fromisoformat("2026-01-01T00:00:00+00:00") + prefix = _rollout( + tmp_path, + "worker", + [ + _event(start, "turn_context", {"turn_id": "scan-turn", "model": "model-before"}), + _token_event(start, 1_000_000, 0), + ], + ) + complete = tmp_path / "complete.jsonl" + tokens = dict( + input_tokens=1_000_000, cached_input_tokens=900_000, output_tokens=0, total_tokens=1_000_000 + ) + receipt = _event( + start, + "token_usage_record", + dict( + response_id="response-one", + thread_id="worker", + turn_id="scan-turn", + model="model-receipt", + usage=tokens, + thread_token_usage=tokens, + ), + ) + complete.write_bytes(prefix.read_bytes() + json.dumps(receipt).encode() + b"\n") + if tail == "partial": + with complete.open("ab") as stream: + stream.write(b'{"type":"event_msg"') + elif tail == "invalid": + with complete.open("ab") as stream: + stream.write(b"invalid-record\n") + paths = [prefix, complete] if not complete_first else [complete, prefix] + models = {} + counts, warnings = reader._read_rollout_copies_usage( + [reader.RolloutSession("worker", None, path) for path in paths], + started_at=start, + completed_at=None, + owner_turn_id="scan-turn", + model_usage=models, + ) + expected = _counts(1_000_000, 900_000, 0) + assert counts == expected + assert models == {"model-receipt": expected} + assert warnings == ( + set() + if tail == "complete" + else {"rollout_record_incomplete" if tail == "partial" else "rollout_record_invalid"} + ) + + def test_rollout_usage_reconciles_stale_cumulative_events_and_models( tmp_path: Path, workbench_api ) -> None: From c08c02a39b5e9800b4ab734f2a6c6c7ce483a1a1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 08:26:14 +0000 Subject: [PATCH 231/251] fix(cost): retain copied rollout counter lower bounds --- sdk/typescript/src/cost.ts | 20 ++++-- sdk/typescript/tests-ts/cost.test.ts | 99 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 91095f098f..666e9af210 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -471,14 +471,15 @@ export class ScanCostTracker { } this.#reportWorkerProgress(session); } - // Choose one copy for both usage and model pricing. Equal token totals - // can still precede later cache-category corrections in the full log. + // Choose one copy using both receipts and cumulative lower bounds. + // Equal totals can still precede later corrections in the full log. const previous = usageSessions.get(threadId); + const total = sessionUsageTotal(session); + const previousTotal = previous ? sessionUsageTotal(previous) : -1; if ( previous === undefined || - (session.usage?.total_tokens ?? -1) > - (previous.usage?.total_tokens ?? -1) || - (session.usage?.total_tokens === previous.usage?.total_tokens && + total > previousTotal || + (total === previousTotal && (session.eventIndex > previous.eventIndex || (session.eventIndex === previous.eventIndex && session.pendingLine.length === 0 && @@ -1298,6 +1299,15 @@ function isMissingFile(error: unknown): boolean { return isRecord(error) && error["code"] === "ENOENT"; } +function sessionUsageTotal(session: SessionUsage): number { + if (session.usage === null) return session.counterUsage?.total_tokens ?? -1; + if (session.counterUsage === null) return session.usage.total_tokens; + return ( + session.usage.total_tokens + + tokenUsageRemainder(session.counterUsage, session.usage).total_tokens + ); +} + function tokenUsageRemainder( usage: ScanTokenUsage, inherited: ScanTokenUsage, diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 6eec91b611..89147b6a5a 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -3359,6 +3359,105 @@ test.each( }, ); +test.each( + [false, true].flatMap((fullInArchive) => + [false, true].map((attributed) => ({ fullInArchive, attributed })), + ), +)( + "retains the complete copy's counter lower bound with missing receipts: %j", + async ({ fullInArchive, attributed }) => { + const home = await codexHome(); + const at = "2026-09-01T00:00:02Z"; + const usage = (input: number) => ({ + input_tokens: input, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: 0, + }); + const counter = (input: number) => ({ + type: "event_msg", + timestamp: at, + payload: { + type: "token_count", + info: { total_token_usage: usage(input) }, + }, + }); + const prefix = [ + { type: "session_meta", payload: { id: "worker" } }, + { + type: "turn_context", + timestamp: at, + payload: { model: "gpt-5.6-sol", turn_id: "turn" }, + }, + counter(1_000_000), + ]; + const complete = [ + ...prefix, + { + type: "token_usage_record", + timestamp: at, + payload: { + thread_id: "worker", + turn_id: "turn", + response_id: "partial-receipt", + model: "gpt-5.6-sol", + usage: usage(600_000), + thread_token_usage: usage(1_000_000), + }, + }, + counter(1_300_000), + ]; + const active = join(home, "sessions"); + const archive = join(home, "archived_sessions"); + await mkdir(active); + await mkdir(archive); + const fullPath = join(fullInArchive ? archive : active, "full.jsonl"); + const prefixPath = join(fullInArchive ? active : archive, "prefix.jsonl"); + await writeFile(fullPath, jsonLines(complete) + "\n"); + const observed: number[] = []; + const createTracker = () => { + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + maxCostUsd: 5, + onCost: (cost) => observed.push(cost.estimatedUsd), + onCostLowerBound: (cost) => observed.push(cost.estimatedUsd), + }); + if (attributed) + tracker.setAttributionReader(async () => ({ + formatVersion: 1, + executionThreadIds: [], + owner: { threadId: "worker", turnId: "turn", startedAt: at }, + startedAt: at, + completedAt: null, + })); + tracker.start("worker"); + return tracker; + }; + const tracker = createTracker(); + let reloaded: ScanCostTracker | undefined; + try { + const baseline = await tracker.refresh(); + expect(baseline.usage).toMatchObject({ + input_tokens: 1_300_000, + total_tokens: 1_300_000, + coverage: "partial", + }); + expect(observed.at(-1)).toBeCloseTo(5.2, 10); + await writeFile(prefixPath, jsonLines(prefix) + "\n"); + expect((await tracker.refresh()).usage).toEqual(baseline.usage); + expect(observed.at(-1)).toBeCloseTo(5.2, 10); + reloaded = createTracker(); + expect((await reloaded.stop()).usage).toEqual(baseline.usage); + expect(observed.at(-1)).toBeCloseTo(5.2, 10); + expect(observed.every((cost) => cost > 5)).toBe(true); + } finally { + await tracker.stop(); + await reloaded?.stop(); + } + }, +); + test.each([ ["150000/15000 vs 100000/200000", 150_000, 15_000, 100_000, 200_000], ["150000/15000 vs 100000/20000", 150_000, 15_000, 100_000, 20_000], From b48dd5471928e5b79e89bb41726c306b4090f811 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 08:56:30 +0000 Subject: [PATCH 232/251] test(logs): exercise native Windows junction semantics --- sdk/typescript/tests-ts/scan-logs-node.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/tests-ts/scan-logs-node.test.ts b/sdk/typescript/tests-ts/scan-logs-node.test.ts index 08963b5321..557fad3bf4 100644 --- a/sdk/typescript/tests-ts/scan-logs-node.test.ts +++ b/sdk/typescript/tests-ts/scan-logs-node.test.ts @@ -106,12 +106,13 @@ for (const directory of ["sessions", "archived_sessions"]) { await mkdir(join(original, "child"), { recursive: true }); await mkdir(join(original, directory)); const link = join(current, "original-home-link"); + const windows = process.platform === "win32"; await symlink( - join(original, "child"), + windows ? original : join(original, "child"), link, - process.platform === "win32" ? "junction" : "dir", + windows ? "junction" : "dir", ); - const recorded = `${link}${sep}..`; + const recorded = windows ? link : `${link}${sep}..`; const timestamp = "2026-08-11T12:01:00.000Z"; const event = { type: "response_item", @@ -147,6 +148,8 @@ for (const directory of ["sessions", "archived_sessions"]) { "--eval", ` import assert from "node:assert/strict"; + import { realpath } from "node:fs/promises"; + assert.equal(await realpath(${JSON.stringify(recorded)}), await realpath(${JSON.stringify(original)})); const { readSavedScanLogs } = await import(${JSON.stringify(pathToFileURL(modulePath).href)}); const attribution = { formatVersion: 1, workerCodexHome: ${JSON.stringify(recorded)}, executionThreadIds: [], owner: { threadId: "owner", turnId: "scan-turn", startedAt: ${JSON.stringify(timestamp)} }, From 3decda1d460ab2ae5dabf30ec2639d6f623a656e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 10:11:51 +0000 Subject: [PATCH 233/251] Fix budget test names in JUnit reports --- sdk/typescript/tests-ts/api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 056849d67a..936de9ee6c 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -5261,7 +5261,7 @@ describe("CodexSecurity orchestration", () => { ["standard", true], ["deep", true], ] as const)( - "enforces priced usage with an unpriced remainder (%s, raised limit: %s)", + "enforces priced usage with an unpriced remainder (%s, raised limit: %p)", async (mode, raised) => { const root = await temporaryDirectory(); const repository = join(root, "repository"); From 721b128265163593d918881809c4b0e4f79da72b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 10 Oct 2026 10:53:39 +0000 Subject: [PATCH 234/251] Allow Windows test shards to finish under variable runner load --- .github/workflows/node-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 031de8212e..50172a78f0 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -485,7 +485,7 @@ jobs: name: windows-latest / node-22 / tests-${{ matrix.shard }} needs: [validate-title, package] runs-on: windows-latest - timeout-minutes: 20 + timeout-minutes: 30 strategy: fail-fast: false matrix: From 64415d72f302ac85bce899b76029f7fac24d458e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 11 Oct 2026 20:47:40 +0000 Subject: [PATCH 235/251] fix(package): retain recovery modules in package inventory --- sdk/typescript/scripts/package-dist-files.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index 58d4669ae6..699bc73e44 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -1,5 +1,9 @@ const distModules = [ "api", + "reasoning-summary", + "preflight-config", + "deep-scan-finalization", + "codex-session", "accepted-audit", "artifact-export", "auth", @@ -66,6 +70,7 @@ const distModules = [ "security-policy-cli", "suggest-owners", "scan-sessions", + "selected-scan-recovery", "server/api", "deduplication/codex-review", "deduplication/checkpointed-review", From 967f936dbb55ad89bc2afa11573e757ec9013c94 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sun, 11 Oct 2026 21:12:24 +0000 Subject: [PATCH 236/251] fix(deep-scan): restore requested paths for recovered workers --- .../mcp-app/src/deep-scan/engine.ts | 1 + .../mcp-app/tests/test_deep_scan_executor.ts | 54 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/engine.ts b/plugins/codex-security/mcp-app/src/deep-scan/engine.ts index 683ce1d52c..b70a85e3d2 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/engine.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/engine.ts @@ -74,6 +74,7 @@ export function startDeepScanEngine(options: { repoRoot: owned.targetPath, scanId: owned.scanId, scope: owned.scope, + includePaths: owned.includePaths, pythonCommand: options.pythonCommand, }, }), diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts index 1801967fc9..b2f926a3ca 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_executor.ts @@ -2532,6 +2532,27 @@ async function testZeroSubagentsPreservesHostRestrictions() { } async function testLargeScopeTransport() { + const engineSource = new URL("../src/deep-scan/engine.ts", import.meta.url); + const { startDeepScanEngine } = await importModule({ + entryPoints: [fileURLToPath(engineSource)], + loader: { ".md": "text" }, + define: { "import.meta.url": JSON.stringify(engineSource.href) }, + banner: { + js: `import { createRequire as engineScopeRequire } from "node:module"; const require = engineScopeRequire(${JSON.stringify(engineSource.href)});`, + }, + plugins: [ + { + name: "capture-coordinator-options", + setup(build) { + build.onLoad({ filter: /[/\\]registry\.ts$/ }, () => ({ + contents: + "export function startOrJoinDeepScanCoordinator(options) { return options; }", + loader: "js", + })); + }, + }, + ], + }); const names = [ "CODEX_CLI_PATH", "CODEX_HOME", @@ -2588,16 +2609,33 @@ async function testLargeScopeTransport() { (_, row) => `scan-${index}/background jobs/café-${row}-${"a".repeat(110)}`, ); - const executor = new CodexSdkWorkerExecutor({ - parentSandbox: trustedParentSandbox, - artifactContext: { - pluginRoot: fixture.root, - repoRoot: fixture.root, - scanId, - scope: ".", - includePaths, + const run = { + scanId, + scanDir: workerRoot, + targetPath: fixture.root, + scope: ".", + includePaths, + executionSettings: { + version: 1, + settings: await captureDeepScanExecutionSettings( + {}, + trustedParentSandbox, + ), }, + }; + const captured = startDeepScanEngine({ + run, + store: {}, + registry: {}, + runWorkbench: async () => ({}), + pluginRoot: fixture.root, + pythonCommand: process.env.PYTHON || "python3", + parentSandbox: trustedParentSandbox, + threadId: "scope-parent", }); + const executor = resumed + ? await captured.options.prepareExecutor(run) + : captured.options.executor; await executor.run( workerRequest(prompt, output, { kind, From 9512f3d53b325a5c5028fbc9789f1d825ec31ec9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 12 Oct 2026 08:14:38 +0000 Subject: [PATCH 237/251] fix: preserve accepted reducer inputs through finalization --- .../mcp-app/src/deep-scan/finalization.ts | 19 +- .../tests/test_deep_scan_finalization.mjs | 81 ++++ .../scripts/deep_scan_workbench.py | 56 +-- .../codex-security/scripts/workbench_db.py | 165 ++++--- .../scripts/workbench_saved_results.py | 64 ++- .../tests/test_budget_coverage_provenance.py | 25 +- .../test_budget_selection_publication.py | 23 +- .../test_finalization_selection_order.py | 233 ++++++++++ .../test_selected_publication_integrity.py | 105 +++++ sdk/typescript/src/contract.ts | 357 +-------------- sdk/typescript/src/runtime.ts | 107 +---- sdk/typescript/src/scan-files.ts | 433 ++++++++++++++++++ 12 files changed, 1073 insertions(+), 595 deletions(-) create mode 100644 plugins/codex-security/tests/test_finalization_selection_order.py create mode 100644 sdk/typescript/src/scan-files.ts diff --git a/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts b/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts index 6cd3d320e0..55a4a3b030 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts @@ -11,12 +11,14 @@ import { type DeepScanPublication, } from "../artifact-scan-draft.js"; import { WorkbenchDeepScanStore } from "./store.js"; -import { createDeepScanArtifacts } from "./artifacts.js"; +import { + createDeepScanArtifacts, + type DeepScanArtifacts, +} from "./artifacts.js"; import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { type ScanDraftInput } from "../artifact-scan-draft.js"; -import { requireRegularFile, type DeepScanArtifacts } from "./artifacts.js"; +import { readScanFile } from "../../../../../sdk/typescript/src/scan-files.js"; import type { DeepScanFinalizationInput, DeepScanRunState, @@ -47,6 +49,7 @@ export async function publishSelectedDeepScan(input: { artifacts, run.scanId, selection, + signal, ); await input.publish(draft, signal, { coordinatorGeneration: run.coordinatorGeneration, @@ -69,6 +72,7 @@ export async function readSelectedDeepScanDraft( artifacts: DeepScanArtifacts, scanId: string, selection: DeepScanFinalizationInput, + signal?: AbortSignal, ): Promise { if (selection.version !== 1) throw new Error("Unsupported Deep Scan finalization input version."); @@ -97,9 +101,12 @@ export async function readSelectedDeepScanDraft( }, }; } - const resultPath = join(artifacts.scanDir, selection.resultPath); - await requireRegularFile(resultPath, artifacts.scanDir); - const contents = await readFile(resultPath); + const contents = await readScanFile( + artifacts.scanDir, + selection.resultPath, + "Selected Deep Scan result", + signal, + ); if ( createHash("sha256").update(contents).digest("hex") !== selection.resultSha256 diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs index 57574cd582..aeb19ee8d2 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; +import { promises as fs, renameSync, symlinkSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; import { mkdir, mkdtemp, @@ -37,6 +39,85 @@ const { ); const scanId = "4e7b4acb-ac80-4d68-98cd-3d5ac5581cd1"; +for (const mode of ["normal", "symlink", "swapped", "changed", "canceled"]) { + test(`selected reducer read preserves accepted-file boundary: ${mode}`, async (t) => { + const root = await realpath( + await mkdtemp(path.join(tmpdir(), "selected-reader-")), + ); + try { + const scanDir = path.join(root, "scan"); + await mkdir(scanDir, { mode: 0o700 }); + const selected = path.join(scanDir, "selected.json"); + const outside = path.join(root, "outside.json"); + const contents = JSON.stringify({ + scanId, + complete: true, + findings: [], + sourceCoverage: { + completeness: "complete", + surfaces: [], + explicitExclusions: [], + deferred: [], + }, + }); + await writeFile(selected, contents); + await writeFile(outside, contents); + const selection = { + version: 1, + resultPath: "selected.json", + resultSha256: createHash("sha256").update(contents).digest("hex"), + terminalReason: "capped", + omittedWorkerIds: [], + selectedAt: "2030-01-01T00:00:00Z", + }; + const swap = () => { + renameSync(selected, `${selected}.original`); + symlinkSync(outside, selected, "file"); + }; + if (mode === "symlink") swap(); + if (mode === "changed") await fs.appendFile(selected, "\n"); + const controller = new AbortController(); + if (mode === "canceled") + controller.abort(new Error("Stopped selected publication")); + const lstat = fs.lstat; + let swapped = false; + const spy = t.mock.method(fs, "lstat", async (...args) => { + const result = await lstat(...args); + if (mode === "swapped" && args[0] === selected && !swapped) { + swap(); + swapped = true; + } + return result; + }); + syncBuiltinESMExports(); + try { + const reading = readSelectedDeepScanDraft( + createDeepScanArtifacts(scanDir), + scanId, + selection, + controller.signal, + ); + if (mode === "normal") assert.equal((await reading).scanId, scanId); + else + await assert.rejects( + reading, + mode === "canceled" + ? /Stopped selected publication/ + : mode === "changed" + ? /changed after acceptance/ + : /file|directory|symlink/, + ); + if (mode === "swapped") assert.equal(swapped, true); + } finally { + spy.mock.restore(); + syncBuiltinESMExports(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} + for (const terminalReason of ["capped", "saturated"]) { test(`replays the selected ${terminalReason} input after replaceable results change`, async () => { const scanDir = await realpath( diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 2e248a00ab..ec2b453156 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -17,6 +17,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from deep_scan_config import resolve_deep_scan_config from finalize_scan_contract import ( + PreparedScanFinalization, _read_scan_local_json, open_scan_local_file_descriptor, write_scan_local_bytes, @@ -25,7 +26,7 @@ from workbench.storage import create_private_directory from workbench_saved_results import ( _restore_published_outputs, - _selected_publication_digest_for_documents, + _selected_publication_digest, _snapshot_published_outputs, _worker_checkpoint_head, ) @@ -2405,6 +2406,23 @@ def finish_deep_scan_locked( return deep_scan_result(connection, scan_id) +def final_deep_scan_reducer(connection: sqlite3.Connection, scan_id: str) -> sqlite3.Row | None: + return connection.execute( + "SELECT workers.*, attempts.accepted_result_path, attempts.accepted_result_sha256 " + "FROM deep_scan_workers AS workers LEFT JOIN deep_scan_attempts AS attempts " + "ON attempts.worker_id = workers.id AND attempts.attempt = workers.attempt " + "WHERE workers.scan_id = ? AND workers.kind = 'dedup' AND workers.status = 'succeeded' " + "AND NOT EXISTS (SELECT 1 FROM deep_scan_merge_claims AS claims " + "JOIN deep_scan_workers AS successor ON successor.id = claims.worker_id " + "WHERE claims.previous_worker_id = workers.id AND claims.scan_id = workers.scan_id " + "AND successor.scan_id = workers.scan_id AND successor.kind = 'dedup' " + "AND successor.status = 'succeeded') " + # Legacy reducers without claims retain their previous ordering. + "ORDER BY workers.completed_at DESC, workers.id DESC LIMIT 1", + (scan_id,), + ).fetchone() + + def selected_deep_scan_finalization( connection: sqlite3.Connection, run: sqlite3.Row, @@ -2423,17 +2441,11 @@ def selected_deep_scan_finalization( if not zero_discovery_deadline: raise SystemExit("Deep Scan finalization requires its accepted reducer result.") else: - accepted = connection.execute( - "SELECT attempts.accepted_result_path, attempts.accepted_result_sha256 " - "FROM deep_scan_workers AS workers LEFT JOIN deep_scan_attempts AS attempts " - "ON attempts.worker_id = workers.id AND attempts.attempt = workers.attempt " - "WHERE workers.id = (SELECT id FROM deep_scan_workers WHERE scan_id = ? " - "AND kind = 'dedup' AND status = 'succeeded' ORDER BY completed_at DESC, id DESC LIMIT 1) " - "AND (workers.result_manifest_path = ? OR attempts.accepted_result_path = ?)", - (scan["id"], result_path, result_path), - ).fetchone() + accepted = final_deep_scan_reducer(connection, scan["id"]) if ( accepted is None + or result_path + not in (accepted["result_manifest_path"], accepted["accepted_result_path"]) or not accepted["accepted_result_path"] or not accepted["accepted_result_sha256"] ): @@ -2476,8 +2488,8 @@ def prepare_budget_exhausted_deep_scan( scan: sqlite3.Row, scan_dir: Path, warning: str, - write_draft: Callable[[dict[str, Any] | None, list[sqlite3.Row]], None], -) -> None: + write_draft: Callable[[dict[str, Any] | None, list[sqlite3.Row]], PreparedScanFinalization], +) -> PreparedScanFinalization: """Finish local budget publication without starting or promoting scan work.""" snapshots = _snapshot_published_outputs(scan_dir) connection.execute("BEGIN IMMEDIATE") @@ -2488,14 +2500,7 @@ def prepare_budget_exhausted_deep_scan( if run["finalization_input_json"] is not None: selection = json.loads(run["finalization_input_json"]) else: - reducer = connection.execute( - "SELECT attempts.accepted_result_path FROM deep_scan_workers AS workers " - "LEFT JOIN deep_scan_attempts AS attempts ON attempts.worker_id = workers.id " - "AND attempts.attempt = workers.attempt WHERE workers.scan_id = ? " - "AND workers.kind = 'dedup' AND workers.status = 'succeeded' " - "ORDER BY workers.completed_at DESC, workers.id DESC LIMIT 1", - (scan["id"],), - ).fetchone() + reducer = final_deep_scan_reducer(connection, scan["id"]) if reducer is not None and not reducer["accepted_result_path"]: raise SystemExit("Budget completion requires the committed reducer reference.") selection = selected_deep_scan_finalization( @@ -2526,14 +2531,8 @@ def prepare_budget_exhausted_deep_scan( or not isinstance(accepted.get("sourceCoverage"), dict) ): raise SystemExit("Budget completion requires the accepted complete reducer.") - write_draft(accepted, unmerged) - documents = tuple( - _read_scan_local_json(scan_dir, name, name) - for name in ("scan-manifest.json", "findings.json", "coverage.json") - ) - selection["publicationSha256"] = _selected_publication_digest_for_documents( - dependencies(), scan, documents - ) + prepared = write_draft(accepted, unmerged) + selection["publicationSha256"] = _selected_publication_digest(prepared) timestamp = dependencies().now() cancel_active_workers(connection, scan["id"], timestamp) connection.execute( @@ -2558,6 +2557,7 @@ def prepare_budget_exhausted_deep_scan( (json.dumps([*warnings, warning]), scan["id"]), ) connection.commit() + return prepared except BaseException: connection.rollback() _restore_published_outputs(scan_dir, snapshots) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 497f7c2d5d..61cc4bc6b4 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -46,6 +46,7 @@ from finalize_scan_contract import ( PRODUCER_NAME, ContractError, + PreparedScanFinalization, RecoverableContractError, _prepare_scan_finalization, _write_prepared_scan_finalization, @@ -1213,25 +1214,30 @@ def complete_budget_exhausted_scan( f"${measured['estimatedUsd']:.6g}; {retained}." ) selection = deep_scan.deep_scan_finalization_input(run) - if selection is not None: - try: + draft_documents = None + try: + if selection is not None: saved_results._require_selected_result(scan, selection) if "publicationSha256" in selection: - prepared = _prepare_scan_finalization( + draft_documents = tuple( + saved_results._read_scan_local_json(scan_dir, name, name) + for name in ("scan-manifest.json", "findings.json", "coverage.json") + ) + manifest = draft_documents[0] + sealed = manifest["scan"].get("sealedAt") is not None or bool( + manifest["scan"].get("artifacts") + ) + validated = _prepare_scan_finalization( scan_dir, expected_coverage_mode=expected_coverage_mode(scan), - completion_binding=workbench_completion_binding( - scan, now(), read_json_object(scan_dir / ARTIFACTS["manifest"]) - ), + completion_binding=workbench_completion_binding(scan, now(), manifest), + draft_documents=None if sealed else draft_documents, ) saved_results.require_selected_publication( - _WORKBENCH_DB_CONTEXT, connection, scan, prepared + _WORKBENCH_DB_CONTEXT, connection, scan, validated ) - except ContractError as exc: - raise SystemExit(str(exc)) from exc - if before_selection: - try: - deep_scan.prepare_budget_exhausted_deep_scan( + if before_selection: + prepared = deep_scan.prepare_budget_exhausted_deep_scan( connection, scan, scan_dir, @@ -1244,34 +1250,24 @@ def complete_budget_exhausted_scan( before_selection=True, accepted_result=accepted, unmerged_workers=unmerged, + draft_documents=draft_documents, ), ) - except ContractError as exc: - raise SystemExit(str(exc)) from exc - else: - budget_exhausted_draft( - scan, - scan_dir, - candidates, - warning, - unmerged_workers=deep_scan.budget_unmerged_workers(connection, scan_id), - ) - if ( - deep_scan.deep_scan_finalization_input( - deep_scan.require_deep_scan_run(connection, scan_id) - ) - is not None - ): - documents = tuple( - saved_results._read_scan_local_json(scan_dir, name, name) - for name in ("scan-manifest.json", "findings.json", "coverage.json") - ) - try: - saved_results.record_selected_publication( - _WORKBENCH_DB_CONTEXT, connection, scan, documents + else: + prepared = budget_exhausted_draft( + scan, + scan_dir, + candidates, + warning, + unmerged_workers=deep_scan.budget_unmerged_workers(connection, scan_id), + draft_documents=draft_documents, ) - except ContractError as exc: - raise SystemExit(str(exc)) from exc + if selection is not None: + saved_results.record_selected_publication( + _WORKBENCH_DB_CONTEXT, connection, scan, prepared + ) + except ContractError as exc: + raise SystemExit(str(exc)) from exc warnings = json.loads(scan["completion_warnings_json"]) if warning not in warnings: connection.execute( @@ -1280,7 +1276,14 @@ def complete_budget_exhausted_scan( ) connection.commit() # A priced subtotal proves the stop but is not the saved total estimate. - return complete_scan_locked(connection, scan_id, None, None if lower_bound else cost_json) + # Sealed replay must verify current disk bytes again at completion. + return complete_scan_locked( + connection, + scan_id, + None, + None if lower_bound else cost_json, + prepared=None if prepared[5] else prepared, + ) def budget_exhausted_candidates(scan: sqlite3.Row, scan_dir: Path) -> list[dict[str, Any]]: @@ -1364,8 +1367,9 @@ def budget_exhausted_draft( before_selection: bool = False, accepted_result: dict[str, Any] | None = None, unmerged_workers: list[sqlite3.Row] = (), -) -> None: - saved_results.budget_exhausted_draft( + draft_documents: tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None = None, +) -> PreparedScanFinalization: + documents = saved_results.budget_exhausted_draft( _WORKBENCH_DB_CONTEXT, scan, scan_dir, @@ -1375,6 +1379,16 @@ def budget_exhausted_draft( before_selection=before_selection, accepted_result=accepted_result, unmerged_workers=unmerged_workers, + draft_documents=draft_documents, + ) + manifest = documents[0] + sealed = manifest["scan"].get("sealedAt") is not None or bool(manifest["scan"].get("artifacts")) + return _prepare_scan_finalization( + scan_dir, + expected_coverage_mode=expected_coverage_mode(scan), + completion_binding=workbench_completion_binding(scan, now(), manifest), + draft_documents=None if sealed else documents, + finding_scope=requested_scan_paths(scan), ) @@ -1414,6 +1428,7 @@ def complete_scan_locked( *, prepare_only: bool = False, thread_id: str | None = None, + prepared: PreparedScanFinalization | None = None, ) -> dict[str, Any]: scan = require_scan(connection, scan_id) if scan["status"] == "complete": @@ -1442,15 +1457,19 @@ def add_warning() -> None: scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) completion_timestamp = now() current_manifest_path = artifact_path(scan_dir, ARTIFACTS["manifest"], required=False) - current_manifest = None - if current_manifest_path is not None: + current_manifest = prepared[2] if prepared else None + if current_manifest_path is not None and prepared is None: current_manifest = read_json_object(current_manifest_path) already_sealed = ( - current_manifest_path is not None - and isinstance(current_manifest.get("scan"), dict) - and ( - current_manifest["scan"].get("sealedAt") is not None - or current_manifest["scan"].get("artifacts") is not None + prepared[5] + if prepared + else ( + current_manifest_path is not None + and isinstance(current_manifest.get("scan"), dict) + and ( + current_manifest["scan"].get("sealedAt") is not None + or current_manifest["scan"].get("artifacts") is not None + ) ) ) completion_binding = workbench_completion_binding(scan, completion_timestamp, current_manifest) @@ -1483,39 +1502,35 @@ def add_warning() -> None: if merge_parent_draft else nullcontext() ): - draft_documents = ( - saved_results.merge_saved_results( + if prepared is None: + draft_documents = ( + saved_results.merge_saved_results( + scan_dir, + scan["id"], + completion_binding, + connection.execute( + "SELECT * FROM deep_scan_workers WHERE scan_id = ? ORDER BY created_at, id", + (scan["id"],), + ).fetchall(), + warnings, + stopped=False, + reason="", + ) + if merge_parent_draft + else None + ) + prepared = _prepare_scan_finalization( scan_dir, - scan["id"], - completion_binding, - connection.execute( - "SELECT * FROM deep_scan_workers WHERE scan_id = ? ORDER BY created_at, id", - (scan["id"],), - ).fetchall(), - warnings, - stopped=False, - reason="", + expected_coverage_mode=expected_coverage_mode(scan), + completion_binding=completion_binding, + completion_warnings=warnings if scan["mode"] != "deep" else None, + draft_documents=draft_documents, + finding_scope=requested_scan_paths(scan) if scan["mode"] == "deep" else None, ) - if merge_parent_draft - else None - ) - completion_manifest = draft_documents[0] if draft_documents else current_manifest - if ( - completion_manifest is not None - and isinstance(completion_manifest.get("scan"), dict) - and completion_manifest["scan"].get("complete") is False - ): + if prepared[2]["scan"].get("complete") is False: raise RecoverableContractError( "The latest saved scan draft is incomplete; continue the scan before completing it." ) - prepared = _prepare_scan_finalization( - scan_dir, - expected_coverage_mode=expected_coverage_mode(scan), - completion_binding=completion_binding, - completion_warnings=warnings if scan["mode"] != "deep" else None, - draft_documents=draft_documents, - finding_scope=requested_scan_paths(scan) if scan["mode"] == "deep" else None, - ) saved_results.require_selected_publication( _WORKBENCH_DB_CONTEXT, connection, scan, prepared ) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index b8b959e4d1..04fc429a7c 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -20,6 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from finalize_scan_contract import ( ContractError, + PreparedScanFinalization, RecoverableContractError, _finding_strength, _populate_unsealed_artifact_envelope, @@ -2274,12 +2275,15 @@ def budget_exhausted_draft( before_selection: bool = False, accepted_result: dict[str, Any] | None = None, unmerged_workers: list[sqlite3.Row] = (), -) -> None: - documents: dict[str, dict[str, Any]] = {} - for name in ("scan-manifest.json", "findings.json", "coverage.json"): - path = db.artifact_path(scan_dir, name, required=False) - if path is not None: - documents[name] = db.read_json_object(path) + draft_documents: tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None = None, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + names = ("scan-manifest.json", "findings.json", "coverage.json") + documents = dict(zip(names, copy.deepcopy(draft_documents))) if draft_documents else {} + if draft_documents is None: + for name in names: + path = db.artifact_path(scan_dir, name, required=False) + if path is not None: + documents[name] = db.read_json_object(path) interrupted_documents = {} if documents and len(documents) != 3: if not before_selection or set(documents) not in ( @@ -2306,19 +2310,8 @@ def budget_exhausted_draft( raise SystemExit( "Budget-exhausted scan cannot replace an already sealed scan draft." ) - # The seal can reach disk before parent completion commits. Validate - # it without changing bytes; the existing finalizer commits replay. - try: - _prepare_scan_finalization( - scan_dir, - expected_coverage_mode=db.expected_coverage_mode(scan), - completion_binding=db.workbench_completion_binding( - scan, scan["started_at"], manifest - ), - ) - except ContractError as exc: - raise SystemExit(str(exc)) from exc - return + # Completion verifies sealed bytes directly from disk. + return manifest, findings, coverage else: target_contract = contract["target"] target: dict[str, Any] = { @@ -2467,6 +2460,7 @@ def budget_exhausted_draft( ) except (ContractError, OSError, TypeError, ValueError) as exc: raise SystemExit(f"Budget-exhausted scan draft could not be saved: {exc}") from exc + return manifest, findings, coverage def retain_unmerged_budget_coverage( @@ -2974,23 +2968,15 @@ def _require_selected_result(scan: Any, selection: dict[str, Any]) -> None: ) -def _selected_publication_digest_for_documents(db: Any, scan: Any, documents: Any) -> str: - prepared = _prepare_scan_finalization( - Path(scan["scan_dir"]), - expected_coverage_mode=db.expected_coverage_mode(scan), - completion_binding=db.workbench_completion_binding(scan, db.now(), documents[0]), - draft_documents=documents, - ) - return _selected_publication_digest(prepared) - - -def record_selected_publication(db: Any, connection: Any, scan: Any, documents: Any) -> None: +def record_selected_publication( + db: Any, connection: Any, scan: Any, prepared: PreparedScanFinalization +) -> None: run = db.deep_scan.require_deep_scan_run(connection, scan["id"]) selection = db.deep_scan.deep_scan_finalization_input(run) if selection is None: return _require_selected_result(scan, selection) - selection["publicationSha256"] = _selected_publication_digest_for_documents(db, scan, documents) + selection["publicationSha256"] = _selected_publication_digest(prepared) with connection: connection.execute( "UPDATE deep_scan_runs SET finalization_input_json = ? WHERE scan_id = ?", @@ -3130,8 +3116,20 @@ def write_scan_draft(db: Any, connection: Any, args: Any) -> dict[str, Any]: (json.dumps(document, allow_nan=False, indent=2) + "\n").encode(), ) model_warning = write_threat_model_projection_if_possible(scan_dir, manifest) - if scan["mode"] == "deep": - record_selected_publication(db, connection, scan, (manifest, findings, coverage)) + if ( + scan["mode"] == "deep" + and db.deep_scan.deep_scan_finalization_input( + db.deep_scan.require_deep_scan_run(connection, scan_id) + ) + is not None + ): + prepared = _prepare_scan_finalization( + scan_dir, + expected_coverage_mode=db.expected_coverage_mode(scan), + completion_binding=binding, + draft_documents=(manifest, findings, coverage), + ) + record_selected_publication(db, connection, scan, prepared) # Accepted Standard drafts are evidence of review or report assembly, # even when the parent omitted its explicit progress call. model_only_checkpoint = ( diff --git a/plugins/codex-security/tests/test_budget_coverage_provenance.py b/plugins/codex-security/tests/test_budget_coverage_provenance.py index f2c2d58366..9f9864dbcb 100644 --- a/plugins/codex-security/tests/test_budget_coverage_provenance.py +++ b/plugins/codex-security/tests/test_budget_coverage_provenance.py @@ -165,14 +165,11 @@ def test_cost_completion_retains_independent_unmerged_surfaces_and_receipts( message="Scan reached its original cost limit.", ) if replay != "fresh": - saved = budget.__globals__["saved_results"] - retain = saved.retain_unmerged_budget_coverage - def legacy_projection(*args): - retain(*args) + def legacy_projection(coverage): # Older writers kept host identity but omitted source descriptions. for field in ("surfaces", "explicitExclusions", "deferred", "openQuestions"): - items = args[2].get(field, []) + items = coverage.get(field, []) for item in list(items): if item.get("provenance", {}).get("workerId") == worker_id: if replay == "duplicated" and any( @@ -184,7 +181,15 @@ def legacy_projection(*args): for key in descriptions: item["provenance"].pop(key, None) - def interrupt_before_seal(connection, *_args): + def interrupt_before_seal(connection, *_args, **_kwargs): + coverage_path = scan.scan_dir / "coverage.json" + coverage = json.loads(coverage_path.read_text()) + legacy_projection(coverage) + # The old writer appended the budget marker after projecting worker rows. + coverage["deferred"].sort(key=lambda item: item.get("id") == "scan-cost-limit") + coverage_path.write_text( + json.dumps(coverage, allow_nan=False, indent=2, sort_keys=True) + "\n" + ) selection = json.loads( connection.execute( "SELECT finalization_input_json FROM deep_scan_runs WHERE scan_id = ?", @@ -205,14 +210,6 @@ def interrupt_before_seal(connection, *_args): connection.execute("PRAGMA foreign_keys = ON") workbench_db.backup(connection) with monkeypatch.context() as patch: - patch.setattr(saved, "retain_unmerged_budget_coverage", legacy_projection) - # The legacy writer predates the selected-publication digest. - patch.setattr(saved, "record_selected_publication", lambda *args: None) - patch.setattr( - budget.__globals__["deep_scan"], - "_selected_publication_digest_for_documents", - lambda *args: None, - ) patch.setitem(budget.__globals__, "complete_scan_locked", interrupt_before_seal) with pytest.raises(RuntimeError, match="budget draft committed"): budget(connection, args) diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index 255c41caf0..287fdf2470 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -706,7 +706,15 @@ def test_budget_rejects_unrelated_incomplete_drafts( @pytest.mark.parametrize( "state", - ["changed-findings", "wrong-scan", "running-discovery", "canceled", "other-owner", "complete"], + [ + "changed-findings", + "late-findings", + "wrong-scan", + "running-discovery", + "canceled", + "other-owner", + "complete", + ], ) def test_budget_sealed_replay_keeps_integrity_and_ownership_guards( workbench_api, workbench_db, publication_scan, monkeypatch, state @@ -729,7 +737,7 @@ def test_budget_sealed_replay_keeps_integrity_and_ownership_guards( patch.setitem( budget.__globals__, "complete_scan_locked", - lambda *a: complete(*a, prepare_only=True), + lambda *a, **kw: complete(*a, **kw, prepare_only=True), ) budget(workbench_db, args) manifest = scan.scan_dir / "scan-manifest.json" @@ -744,6 +752,17 @@ def test_budget_sealed_replay_keeps_integrity_and_ownership_guards( else: document["scan"]["id"] = "69078890-d24c-4416-a6fa-c286825bef88" path.write_text(json.dumps(document)) + elif state == "late-findings": + + def change_before_completion(*a, **kw): + path = scan.scan_dir / "findings.json" + document = json.loads(path.read_text()) + document["findings"] = [] + path.write_text(json.dumps(document)) + before_files["findings.json"] = path.read_bytes() + return complete(*a, **kw) + + monkeypatch.setitem(budget.__globals__, "complete_scan_locked", change_before_completion) elif state == "running-discovery": with workbench_db: workbench_db.execute("UPDATE deep_scan_runs SET status = 'running'") diff --git a/plugins/codex-security/tests/test_finalization_selection_order.py b/plugins/codex-security/tests/test_finalization_selection_order.py new file mode 100644 index 0000000000..9ad456a60f --- /dev/null +++ b/plugins/codex-security/tests/test_finalization_selection_order.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +@pytest.mark.parametrize( + ("timing", "successor"), + [ + ("tied", "succeeded"), + ("earlier", "succeeded"), + ("later", "succeeded"), + ("later", "running"), + ("later", "failed"), + ("legacy", "succeeded"), + ], +) +def test_final_selection_follows_committed_reducer_chain( + workbench_api, workbench_db, tmp_path, monkeypatch, timing, successor +): + target, scan_dir, home = (tmp_path / name for name in ("target", "scan", "home")) + target.mkdir() + scan_dir.mkdir(mode=0o700) + (target / "app.py").write_text("value = 1\n") + (home / "codex-security").mkdir(parents=True) + (home / "codex-security/config.toml").write_text( + "[deep_scan]\nworkers=1\nsubagents=0\nmax_discovery_runs=3\nstop_after_no_new=5\n" + ) + monkeypatch.setenv("CODEX_HOME", str(home)) + monkeypatch.setenv("CODEX_SECURITY_STATE_DIR", str(tmp_path / "state")) + clock = ["2030-01-01T00:00:01Z"] + deep = workbench_api["deep_scan"] + monkeypatch.setattr( + deep, + "_dependencies", + SimpleNamespace( + **{ + **workbench_api, + "now": lambda: clock[0], + "preserve_stopped_results": workbench_api[ + "preserve_stopped_results_after_transition" + ], + } + ), + ) + + def args(command, *words, **overrides): + monkeypatch.setattr(sys, "argv", ["workbench_db", command, *map(str, words)]) + parsed = workbench_api["parse_args"]("test") + vars(parsed).update(overrides) + return parsed + + registered = workbench_api["register_cli_scan"]( + workbench_db, + args( + "register-cli-scan", + "--scan-dir", + scan_dir, + "--repository", + target, + "--recipe-json", + json.dumps( + { + "config": {}, + "mode": "deep", + "repository": str(target), + "target": {"kind": "repository", "paths": []}, + } + ), + ), + ) + scan_id = registered["scanId"] + deep.begin_deep_scan( + workbench_db, + args( + "begin-deep-scan", + "--scan-id", + scan_id, + "--thread-id", + "synthetic-owner", + execution_settings={ + "codexPath": sys.executable, + "codexHome": str(home), + "parentSandbox": {"filesystemDenies": []}, + }, + ), + ) + coverage = { + "completeness": "complete", + "surfaces": [], + "explicitExclusions": [], + "deferred": [], + } + + def worker(number, inputs=None, status="succeeded"): + kind = "discovery" if inputs is None else "dedup" + prefix = "10000000" if inputs is None else "ffffffff" if number == 1 else "00000000" + worker_id = f"{prefix}-0000-4000-8000-{number:012d}" + directory = scan_dir / "workers" / f"{kind}-{number}" + directory.mkdir(parents=True) + prompt, result = directory / "prompt.md", directory / "result.json" + prompt.write_text("Synthetic review") + common = [ + "--scan-id", + scan_id, + "--worker-id", + worker_id, + "--prompt-path", + prompt, + "--artifact-dir", + directory, + ] + if inputs is not None: + deep.claim_deep_scan_dedup( + workbench_db, + args( + "claim-deep-scan-dedup", + *common, + *(value for item in inputs for value in ("--input-worker-id", item)), + ), + ) + common += ["--kind", kind, "--attempt", "1"] + deep.upsert_deep_scan_worker( + workbench_db, args("upsert-deep-scan-worker", *common, "--status", "running") + ) + result.write_text( + json.dumps( + { + "scanId": scan_id, + "complete": True, + "findings": [], + "coverage" if inputs is None else "sourceCoverage": { + **coverage, + "completeness": "partial", + "deferred": [{"reason": f"Review gap from {kind} {number}."}], + }, + } + ) + ) + if inputs is not None and status == "succeeded": + deep.commit_deep_scan_dedup( + workbench_db, + args( + "commit-deep-scan-dedup", + "--scan-id", + scan_id, + "--worker-id", + worker_id, + "--result-manifest-path", + result, + "--new-findings-count", + "1", + ), + ) + elif status != "running": + deep.upsert_deep_scan_worker( + workbench_db, + args( + "upsert-deep-scan-worker", + *common, + "--status", + status, + "--result-manifest-path", + result, + ), + ) + return worker_id + + first = worker(1, [worker(1), worker(2)]) + clock[0] = {"earlier": "2030-01-01T00:00:00Z", "tied": clock[0]}.get( + timing, "2030-01-01T00:00:02Z" + ) + second = worker(2, [worker(3)], successor) + assert ( + workbench_db.execute( + "SELECT previous_worker_id FROM deep_scan_merge_claims WHERE worker_id = ?", (second,) + ).fetchone()[0] + == first + ) + if timing == "legacy": + with workbench_db: + workbench_db.execute("DELETE FROM deep_scan_merge_claims") + expected_worker = second if successor == "succeeded" else first + accepted = workbench_db.execute( + "SELECT accepted_result_path, accepted_result_sha256 FROM deep_scan_attempts WHERE worker_id = ?", + (expected_worker,), + ).fetchone() + run, scan = deep.require_running_deep_scan(workbench_db, scan_id) + + def observe_budget_draft(document, unmerged): + assert document == json.loads(Path(accepted["accepted_result_path"]).read_bytes()) + assert len(unmerged) == (0 if successor == "succeeded" else 1) + raise RuntimeError("observed budget draft boundary") + + # Exercise budget auto-selection before committing a normal final selection. + with pytest.raises(RuntimeError, match="observed budget draft boundary"): + deep.prepare_budget_exhausted_deep_scan( + workbench_db, scan, scan_dir, "Synthetic budget stop", observe_budget_draft + ) + finish = args( + "finish-deep-scan", + "--scan-id", + scan_id, + "--terminal-reason", + "capped", + "--manifest-path", + scan_dir / "scan-manifest.json", + select_finalization=True, + finalization_result_path=accepted["accepted_result_path"], + ) + if successor == "succeeded": + previous = workbench_db.execute( + "SELECT accepted_result_path FROM deep_scan_attempts WHERE worker_id = ?", (first,) + ).fetchone()[0] + stale = SimpleNamespace(**{**vars(finish), "finalization_result_path": previous}) + with pytest.raises(SystemExit, match="committed accepted reducer"): + deep.selected_deep_scan_finalization(workbench_db, run, scan, stale, [], False) + selection = deep.finish_deep_scan_locked(workbench_db, finish, scan_id)["deepScan"][ + "finalizationInput" + ] + else: + # A pending or failed successor cannot replace the last committed input + # selected for budget publication, which does not await model work. + selection = deep.selected_deep_scan_finalization(workbench_db, run, scan, finish, [], False) + assert ( + selection["resultPath"] + == Path(accepted["accepted_result_path"]).relative_to(scan_dir).as_posix() + ) + assert selection["resultSha256"] == accepted["accepted_result_sha256"] diff --git a/plugins/codex-security/tests/test_selected_publication_integrity.py b/plugins/codex-security/tests/test_selected_publication_integrity.py index 53d8c2316b..ffc48dd165 100644 --- a/plugins/codex-security/tests/test_selected_publication_integrity.py +++ b/plugins/codex-security/tests/test_selected_publication_integrity.py @@ -209,3 +209,108 @@ def test_stopped_recovery_keeps_accepted_reviews_with_later_parent_coverage( ) assert (scan.scan_dir / "scan-manifest.json").read_bytes() == manifest assert accepted.read_bytes() == contents + + +@pytest.mark.parametrize("cut", ["none", "projection", "selected-validation", "completion"]) +def test_budget_completion_uses_one_accepted_snapshot( + workbench_api, workbench_db, publication_scan, monkeypatch, cut +): + scan = publication_scan() + api, connection = workbench_api, workbench_db + if cut == "selected-validation": + accepted, _ = publish_selected(api, connection, scan) + else: + _, accepted, _ = accept_reducer(connection, scan) + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + with connection: + connection.execute( + "UPDATE deep_scan_runs SET status='running', phase='discovery', " + "workflow_version='deep-security-scan/v2', manifest_path=NULL, " + "terminal_reason=NULL, completed_at=NULL" + ) + original = accepted.read_bytes() + title = json.loads(original)["findings"][0]["title"] + with connection: + recipe = json.loads(connection.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + connection.execute("UPDATE scans SET recipe_json=?", (json.dumps(recipe),)) + replaced = [] + + def replace_canonical(): + if replaced: + return + path = scan.scan_dir / "findings.json" + document = json.loads(path.read_bytes()) + document["findings"][0]["title"] = "Synthetic replacement after acceptance" + path.write_text(json.dumps(document)) + replaced.append(True) + + if cut == "selected-validation": + original_check = api["saved_results"].require_selected_publication + + def check(*args): + value = original_check(*args) + replace_canonical() + return value + + monkeypatch.setattr(api["saved_results"], "require_selected_publication", check) + elif cut != "none": + namespace = api["complete_budget_exhausted_scan"].__globals__ + key = "budget_exhausted_draft" if cut == "projection" else "complete_scan_locked" + original_call = namespace[key] + + def call(*args, **kwargs): + if cut == "completion": + replace_canonical() + value = original_call(*args, **kwargs) + if cut == "projection": + replace_canonical() + return value + + monkeypatch.setitem(namespace, key, call) + api["complete_budget_exhausted_scan"]( + connection, Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None) + ) + assert bool(replaced) is (cut != "none") + assert accepted.read_bytes() == original + assert connection.execute("SELECT status FROM scans").fetchone()[0] == "complete" + published = json.loads((scan.scan_dir / "findings.json").read_bytes()) + assert published["findings"][0]["title"] == title + published["findings"][0]["title"] = "Synthetic corruption after sealing" + (scan.scan_dir / "findings.json").write_text(json.dumps(published)) + with pytest.raises(SystemExit): + api["complete_scan"]( + connection, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + + +@pytest.mark.parametrize("replace", [False, True]) +def test_completion_checks_the_manifest_it_prepares( + workbench_api, workbench_db, publication_scan, monkeypatch, replace +): + scan = publication_scan() + namespace = workbench_api["complete_scan_locked"].__globals__ + prepare = namespace["_prepare_scan_finalization"] + + def prepare_after_replacement(*args, **kwargs): + if replace: + path = scan.scan_dir / "scan-manifest.json" + manifest = json.loads(path.read_bytes()) + manifest["scan"]["complete"] = False + path.write_text(json.dumps(manifest)) + return prepare(*args, **kwargs) + + monkeypatch.setitem(namespace, "_prepare_scan_finalization", prepare_after_replacement) + args = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + if replace: + with pytest.raises(SystemExit, match="incomplete"): + workbench_api["complete_scan"](workbench_db, args) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + assert ( + "sealedAt" + not in json.loads((scan.scan_dir / "scan-manifest.json").read_bytes())["scan"] + ) + else: + workbench_api["complete_scan"](workbench_db, args) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index 3c40f830de..72f0249cb2 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -1,18 +1,11 @@ import { isDeepStrictEqual as sameArray } from "node:util"; import { createHash, hash } from "node:crypto"; import { isNonEmptyString } from "./value.js"; -import { constants, type BigIntStats, type Stats } from "node:fs"; -import { - lstat, - open, - readFile, - realpath, - type FileHandle, -} from "node:fs/promises"; -import { isAbsolute, join, posix, resolve } from "node:path"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import Ajv2020, { type ErrorObject } from "ajv/dist/2020.js"; import { regexes } from "zod"; -import { ContractValidationError, abortReason } from "./errors.js"; +import { ContractValidationError } from "./errors.js"; import { isRecord } from "./record.js"; import type { ContractObject as JsonRecord, @@ -21,12 +14,20 @@ import type { ScanManifest, } from "./models.js"; import { - requirePrivateOutputDirectory, - requireSecureOutputAncestry, -} from "./runtime.js"; + openCheckedScanFile, + portableRelativePath, + requireScanRoot, + safeRelativePath, + throwIfAborted, + verifyScanRoot, + type ScanRoot, +} from "./scan-files.js"; +export { + requireScanFile, + readScanFile, + sameCheckedFileDevice, +} from "./scan-files.js"; import type { NormalizedTarget, ScanMode } from "./targets.js"; -import { isWithin as isContained } from "./trusted-executable.js"; -import { isWindowsUnsafePathComponent } from "./windows-path.js"; const DOCUMENTS = { "scan-manifest.json": "scan-manifest.schema.json", @@ -34,17 +35,6 @@ const DOCUMENTS = { "coverage.json": "coverage.schema.json", } as const; const PRODUCER_NAME = "codex-security-plugin"; -interface CheckedScanFile { - path: string; - metadata: Stats; - parents: Array<{ path: string; metadata: Stats }>; -} - -interface ScanRoot { - path: string; - metadata: Stats; -} - export interface ScanExpectation { repository: string; repositoryRevision: string | null; @@ -465,98 +455,6 @@ function validateCanonicalContract( } } -export async function requireScanFile( - scanDirectory: string, - relativePath: string, - context: string, - signal?: AbortSignal, -): Promise { - return ( - await requireCheckedScanFile(scanDirectory, relativePath, context, signal) - ).path; -} - -export async function readScanFile( - scanDirectory: string, - relativePath: string, - context: string, - signal?: AbortSignal, -): Promise { - const file = await openCheckedScanFile( - scanDirectory, - relativePath, - context, - signal, - ); - try { - return await file.readFile({ signal }); - } finally { - await file.close(); - } -} - -async function requireCheckedScanFile( - scanDirectory: string, - relativePath: string, - context: string, - signal?: AbortSignal, - expectedRoot?: ScanRoot, -): Promise { - const checkedRoot = await requireScanRoot(scanDirectory, signal); - const scanDir = checkedRoot.path; - throwIfAborted(signal); - const safePath = portableRelativePath(relativePath, context); - const parts = safePath.split("/"); - let current = scanDir; - try { - const rootMetadata = checkedRoot.metadata; - if ( - expectedRoot !== undefined && - (scanDir !== expectedRoot.path || - rootMetadata.dev !== expectedRoot.metadata.dev || - rootMetadata.ino !== expectedRoot.metadata.ino) - ) { - throw new Error("scan directory changed while reading"); - } - const parents = [{ path: scanDir, metadata: rootMetadata }]; - throwIfAborted(signal); - for (const part of parts.slice(0, -1)) { - current = join(current, part); - const metadata = await lstat(current); - throwIfAborted(signal); - if (!metadata.isDirectory()) { - throw new Error("unsafe parent"); - } - parents.push({ path: current, metadata }); - } - const path = join(scanDir, ...parts); - const metadata = await lstat(path); - throwIfAborted(signal); - if (!metadata.isFile()) { - throw new ContractValidationError( - `${context}: expected a regular non-symlink file.`, - ); - } - const canonical = await realpath(path); - throwIfAborted(signal); - if (!isContained(scanDir, canonical)) { - throw new Error("outside scan directory"); - } - return { path, metadata, parents }; - } catch (error) { - throwIfAborted(signal); - if (error instanceof ContractValidationError) { - throw error; - } - throw new ContractValidationError( - `${context}: expected a file inside the scan directory.`, - { - cause: error, - }, - ); - } -} - async function validateSeal( scanDir: string, manifest: ScanManifest, @@ -741,118 +639,6 @@ function expectedCoverageMode( return mode === "deep" ? "deep_repository" : "repository"; } -async function requireScanRoot( - scanDirectory: string, - signal?: AbortSignal, -): Promise { - throwIfAborted(signal); - const absolute = resolve(scanDirectory); - try { - const metadata = await lstat(absolute); - throwIfAborted(signal); - const canonical = await realpath(absolute); - throwIfAborted(signal); - const current = await lstat(absolute); - throwIfAborted(signal); - const returned = await lstat(canonical); - throwIfAborted(signal); - if ( - !metadata.isDirectory() || - !current.isDirectory() || - metadata.dev !== current.dev || - metadata.ino !== current.ino || - !returned.isDirectory() || - metadata.dev !== returned.dev || - metadata.ino !== returned.ino - ) { - throw new Error("not a directory"); - } - try { - requirePrivateOutputDirectory(returned, canonical); - await requireSecureOutputAncestry(canonical); - } catch (error) { - throw new ContractValidationError( - error instanceof Error - ? error.message - : "Scan directory must remain private to the current user.", - { cause: error }, - ); - } - return { path: canonical, metadata: returned }; - } catch (error) { - throwIfAborted(signal); - if (error instanceof ContractValidationError) throw error; - throw new ContractValidationError( - "Scan directory must be an existing non-symlink directory.", - { cause: error }, - ); - } -} - -async function verifyScanRoot( - root: ScanRoot, - signal?: AbortSignal, -): Promise { - try { - const current = await lstat(root.path); - throwIfAborted(signal); - if ( - !current.isDirectory() || - current.dev !== root.metadata.dev || - current.ino !== root.metadata.ino - ) { - throw new Error("scan directory changed while reading"); - } - requirePrivateOutputDirectory(current, root.path); - await requireSecureOutputAncestry(root.path); - } catch (error) { - throwIfAborted(signal); - throw new ContractValidationError( - "Scan directory changed while reading the canonical contract.", - { cause: error }, - ); - } -} - -function safeRelativePath(value: string, context: string): string { - const parts = value.split("/"); - if ( - value.trim().length === 0 || - Buffer.from(value, "utf8").toString("utf8") !== value || - value === "." || - value.startsWith("/") || - /^[A-Za-z]:/.test(value) || - parts.includes("..") || - value.includes("\\") || - /[\u0000-\u001f]/u.test(value) - ) { - throw new ContractValidationError( - `${context}: expected a safe scan-relative POSIX path.`, - ); - } - const normalized = posix.normalize(value).replace(/\/+$/, ""); - if ( - normalized === "." || - normalized.startsWith("../") || - isAbsolute(normalized) - ) { - throw new ContractValidationError( - `${context}: expected a safe scan-relative POSIX path.`, - ); - } - return normalized; -} - -function portableRelativePath(value: string, context: string): string { - const normalized = safeRelativePath(value, context); - if (value.split("/").some(isWindowsUnsafePathComponent)) { - throw new ContractValidationError( - `${context}: expected a safe scan-relative POSIX path.`, - ); - } - return normalized; -} - function safeScopePath(value: string): string { return value === "." ? value @@ -1017,117 +803,6 @@ async function sha256ScanFile( } } -async function openCheckedScanFile( - scanDir: string, - relativePath: string, - context: string, - signal?: AbortSignal, - expectedRoot?: ScanRoot, -): Promise { - const checked = await requireCheckedScanFile( - scanDir, - relativePath, - context, - signal, - expectedRoot, - ); - let file: FileHandle | undefined; - try { - file = await open( - checked.path, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, - ); - const opened = await file.stat(); - throwIfAborted(signal); - if ( - !opened.isFile() || - opened.ino !== checked.metadata.ino || - !(await sameCheckedFileDevice(file, checked, opened)) - ) { - throw new ContractValidationError( - `${context}: expected the checked regular file.`, - ); - } - for (const parent of checked.parents) { - const current = await lstat(parent.path); - throwIfAborted(signal); - if ( - !current.isDirectory() || - current.dev !== parent.metadata.dev || - current.ino !== parent.metadata.ino - ) { - throw new ContractValidationError( - `${context}: checked parent changed before opening the file.`, - ); - } - } - const current = await lstat(checked.path); - throwIfAborted(signal); - if ( - !current.isFile() || - current.dev !== checked.metadata.dev || - current.ino !== checked.metadata.ino - ) { - throw new ContractValidationError( - `${context}: checked file changed before reading.`, - ); - } - return file; - } catch (error) { - await file?.close(); - throwIfAborted(signal); - if (error instanceof ContractValidationError) throw error; - throw new ContractValidationError( - `${context}: unable to open the checked regular file.`, - { cause: error }, - ); - } -} - -export async function sameCheckedFileDevice( - file: FileHandle, - checked: { path: string; metadata: Pick }, - opened: Pick, - platform: NodeJS.Platform = process.platform, - openReference: (path: string, flags: number) => Promise = open, -): Promise { - if (opened.ino !== checked.metadata.ino) return false; - if (opened.dev === checked.metadata.dev) return true; - if (platform !== "win32") return false; - - const [openedIdentity, checkedIdentity] = await Promise.all([ - file.stat({ bigint: true }), - lstat(checked.path, { bigint: true }), - ]); - if ( - !openedIdentity.isFile() || - !checkedIdentity.isFile() || - openedIdentity.ino !== checkedIdentity.ino - ) { - return false; - } - - const reference = await openReference( - checked.path, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, - ); - try { - const referenceIdentity = await reference.stat({ bigint: true }); - return ( - referenceIdentity.isFile() && - openedIdentity.dev === referenceIdentity.dev && - openedIdentity.ino === referenceIdentity.ino - ); - } finally { - await reference.close(); - } -} - -function throwIfAborted(signal?: AbortSignal): void { - if (!signal?.aborted) return; - throw abortReason(signal); -} - const RFC3339_DATE_TIME = new RegExp( regexes.datetime({ offset: true }).source, "i", diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 0246431b3b..ffc94c221a 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -74,6 +74,17 @@ import { windowsUnsafePathComponent, } from "./windows-path.js"; +import { + nodeErrorCode, + requirePrivateOutputDirectory, + requireSecureOutputAncestry, +} from "./scan-files.js"; +export { + requirePrivateOutputDirectory, + requireSecureOutputAncestry, + requireTrustedOutputAncestor, +} from "./scan-files.js"; + const execFile = promisify(execFileCallback); export const MARKETPLACE_NAME = "codex-security-sdk"; @@ -2105,96 +2116,6 @@ export async function validatePreparedOutputDir( return canonical; } -export function requirePrivateOutputDirectory( - metadata: Pick, - path: string, - effectiveUid = process.geteuid?.(), -): void { - if (process.platform === "win32") return; - if ((metadata.mode & 0o077) !== 0) { - throw new OutputDirectoryError( - `Scan output directory must not be accessible to other users (chmod 700): ${path}`, - ); - } - if (effectiveUid !== undefined && metadata.uid !== effectiveUid) { - throw new OutputDirectoryError( - `Scan output directory must be owned by the current user: ${path}`, - ); - } -} - -/** Reject shared parents whose owner can rename or replace private scan output. */ -export async function requireSecureOutputAncestry( - path: string, - effectiveUid = process.geteuid?.(), -): Promise { - if (process.platform === "win32") return; - let current = dirname(resolve(path)); - while (true) { - try { - current = await realpath(current); - break; - } catch (error) { - if (nodeErrorCode(error) !== "ENOENT") { - throw new OutputDirectoryError( - `Unable to inspect scan output parent directory: ${current}`, - { cause: error }, - ); - } - const parent = dirname(current); - if (parent === current) { - throw new OutputDirectoryError( - `Unable to inspect scan output parent directory: ${current}`, - { cause: error }, - ); - } - current = parent; - } - } - while (true) { - let metadata: Stats; - try { - metadata = await lstat(current); - } catch (error) { - throw new OutputDirectoryError( - `Unable to inspect scan output parent directory: ${current}`, - { cause: error }, - ); - } - if (!metadata.isDirectory()) { - throw new OutputDirectoryError( - `Scan output parent must be a non-symlink directory: ${current}`, - ); - } - requireTrustedOutputAncestor(metadata, current, effectiveUid); - const parent = dirname(current); - if (parent === current) return; - current = parent; - } -} - -export function requireTrustedOutputAncestor( - metadata: Pick, - path: string, - effectiveUid = process.geteuid?.(), -): void { - if ( - effectiveUid !== undefined && - metadata.uid !== 0 && - metadata.uid !== effectiveUid - ) { - throw new OutputDirectoryError( - `Scan output parent must have a trusted owner: ${path}`, - ); - } - if ((metadata.mode & 0o022) === 0) return; - if ((metadata.mode & 0o1000) === 0) { - throw new OutputDirectoryError( - `Scan output parent must not be group- or world-writable without the sticky bit: ${path}`, - ); - } -} - async function removeEmptyDirectories( path: string, root: string, @@ -3560,12 +3481,6 @@ function processErrorDetail(error: unknown): string { return String(error) || "unknown error"; } -function nodeErrorCode(error: unknown): string | undefined { - return isRecord(error) && typeof error["code"] === "string" - ? error["code"] - : undefined; -} - function throwIfSignalAborted(signal?: AbortSignal): void { if (signal?.aborted) throw abortReason(signal); } diff --git a/sdk/typescript/src/scan-files.ts b/sdk/typescript/src/scan-files.ts new file mode 100644 index 0000000000..8b2ce7d3ee --- /dev/null +++ b/sdk/typescript/src/scan-files.ts @@ -0,0 +1,433 @@ +import { constants, type BigIntStats, type Stats } from "node:fs"; +import { lstat, open, realpath, type FileHandle } from "node:fs/promises"; +import { dirname, isAbsolute, join, posix, resolve } from "node:path"; +import { + ContractValidationError, + OutputDirectoryError, + abortReason, +} from "./errors.js"; +import { isRecord } from "./record.js"; +import { isWithin as isContained } from "./trusted-executable.js"; +import { isWindowsUnsafePathComponent } from "./windows-path.js"; + +interface CheckedScanFile { + path: string; + metadata: Stats; + parents: Array<{ path: string; metadata: Stats }>; +} + +export interface ScanRoot { + path: string; + metadata: Stats; +} + +export async function requireScanFile( + scanDirectory: string, + relativePath: string, + context: string, + signal?: AbortSignal, +): Promise { + return ( + await requireCheckedScanFile(scanDirectory, relativePath, context, signal) + ).path; +} + +export async function readScanFile( + scanDirectory: string, + relativePath: string, + context: string, + signal?: AbortSignal, +): Promise { + const file = await openCheckedScanFile( + scanDirectory, + relativePath, + context, + signal, + ); + try { + return await file.readFile({ signal }); + } finally { + await file.close(); + } +} + +async function requireCheckedScanFile( + scanDirectory: string, + relativePath: string, + context: string, + signal?: AbortSignal, + expectedRoot?: ScanRoot, +): Promise { + const checkedRoot = await requireScanRoot(scanDirectory, signal); + const scanDir = checkedRoot.path; + throwIfAborted(signal); + const safePath = portableRelativePath(relativePath, context); + const parts = safePath.split("/"); + let current = scanDir; + try { + const rootMetadata = checkedRoot.metadata; + if ( + expectedRoot !== undefined && + (scanDir !== expectedRoot.path || + rootMetadata.dev !== expectedRoot.metadata.dev || + rootMetadata.ino !== expectedRoot.metadata.ino) + ) { + throw new Error("scan directory changed while reading"); + } + const parents = [{ path: scanDir, metadata: rootMetadata }]; + throwIfAborted(signal); + for (const part of parts.slice(0, -1)) { + current = join(current, part); + const metadata = await lstat(current); + throwIfAborted(signal); + if (!metadata.isDirectory()) { + throw new Error("unsafe parent"); + } + parents.push({ path: current, metadata }); + } + const path = join(scanDir, ...parts); + const metadata = await lstat(path); + throwIfAborted(signal); + if (!metadata.isFile()) { + throw new ContractValidationError( + `${context}: expected a regular non-symlink file.`, + ); + } + const canonical = await realpath(path); + throwIfAborted(signal); + if (!isContained(scanDir, canonical)) { + throw new Error("outside scan directory"); + } + return { path, metadata, parents }; + } catch (error) { + throwIfAborted(signal); + if (error instanceof ContractValidationError) { + throw error; + } + throw new ContractValidationError( + `${context}: expected a file inside the scan directory.`, + { + cause: error, + }, + ); + } +} + +export async function requireScanRoot( + scanDirectory: string, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal); + const absolute = resolve(scanDirectory); + try { + const metadata = await lstat(absolute); + throwIfAborted(signal); + const canonical = await realpath(absolute); + throwIfAborted(signal); + const current = await lstat(absolute); + throwIfAborted(signal); + const returned = await lstat(canonical); + throwIfAborted(signal); + if ( + !metadata.isDirectory() || + !current.isDirectory() || + metadata.dev !== current.dev || + metadata.ino !== current.ino || + !returned.isDirectory() || + metadata.dev !== returned.dev || + metadata.ino !== returned.ino + ) { + throw new Error("not a directory"); + } + try { + requirePrivateOutputDirectory(returned, canonical); + await requireSecureOutputAncestry(canonical); + } catch (error) { + throw new ContractValidationError( + error instanceof Error + ? error.message + : "Scan directory must remain private to the current user.", + { cause: error }, + ); + } + return { path: canonical, metadata: returned }; + } catch (error) { + throwIfAborted(signal); + if (error instanceof ContractValidationError) throw error; + throw new ContractValidationError( + "Scan directory must be an existing non-symlink directory.", + { cause: error }, + ); + } +} + +export async function verifyScanRoot( + root: ScanRoot, + signal?: AbortSignal, +): Promise { + try { + const current = await lstat(root.path); + throwIfAborted(signal); + if ( + !current.isDirectory() || + current.dev !== root.metadata.dev || + current.ino !== root.metadata.ino + ) { + throw new Error("scan directory changed while reading"); + } + requirePrivateOutputDirectory(current, root.path); + await requireSecureOutputAncestry(root.path); + } catch (error) { + throwIfAborted(signal); + throw new ContractValidationError( + "Scan directory changed while reading the canonical contract.", + { cause: error }, + ); + } +} + +export function safeRelativePath(value: string, context: string): string { + const parts = value.split("/"); + if ( + value.trim().length === 0 || + Buffer.from(value, "utf8").toString("utf8") !== value || + value === "." || + value.startsWith("/") || + /^[A-Za-z]:/.test(value) || + parts.includes("..") || + value.includes("\\") || + /[\u0000-\u001f]/u.test(value) + ) { + throw new ContractValidationError( + `${context}: expected a safe scan-relative POSIX path.`, + ); + } + const normalized = posix.normalize(value).replace(/\/+$/, ""); + if ( + normalized === "." || + normalized.startsWith("../") || + isAbsolute(normalized) + ) { + throw new ContractValidationError( + `${context}: expected a safe scan-relative POSIX path.`, + ); + } + return normalized; +} + +export function portableRelativePath(value: string, context: string): string { + const normalized = safeRelativePath(value, context); + if (value.split("/").some(isWindowsUnsafePathComponent)) { + throw new ContractValidationError( + `${context}: expected a safe scan-relative POSIX path.`, + ); + } + return normalized; +} + +export async function openCheckedScanFile( + scanDir: string, + relativePath: string, + context: string, + signal?: AbortSignal, + expectedRoot?: ScanRoot, +): Promise { + const checked = await requireCheckedScanFile( + scanDir, + relativePath, + context, + signal, + expectedRoot, + ); + let file: FileHandle | undefined; + try { + file = await open( + checked.path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + const opened = await file.stat(); + throwIfAborted(signal); + if ( + !opened.isFile() || + opened.ino !== checked.metadata.ino || + !(await sameCheckedFileDevice(file, checked, opened)) + ) { + throw new ContractValidationError( + `${context}: expected the checked regular file.`, + ); + } + for (const parent of checked.parents) { + const current = await lstat(parent.path); + throwIfAborted(signal); + if ( + !current.isDirectory() || + current.dev !== parent.metadata.dev || + current.ino !== parent.metadata.ino + ) { + throw new ContractValidationError( + `${context}: checked parent changed before opening the file.`, + ); + } + } + const current = await lstat(checked.path); + throwIfAborted(signal); + if ( + !current.isFile() || + current.dev !== checked.metadata.dev || + current.ino !== checked.metadata.ino + ) { + throw new ContractValidationError( + `${context}: checked file changed before reading.`, + ); + } + return file; + } catch (error) { + await file?.close(); + throwIfAborted(signal); + if (error instanceof ContractValidationError) throw error; + throw new ContractValidationError( + `${context}: unable to open the checked regular file.`, + { cause: error }, + ); + } +} + +export async function sameCheckedFileDevice( + file: FileHandle, + checked: { path: string; metadata: Pick }, + opened: Pick, + platform: NodeJS.Platform = process.platform, + openReference: (path: string, flags: number) => Promise = open, +): Promise { + if (opened.ino !== checked.metadata.ino) return false; + if (opened.dev === checked.metadata.dev) return true; + if (platform !== "win32") return false; + + const [openedIdentity, checkedIdentity] = await Promise.all([ + file.stat({ bigint: true }), + lstat(checked.path, { bigint: true }), + ]); + if ( + !openedIdentity.isFile() || + !checkedIdentity.isFile() || + openedIdentity.ino !== checkedIdentity.ino + ) { + return false; + } + + const reference = await openReference( + checked.path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const referenceIdentity = await reference.stat({ bigint: true }); + return ( + referenceIdentity.isFile() && + openedIdentity.dev === referenceIdentity.dev && + openedIdentity.ino === referenceIdentity.ino + ); + } finally { + await reference.close(); + } +} + +export function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + throw abortReason(signal); +} + +export function requirePrivateOutputDirectory( + metadata: Pick, + path: string, + effectiveUid = process.geteuid?.(), +): void { + if (process.platform === "win32") return; + if ((metadata.mode & 0o077) !== 0) { + throw new OutputDirectoryError( + `Scan output directory must not be accessible to other users (chmod 700): ${path}`, + ); + } + if (effectiveUid !== undefined && metadata.uid !== effectiveUid) { + throw new OutputDirectoryError( + `Scan output directory must be owned by the current user: ${path}`, + ); + } +} + +/** Reject shared parents whose owner can rename or replace private scan output. */ +export async function requireSecureOutputAncestry( + path: string, + effectiveUid = process.geteuid?.(), +): Promise { + if (process.platform === "win32") return; + let current = dirname(resolve(path)); + while (true) { + try { + current = await realpath(current); + break; + } catch (error) { + if (nodeErrorCode(error) !== "ENOENT") { + throw new OutputDirectoryError( + `Unable to inspect scan output parent directory: ${current}`, + { cause: error }, + ); + } + const parent = dirname(current); + if (parent === current) { + throw new OutputDirectoryError( + `Unable to inspect scan output parent directory: ${current}`, + { cause: error }, + ); + } + current = parent; + } + } + while (true) { + let metadata: Stats; + try { + metadata = await lstat(current); + } catch (error) { + throw new OutputDirectoryError( + `Unable to inspect scan output parent directory: ${current}`, + { cause: error }, + ); + } + if (!metadata.isDirectory()) { + throw new OutputDirectoryError( + `Scan output parent must be a non-symlink directory: ${current}`, + ); + } + requireTrustedOutputAncestor(metadata, current, effectiveUid); + const parent = dirname(current); + if (parent === current) return; + current = parent; + } +} + +export function requireTrustedOutputAncestor( + metadata: Pick, + path: string, + effectiveUid = process.geteuid?.(), +): void { + if ( + effectiveUid !== undefined && + metadata.uid !== 0 && + metadata.uid !== effectiveUid + ) { + throw new OutputDirectoryError( + `Scan output parent must have a trusted owner: ${path}`, + ); + } + if ((metadata.mode & 0o022) === 0) return; + if ((metadata.mode & 0o1000) === 0) { + throw new OutputDirectoryError( + `Scan output parent must not be group- or world-writable without the sticky bit: ${path}`, + ); + } +} + +export function nodeErrorCode(error: unknown): string | undefined { + return isRecord(error) && typeof error["code"] === "string" + ? error["code"] + : undefined; +} From a81ca50292bbbe05aa0e3c6960289d2c35d382eb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 12 Oct 2026 08:59:10 +0000 Subject: [PATCH 238/251] fix: follow reducer causality when claiming work --- .../scripts/deep_scan_workbench.py | 20 +++-------- .../test_finalization_selection_order.py | 35 +++++++++++++++---- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index ec2b453156..79dc398552 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -1841,15 +1841,8 @@ def claim_deep_scan_dedup( ).fetchone() is None ) - successful_reducer = connection.execute( - """ - SELECT 1 FROM deep_scan_workers - WHERE scan_id = ? AND kind = 'dedup' AND status = 'succeeded' - LIMIT 1 - """, - (scan_id,), - ).fetchone() - minimum_inputs = 1 if successful_reducer is not None or capped_singleton else 2 + previous = final_deep_scan_reducer(connection, scan_id) + minimum_inputs = 1 if previous is not None or capped_singleton else 2 if len(input_ids) < minimum_inputs: raise SystemExit( "The first Deep Scan dedup requires two buffered discovery results." @@ -1866,11 +1859,6 @@ def claim_deep_scan_dedup( """, (worker_id, scan_id, prompt_path, artifact_dir, timestamp, timestamp), ) - previous = connection.execute( - "SELECT * FROM deep_scan_workers WHERE scan_id = ? AND kind = 'dedup' " - "AND status = 'succeeded' ORDER BY completed_at DESC, rowid DESC LIMIT 1", - (scan_id,), - ).fetchone() previous_path, previous_sha = ( worker_result_reference(connection, scan, previous) if previous else (None, None) ) @@ -2417,8 +2405,8 @@ def final_deep_scan_reducer(connection: sqlite3.Connection, scan_id: str) -> sql "WHERE claims.previous_worker_id = workers.id AND claims.scan_id = workers.scan_id " "AND successor.scan_id = workers.scan_id AND successor.kind = 'dedup' " "AND successor.status = 'succeeded') " - # Legacy reducers without claims retain their previous ordering. - "ORDER BY workers.completed_at DESC, workers.id DESC LIMIT 1", + # Legacy reducers retain the producer's completion and insertion order. + "ORDER BY workers.completed_at DESC, workers.rowid DESC LIMIT 1", (scan_id,), ).fetchone() diff --git a/plugins/codex-security/tests/test_finalization_selection_order.py b/plugins/codex-security/tests/test_finalization_selection_order.py index 9ad456a60f..a4843eabdd 100644 --- a/plugins/codex-security/tests/test_finalization_selection_order.py +++ b/plugins/codex-security/tests/test_finalization_selection_order.py @@ -17,6 +17,7 @@ ("later", "running"), ("later", "failed"), ("legacy", "succeeded"), + ("legacy-tied", "succeeded"), ], ) def test_final_selection_follows_committed_reducer_chain( @@ -28,7 +29,7 @@ def test_final_selection_follows_committed_reducer_chain( (target / "app.py").write_text("value = 1\n") (home / "codex-security").mkdir(parents=True) (home / "codex-security/config.toml").write_text( - "[deep_scan]\nworkers=1\nsubagents=0\nmax_discovery_runs=3\nstop_after_no_new=5\n" + "[deep_scan]\nworkers=1\nsubagents=0\nmax_discovery_runs=4\nstop_after_no_new=5\n" ) monkeypatch.setenv("CODEX_HOME", str(home)) monkeypatch.setenv("CODEX_SECURITY_STATE_DIR", str(tmp_path / "state")) @@ -171,9 +172,11 @@ def worker(number, inputs=None, status="succeeded"): return worker_id first = worker(1, [worker(1), worker(2)]) - clock[0] = {"earlier": "2030-01-01T00:00:00Z", "tied": clock[0]}.get( - timing, "2030-01-01T00:00:02Z" - ) + clock[0] = { + "earlier": "2030-01-01T00:00:00Z", + "tied": clock[0], + "legacy-tied": clock[0], + }.get(timing, "2030-01-01T00:00:02Z") second = worker(2, [worker(3)], successor) assert ( workbench_db.execute( @@ -181,10 +184,26 @@ def worker(number, inputs=None, status="succeeded"): ).fetchone()[0] == first ) - if timing == "legacy": + if timing.startswith("legacy"): + # Exercise the legacy no-claim fallback with controlled metadata. with workbench_db: workbench_db.execute("DELETE FROM deep_scan_merge_claims") - expected_worker = second if successor == "succeeded" else first + expected_worker = first + if successor == "succeeded": + second_reference = workbench_db.execute( + "SELECT accepted_result_path, accepted_result_sha256 FROM deep_scan_attempts " + "WHERE worker_id = ?", + (second,), + ).fetchone() + if timing == "later": + clock[0] = "2030-01-01T00:00:03Z" + expected_worker = worker(3, [worker(4)]) + claim = workbench_db.execute( + "SELECT * FROM deep_scan_merge_claims WHERE worker_id = ?", (expected_worker,) + ).fetchone() + assert claim["previous_worker_id"] == second + assert claim["previous_result_path"] == second_reference["accepted_result_path"] + assert claim["previous_result_sha256"] == second_reference["accepted_result_sha256"] accepted = workbench_db.execute( "SELECT accepted_result_path, accepted_result_sha256 FROM deep_scan_attempts WHERE worker_id = ?", (expected_worker,), @@ -222,6 +241,10 @@ def observe_budget_draft(document, unmerged): selection = deep.finish_deep_scan_locked(workbench_db, finish, scan_id)["deepScan"][ "finalizationInput" ] + replay = deep.finish_deep_scan_locked(workbench_db, stale, scan_id)["deepScan"][ + "finalizationInput" + ] + assert replay == selection else: # A pending or failed successor cannot replace the last committed input # selected for budget publication, which does not await model work. From 19f129a518cc73f0a4c172f089986c0f401d0ca4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 12 Oct 2026 09:56:35 +0000 Subject: [PATCH 239/251] fix(deep-scan): carry reducer causality across legacy migration --- plugins/codex-security/scripts/deep_scan_workbench.py | 7 +++++-- .../tests/test_finalization_selection_order.py | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 79dc398552..b1f5806665 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -2399,14 +2399,17 @@ def final_deep_scan_reducer(connection: sqlite3.Connection, scan_id: str) -> sql "SELECT workers.*, attempts.accepted_result_path, attempts.accepted_result_sha256 " "FROM deep_scan_workers AS workers LEFT JOIN deep_scan_attempts AS attempts " "ON attempts.worker_id = workers.id AND attempts.attempt = workers.attempt " + "LEFT JOIN deep_scan_merge_claims AS own_claim " + "ON own_claim.worker_id = workers.id AND own_claim.scan_id = workers.scan_id " "WHERE workers.scan_id = ? AND workers.kind = 'dedup' AND workers.status = 'succeeded' " "AND NOT EXISTS (SELECT 1 FROM deep_scan_merge_claims AS claims " "JOIN deep_scan_workers AS successor ON successor.id = claims.worker_id " "WHERE claims.previous_worker_id = workers.id AND claims.scan_id = workers.scan_id " "AND successor.scan_id = workers.scan_id AND successor.kind = 'dedup' " "AND successor.status = 'succeeded') " - # Legacy reducers retain the producer's completion and insertion order. - "ORDER BY workers.completed_at DESC, workers.rowid DESC LIMIT 1", + # Committed claims supersede legacy aggregates; legacy-only runs retain their order. + "ORDER BY (own_claim.worker_id IS NOT NULL) DESC, " + "workers.completed_at DESC, workers.rowid DESC LIMIT 1", (scan_id,), ).fetchone() diff --git a/plugins/codex-security/tests/test_finalization_selection_order.py b/plugins/codex-security/tests/test_finalization_selection_order.py index a4843eabdd..2c3a8989f9 100644 --- a/plugins/codex-security/tests/test_finalization_selection_order.py +++ b/plugins/codex-security/tests/test_finalization_selection_order.py @@ -18,6 +18,7 @@ ("later", "failed"), ("legacy", "succeeded"), ("legacy-tied", "succeeded"), + ("legacy-earlier", "succeeded"), ], ) def test_final_selection_follows_committed_reducer_chain( @@ -197,6 +198,8 @@ def worker(number, inputs=None, status="succeeded"): ).fetchone() if timing == "later": clock[0] = "2030-01-01T00:00:03Z" + elif timing == "legacy-earlier": + clock[0] = "2030-01-01T00:00:00Z" expected_worker = worker(3, [worker(4)]) claim = workbench_db.execute( "SELECT * FROM deep_scan_merge_claims WHERE worker_id = ?", (expected_worker,) From 812abc28dc6dbbbaff995b3fddb01232367be213 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 12 Oct 2026 11:42:57 -0700 Subject: [PATCH 240/251] fix: preserve incomplete Deep scan drafts before finalization --- .../tests/test_deep_scan_coordinator.ts | 6 +- .../tests/test_deep_scan_finalization.mjs | 5 +- .../scripts/finalize_scan_contract.py | 5 ++ .../codex-security/scripts/workbench_db.py | 1 + .../test_deep_scan_successful_publication.py | 71 +++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts index 3a56ae23cb..34fc063b3b 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts @@ -3779,6 +3779,7 @@ async function testSaturationIgnoresDiscoveryCancellationWriteFailure( stopAfterNoNew: 2, maxDiscoveryRuns: 6, }); + await mkdir(fixture.run.scanDir, { mode: 0o700 }); fixture.run.workflowVersion = "deep-security-scan/v2"; store.run.workflowVersion = "deep-security-scan/v2"; store.selectFinalization = async (input) => { @@ -3791,7 +3792,10 @@ async function testSaturationIgnoresDiscoveryCancellationWriteFailure( const bytes = await readFile(checkpoint); store.run.finalizationInput = { version: 1, - resultPath: path.relative(fixture.run.scanDir, checkpoint), + resultPath: path + .relative(fixture.run.scanDir, checkpoint) + .split(path.sep) + .join("/"), resultSha256: createHash("sha256").update(bytes).digest("hex"), terminalReason: input.reason, omittedWorkerIds: input.omittedWorkerIds ?? [], diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs index aeb19ee8d2..67a2149189 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs @@ -153,7 +153,10 @@ for (const terminalReason of ["capped", "saturated"]) { const contents = await readFile(checkpoint); const selection = { version: 1, - resultPath: path.relative(scanDir, checkpoint), + resultPath: path + .relative(scanDir, checkpoint) + .split(path.sep) + .join("/"), resultSha256: createHash("sha256").update(contents).digest("hex"), terminalReason, omittedWorkerIds: [], diff --git a/plugins/codex-security/scripts/finalize_scan_contract.py b/plugins/codex-security/scripts/finalize_scan_contract.py index a1b7f143d4..79e9d55443 100644 --- a/plugins/codex-security/scripts/finalize_scan_contract.py +++ b/plugins/codex-security/scripts/finalize_scan_contract.py @@ -2931,6 +2931,7 @@ def _prepare_scan_finalization( completion_warnings: list[str] | None = None, draft_documents: tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None = None, finding_scope: list[str] | None = None, + require_complete: bool = False, ) -> PreparedScanFinalization: """Read, populate, and validate a scan without writing any output files.""" @@ -2942,6 +2943,10 @@ def _prepare_scan_finalization( else _read_scan_local_json(scan_dir, "scan-manifest.json", "scan-manifest.json") ) scan = _require_dict(manifest, "scan", "manifest") + if require_complete and scan.get("complete") is False: + raise RecoverableContractError( + "The latest saved scan draft is incomplete; continue the scan before completing it." + ) if scan.get("sealedAt") is None and scan.get("artifacts") == []: del scan["artifacts"] was_sealed = scan.get("sealedAt") is not None or scan.get("artifacts") is not None diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 61cc4bc6b4..2037bd4400 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1526,6 +1526,7 @@ def add_warning() -> None: completion_warnings=warnings if scan["mode"] != "deep" else None, draft_documents=draft_documents, finding_scope=requested_scan_paths(scan) if scan["mode"] == "deep" else None, + require_complete=True, ) if prepared[2]["scan"].get("complete") is False: raise RecoverableContractError( diff --git a/plugins/codex-security/tests/test_deep_scan_successful_publication.py b/plugins/codex-security/tests/test_deep_scan_successful_publication.py index 46671eecb1..c819917ea6 100644 --- a/plugins/codex-security/tests/test_deep_scan_successful_publication.py +++ b/plugins/codex-security/tests/test_deep_scan_successful_publication.py @@ -369,6 +369,77 @@ def test_deep_prepare_and_complete_preserve_the_same_aggregate( assert_published_aggregate(scan) +@pytest.mark.parametrize("prepare_only", [False, True], ids=["complete", "prepare"]) +@pytest.mark.parametrize("pending_artifact", ["writeup", "receipt"]) +def test_incomplete_deep_draft_remains_publishable_with_pending_artifacts( + workbench_api, workbench_db, publication_scan, prepare_only, pending_artifact +): + scan = publication_scan() + manifest_path = scan.scan_dir / "scan-manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["scan"]["complete"] = False + if pending_artifact == "writeup": + relative_path = "findings/pending/pending.md" + scan.findings[0]["writeup"] = {"reportPath": relative_path} + else: + relative_path = "artifacts/receipts/pending.md" + scan.coverage["surfaces"] = [ + { + "id": "source-review", + "label": "Source review", + "disposition": "no_issue_found", + "receiptRefs": [relative_path], + } + ] + draft_dir = scan.scan_dir / "drafts" + draft_dir.mkdir() + draft_path = draft_dir / f"{uuid.uuid4()}.json" + documents = { + "manifest": manifest, + "findings": {"findings": scan.findings}, + "coverage": scan.coverage, + } + args = Namespace( + scan_id=scan.scan_id, + claim_token=None, + draft_path=str(draft_path), + checkpoint_path=None, + expected_draft_digest=None, + ) + saved_results = workbench_api["saved_results"] + context = workbench_api["_WORKBENCH_DB_CONTEXT"] + draft_path.write_text(json.dumps(documents)) + assert saved_results.write_scan_draft(context, workbench_db, args)["status"] == "draft_written" + names = ( + "scan-manifest.json", + "findings.json", + "coverage.json", + "checkpoint-head.json", + "report.md", + ) + before = {name: (scan.scan_dir / name).read_bytes() for name in names} + + with pytest.raises(SystemExit, match="The latest saved scan draft is incomplete"): + complete(workbench_api, workbench_db, scan, prepare_only=prepare_only) + + assert ( + workbench_db.execute("SELECT status FROM scans WHERE id = ?", (scan.scan_id,)).fetchone()[0] + == "running" + ) + assert {name: (scan.scan_dir / name).read_bytes() for name in names} == before + assert not (scan.scan_dir / relative_path).exists() + + artifact = scan.scan_dir / relative_path + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_text("# Completed source review\n\nSynthetic review evidence.\n") + manifest["scan"]["complete"] = True + draft_path.write_text(json.dumps(documents)) + assert saved_results.write_scan_draft(context, workbench_db, args)["status"] == "draft_written" + completed = complete(workbench_api, workbench_db, scan) + assert completed["progress"]["status"] == "complete" + assert_published_aggregate(scan) + + @pytest.mark.parametrize( ("source", "scope", "has_parent"), [ From 6fcc312405056c6fd5793f66511a63192e40b701 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 12 Oct 2026 12:02:58 -0700 Subject: [PATCH 241/251] fix: include scan file helpers in package contract --- sdk/typescript/scripts/package-dist-files.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index 699bc73e44..533d4b7b92 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -63,6 +63,7 @@ const distModules = [ "scan-activity", "scan-comparison", "scan-dashboard", + "scan-files", "scan-history-renderer", "scan-inputs", "scan-logs", From 9522bea56333af3421df4ae4cfa7ee8cb4e56e36 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Mon, 12 Oct 2026 12:51:21 -0700 Subject: [PATCH 242/251] test: include SDK source in native policy build fixture --- sdk/typescript/tests-ts/build-plugin.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 5ad4fbb404..9045b3e91f 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -73,6 +73,9 @@ describe("bundled plugin build", () => { const source = new URL("../../../plugins/codex-security/", import.meta.url); await mkdir(native, { recursive: true }); await mkdir(sdk, { recursive: true }); + await cp(new URL("../src", import.meta.url), join(sdk, "src"), { + recursive: true, + }); await symlink( fileURLToPath(new URL("../node_modules", import.meta.url)), join(sdk, "node_modules"), From 5cc4842ad0c3b79f4336862304fdd86485b5b5dc Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 17:11:21 +0000 Subject: [PATCH 243/251] test: distinguish skill profile inventory cases --- .../tests-ts/scan-skill-paths.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/sdk/typescript/tests-ts/scan-skill-paths.test.ts b/sdk/typescript/tests-ts/scan-skill-paths.test.ts index 7893d803c3..048465c50d 100644 --- a/sdk/typescript/tests-ts/scan-skill-paths.test.ts +++ b/sdk/typescript/tests-ts/scan-skill-paths.test.ts @@ -39,9 +39,8 @@ async function fixture() { return { root, home, plugin: { pluginRoot, installedRoot }, blocked }; } -test.each([false, true])( - "disables cached skill files while preserving effective selection (profile=%s)", - async (profile) => { +for (const profile of [false, true]) { + test(`disables cached skill files while preserving effective selection (profile=${profile})`, async () => { const { root, home, plugin, blocked } = await fixture(); const unrelated = { path: join(root, "other-plugin", "SKILL.md"), @@ -92,8 +91,8 @@ test.each([false, true])( (before["profiles"] as JsonObject)["unused"], ); } - }, -); + }); +} test("leaves plugins outside the credential home and absent skills unchanged", async () => { const { root, home, plugin } = await fixture(); @@ -116,9 +115,8 @@ test("leaves plugins outside the credential home and absent skills unchanged", a ).toBe(config); }); -test.each([false, true])( - "preserves malformed native skill settings (profile=%s)", - async (profile) => { +for (const profile of [false, true]) { + test(`preserves malformed native skill settings (profile=${profile})`, async () => { const { home, plugin } = await fixture(); for (const skills of [ false, @@ -135,8 +133,8 @@ test.each([false, true])( expect(await scanSkillConfig(config, plugin, home)).toBe(config); expect(config).toEqual(before); } - }, -); + }); +} test("isolates concurrent scan selections without changing their shared configuration", async () => { const [first, second] = await Promise.all([fixture(), fixture()]); From 7380d6cfa9c9c6775de8f11ce8c3ded22d5e755b Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 19:12:51 +0000 Subject: [PATCH 244/251] test: allow resumed attribution reads before finalization --- sdk/typescript/tests-ts/api-usage-finalization.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk/typescript/tests-ts/api-usage-finalization.test.ts b/sdk/typescript/tests-ts/api-usage-finalization.test.ts index 685cbfa811..8cfc0d122d 100644 --- a/sdk/typescript/tests-ts/api-usage-finalization.test.ts +++ b/sdk/typescript/tests-ts/api-usage-finalization.test.ts @@ -216,6 +216,9 @@ test.each([ } if (args[0] === "prepare-scan-completion") throw originalFailure; if (args[0] === "get-scan") { + if (scenario === "resumed" && !turnExited) { + return { scan: { progress: { status: "running" } } }; + } expect(turnExited).toBe(true); if (scenario !== "post-turn-error") expect(options.signal).toBeUndefined(); From d0a40a8b64b29b3b6b7809b723c019468031cfcb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 19:55:48 +0000 Subject: [PATCH 245/251] fix(deep-scan): preserve publication recovery across main integration --- .../src/deep-scan/artifact-validation.ts | 4 +- .../test_deep_scan_artifact_validation.ts | 88 +++++- .../scripts/deep_scan_workbench.py | 4 +- .../scripts/workbench_saved_results.py | 33 +- ...test_selected_publication_compatibility.py | 288 ++++++++++++++++++ 5 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 plugins/codex-security/tests/test_selected_publication_compatibility.py diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index 0524e15e1d..fb58bc74ff 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -447,7 +447,9 @@ export function projectDiscoveryCoverage( id: scopedId(typeof item.id === "string" ? item.id : String(index)), ...(item.candidateId === undefined ? {} - : { candidateId: `${worker.id}-candidate-${item.candidateId}` }), + : { + candidateId: `${worker.id}-attempt-${worker.attempt ?? "unknown"}-candidate-${index + 1}`, + }), ...(item.surfaceIds === undefined ? {} : { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts index 9d61683396..f5e23778c1 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts @@ -6,10 +6,14 @@ import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { importSource } from "./import-module.ts"; -const { validateDiscoveryArtifacts, validateReducerArtifacts } = - await importSource( - path.join(import.meta.dirname, "../src/deep-scan/artifact-validation.ts"), - ); +const { + parseDeepReduction, + projectDiscoveryCoverage, + validateDiscoveryArtifacts, + validateReducerArtifacts, +} = await importSource( + path.join(import.meta.dirname, "../src/deep-scan/artifact-validation.ts"), +); const otherScanId = "12c17317-9594-49e0-b06a-d72fd7e14bba"; const root = await temporaryDirectory("deep-scan-artifact-validation-", true); @@ -17,6 +21,7 @@ try { await testDiscoveryValidation(root); await testReducerValidation(root); await testEmptyDiscoveryAndReduction(root); + await testLongDeferredCandidateRecovery(root); } finally { await rm(root, { recursive: true, force: true }); } @@ -609,6 +614,81 @@ async function testEmptyDiscoveryAndReduction(root: string) { ); } +async function testLongDeferredCandidateRecovery(root: string) { + const candidateIds = ["a".repeat(512), "b".repeat(512)]; + const { artifacts, ...worker } = await createWorker( + path.join(root, "long-deferred-candidates"), + "903ef9e0-b34e-4b59-99ac-31d047f82742", + draft([], { + coverage: { + surfaces: [], + explicitExclusions: [], + deferred: candidateIds.map((candidateId) => ({ + candidateId, + reason: "Follow up on this accepted candidate.", + })), + }, + }), + ); + const discovery = await validateDiscoveryArtifacts( + artifacts, + worker.resultPath, + scanId, + ); + const prefix = "artifacts/deep_discovery/workers/discovery-0001/output"; + const coverage = projectDiscoveryCoverage( + discovery.coverage, + { id: worker.id, attempt: 2 }, + prefix, + ); + const artifactDir = path.join(artifacts.dedupRoot, "dedup-long", "output"); + const resultPath = path.join(artifactDir, "result.json"); + await mkdir(artifactDir, { recursive: true }); + await writeResult(resultPath, reduction([])); + const accepted = await validateReducerArtifacts( + { + artifacts, + artifactDir, + resultPath, + reducerId: "dedup-long", + persistSourceCoverage: true, + sources: { + discoveries: [ + { workerId: worker.id, attempt: 2, coverage, result: discovery }, + ], + previous: null, + }, + }, + scanId, + ); + const recovered = parseDeepReduction(await readJson(resultPath), true); + assert.deepEqual(recovered.sourceCoverage, accepted.result.sourceCoverage); + assert.deepEqual( + recovered.sourceCoverage.deferred.map( + (item: { provenance: { candidateId: string } }) => + item.provenance.candidateId, + ), + candidateIds, + ); + assert.equal( + new Set( + recovered.sourceCoverage.deferred.map( + (item: { candidateId: string }) => item.candidateId, + ), + ).size, + candidateIds.length, + ); + const nextAttempt = projectDiscoveryCoverage( + discovery.coverage, + { id: worker.id, attempt: 3 }, + prefix, + ); + assert.notEqual( + nextAttempt.deferred[0].candidateId, + recovered.sourceCoverage.deferred[0].candidateId, + ); +} + async function createWorker(scanDir: string, id: string, result: unknown) { const artifacts = { scanDir, diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 1c8d7c902e..1462cb028c 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -2401,7 +2401,9 @@ def finish_deep_scan_locked( promotion = promote_staged_file(staged_manifest_path, manifest_path) with ( saved_results.preserve_deep_deadline_draft(dependencies(), connection, scan) - if zero_discovery_deadline and standard_scan_manifest + if zero_discovery_deadline + and standard_scan_manifest + and (finalization is None or "publicationSha256" not in finalization) else nullcontext() ): timestamp = dependencies().now() diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index acbf7933bb..6c0ff781fe 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -22,6 +22,7 @@ ContractError, PreparedScanFinalization, RecoverableContractError, + _contract_json_bytes, _finding_strength, _populate_unsealed_artifact_envelope, _populate_unsealed_manifest_envelope, @@ -2903,13 +2904,23 @@ def _read_staged_scan_draft(scan_dir: Path, draft_path: str) -> dict[str, Any]: return _read_scan_local_json(scan_dir, relative, "Staged scan draft") -def _selected_publication_digest(prepared: Any) -> str: +def _selected_publication_digest(prepared: Any, legacy_completeness: str | None = None) -> str: # Completion time is chosen at sealing, after publication. Everything else # must remain the host projection of the same accepted aggregate. manifest = copy.deepcopy(prepared[2]) for field in ("completedAt", "sealedAt"): manifest["scan"].pop(field, None) - return _digest([manifest, prepared[3], prepared[4]]) + coverage = prepared.coverage + if legacy_completeness is not None: + # Reconstruct the historical projection and its coverage artifact binding. + coverage = {**coverage, "completeness": legacy_completeness} + coverage_digest = hashlib.sha256( + _contract_json_bytes("coverage.json", coverage) + ).hexdigest() + for artifact in manifest["scan"]["artifacts"]: + if artifact["path"] == manifest["scan"]["coverageRef"]: + artifact["sha256"] = coverage_digest + return _digest([manifest, prepared.findings, coverage]) def _require_selected_result(scan: Any, selection: dict[str, Any]) -> None: @@ -2953,11 +2964,19 @@ def require_selected_publication(db: Any, connection: Any, scan: Any, prepared: _require_selected_result(scan, selection) except ContractError as exc: raise RecoverableContractError(str(exc)) from exc - if selection.get("publicationSha256") != _selected_publication_digest(prepared): - raise RecoverableContractError( - "The selected Deep Scan publication changed or is missing; " - "republish its accepted result before completing the scan." - ) + digest = selection.get("publicationSha256") + if digest == _selected_publication_digest(prepared): + return + # Old receipts still bind every field, including on normalized seal replay. + if "completeness" not in prepared.coverage and any( + digest == _selected_publication_digest(prepared, completeness) + for completeness in ("complete", "partial", "unknown") + ): + return + raise RecoverableContractError( + "The selected Deep Scan publication changed or is missing; " + "republish its accepted result before completing the scan." + ) def _require_current_deep_publication( diff --git a/plugins/codex-security/tests/test_selected_publication_compatibility.py b/plugins/codex-security/tests/test_selected_publication_compatibility.py new file mode 100644 index 0000000000..99f2b3c402 --- /dev/null +++ b/plugins/codex-security/tests/test_selected_publication_compatibility.py @@ -0,0 +1,288 @@ +"""Preserve selected Deep Scan publications across main contract normalization.""" + +from __future__ import annotations + +import copy +import hashlib +import json +from argparse import Namespace + +import pytest +from test_deep_scan_publication_authority import stage_publication +from test_deep_scan_successful_publication import publication_scan as publication_scan + + +def selected_publication(api, connection, scan, *, legacy_completeness=None): + if legacy_completeness is not None: + scan.coverage["completeness"] = legacy_completeness + coverage_path = scan.scan_dir / "coverage.json" + coverage = json.loads(coverage_path.read_bytes()) + coverage["completeness"] = legacy_completeness + coverage_path.write_text(json.dumps(coverage)) + result = scan.scan_dir / "accepted.json" + result.write_text( + json.dumps( + { + "scanId": scan.scan_id, + "complete": True, + "findings": scan.findings, + "sourceCoverage": scan.coverage, + } + ) + ) + row = api["require_scan"](connection, scan.scan_id) + prepared = api["_prepare_scan_finalization"]( + scan.scan_dir, + expected_coverage_mode=api["expected_coverage_mode"](row), + completion_binding=api["workbench_completion_binding"](row, api["now"]()), + ) + manifest = copy.deepcopy(prepared[2]) + coverage = copy.deepcopy(prepared[4]) + if legacy_completeness is not None: + coverage["completeness"] = legacy_completeness + coverage_bytes = (json.dumps(coverage, indent=2, sort_keys=True) + "\n").encode() + for artifact in manifest["scan"]["artifacts"]: + if artifact["path"] == "coverage.json": + artifact["sha256"] = hashlib.sha256(coverage_bytes).hexdigest() + for key in ("completedAt", "sealedAt"): + manifest["scan"].pop(key, None) + encoded = json.dumps( + [manifest, prepared[3], coverage], sort_keys=True, separators=(",", ":") + ).encode() + selection = { + "version": 1, + "resultPath": result.name, + "resultSha256": hashlib.sha256(result.read_bytes()).hexdigest(), + "publicationSha256": hashlib.sha256(encoded).hexdigest(), + "terminalReason": "saturated", + "omittedWorkerIds": [], + "selectedAt": scan.timestamp, + } + with connection: + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "coordinator_generation = 2, finalization_input_json = ? WHERE scan_id = ?", + (json.dumps(selection), scan.scan_id), + ) + return result + + +@pytest.mark.parametrize("legacy_completeness", ["complete", "partial", "unknown"]) +@pytest.mark.parametrize("cut", ["before", "after"]) +def test_selected_replays_historical_publication_across_normalization( + workbench_api, workbench_db, publication_scan, monkeypatch, legacy_completeness, cut +): + scan = publication_scan() + selected_publication(workbench_api, workbench_db, scan, legacy_completeness=legacy_completeness) + selection = workbench_db.execute( + "SELECT finalization_input_json FROM deep_scan_runs" + ).fetchone()[0] + namespace = workbench_api["complete_scan_locked"].__globals__ + write = namespace["_write_prepared_scan_finalization"] + + def interrupted_write(*args, **kwargs): + if cut == "after": + write(*args, **kwargs) + raise RuntimeError("Interrupted parent completion") + + args = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + with monkeypatch.context() as patch: + patch.setitem(namespace, "_write_prepared_scan_finalization", interrupted_write) + with pytest.raises(RuntimeError, match="Interrupted parent completion"): + workbench_api["complete_scan"](workbench_db, args) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + if cut == "after": + assert "completeness" not in json.loads((scan.scan_dir / "coverage.json").read_bytes()) + workbench_api["complete_scan"](workbench_db, args) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + assert ( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + == selection + ) + sealed = {p: p.read_bytes() for p in scan.scan_dir.rglob("*.json")} + workbench_api["complete_scan"](workbench_db, args) + assert {p: p.read_bytes() for p in sealed} == sealed + + +@pytest.mark.parametrize("damage", [None, "findings", "coverage", "selected"]) +@pytest.mark.parametrize("legacy_completeness", [None, "complete", "partial", "unknown"]) +def test_selected_completion_validates_saved_publication( + workbench_api, workbench_db, publication_scan, damage, legacy_completeness +): + scan = publication_scan() + selected = selected_publication( + workbench_api, workbench_db, scan, legacy_completeness=legacy_completeness + ) + if damage == "selected": + selected.write_bytes(selected.read_bytes() + b"\n") + elif damage is not None: + path = scan.scan_dir / f"{damage}.json" + document = json.loads(path.read_bytes()) + if damage == "findings": + document["findings"][0]["remediation"] = "Substituted repair." + else: + document["completeness"] = "partial" + document["deferred"] = [ + {"id": "changed-coverage", "reason": "Substituted unresolved review."} + ] + path.write_text(json.dumps(document)) + before = {p: p.read_bytes() for p in scan.scan_dir.rglob("*.json")} + state = "\n".join(workbench_db.iterdump()) + args = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + if damage is None: + workbench_api["complete_scan"](workbench_db, args) + sealed = {p: p.read_bytes() for p in scan.scan_dir.rglob("*.json")} + workbench_api["complete_scan"](workbench_db, args) + assert {p: p.read_bytes() for p in sealed} == sealed + else: + with pytest.raises(SystemExit, match="publication|changed after acceptance"): + workbench_api["complete_scan"](workbench_db, args) + assert "\n".join(workbench_db.iterdump()) == state + assert {p: p.read_bytes() for p in before} == before + + +def test_selected_historical_publication_still_binds_receipt_bytes( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + receipt = scan.scan_dir / "artifacts" / "review.json" + receipt.parent.mkdir() + receipt.write_text('{"review": "original"}\n') + scan.coverage["surfaces"] = [ + { + "id": "saved-review", + "label": "Synthetic saved review", + "disposition": "no_issue_found", + "receiptRefs": ["artifacts/review.json"], + } + ] + (scan.scan_dir / "coverage.json").write_text(json.dumps(scan.coverage)) + selected_publication(workbench_api, workbench_db, scan, legacy_completeness="complete") + receipt.write_text('{"review": "substituted"}\n') + state = "\n".join(workbench_db.iterdump()) + files = {p: p.read_bytes() for p in scan.scan_dir.rglob("*.json")} + with pytest.raises(SystemExit, match="publication"): + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert "\n".join(workbench_db.iterdump()) == state + assert {p: p.read_bytes() for p in files} == files + + +@pytest.mark.parametrize("damage", [None, "draft", "selected"]) +@pytest.mark.parametrize("legacy_completeness", [None, "partial"]) +def test_selected_republication_checks_identity_before_writing_checkpoints( + workbench_api, workbench_db, publication_scan, damage, legacy_completeness +): + scan = publication_scan() + result = selected_publication( + workbench_api, workbench_db, scan, legacy_completeness=legacy_completeness + ) + staged = stage_publication( + scan, + generation=2, + result_path=result, + title="Substituted aggregate" if damage == "draft" else scan.findings[0]["title"], + ) + if damage == "selected": + result.write_bytes(result.read_bytes() + b"\n") + before = {p: p.read_bytes() for p in scan.scan_dir.rglob("*.json")} + state = "\n".join(workbench_db.iterdump()) + if damage is None: + result = workbench_api["saved_results"].write_scan_draft( + workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged + ) + assert result["status"] == "draft_written" + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + return + with pytest.raises( + workbench_api["ContractError"], match="publication|changed after acceptance" + ): + workbench_api["saved_results"].write_scan_draft( + workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged + ) + assert {p: p.read_bytes() for p in scan.scan_dir.rglob("*.json")} == before + assert "\n".join(workbench_db.iterdump()) == state + + +def test_selected_zero_discovery_deadline_preserves_the_published_draft( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + scan.findings = [] + scan.coverage["deferred"] = [ + { + "id": "deadline", + "reason": "The configured discovery time limit elapsed before source review completed.", + } + ] + selection = { + "version": 1, + "resultPath": None, + "terminalReason": "capped", + "omittedWorkerIds": [], + "selectedAt": scan.timestamp, + } + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "coordinator_generation = 2, status = 'running', phase = 'terminal', " + "terminal_reason = 'capped', completed_at = NULL, " + "created_at = '2000-01-01T00:00:00Z', max_time_hours = 1, " + "finalization_input_json = ? WHERE scan_id = ?", + (json.dumps(selection), scan.scan_id), + ) + draft_dir = scan.scan_dir / "drafts" + draft_dir.mkdir() + draft_path = draft_dir / "00000000-0000-4000-8000-000000000001.json" + draft_path.write_text( + json.dumps( + { + "manifest": json.loads((scan.scan_dir / "scan-manifest.json").read_text()), + "findings": {"findings": []}, + "coverage": scan.coverage, + "deepScanPublication": {"coordinatorGeneration": 2, "resultPath": None}, + } + ) + ) + workbench_api["saved_results"].write_scan_draft( + workbench_api["_WORKBENCH_DB_CONTEXT"], + workbench_db, + Namespace( + scan_id=scan.scan_id, + claim_token=None, + draft_path=str(draft_path), + checkpoint_path=None, + expected_draft_digest=None, + ), + ) + selection_json = workbench_db.execute( + "SELECT finalization_input_json FROM deep_scan_runs" + ).fetchone()[0] + assert "publicationSha256" in json.loads(selection_json) + before = {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} + finish = Namespace( + scan_id=scan.scan_id, + terminal_reason="capped", + omitted_worker_id=[], + manifest_path=str(scan.scan_dir / "scan-manifest.json"), + staged_manifest_path=None, + coordinator_generation=2, + ) + deep = workbench_api["deep_scan"] + deep.finish_deep_scan(workbench_db, finish) + assert workbench_db.execute("SELECT status FROM deep_scan_runs").fetchone()[0] == "succeeded" + assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} == before + completion = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + workbench_api["complete_scan"](workbench_db, completion) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + sealed = {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} + deep.finish_deep_scan(workbench_db, finish) + workbench_api["complete_scan"](workbench_db, completion) + assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} == sealed + assert ( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + == selection_json + ) From c3378cb03bed1ae1da5a23a5a439a0404f8d5eef Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 21:28:05 +0000 Subject: [PATCH 246/251] fix(deep-scan): replay accepted historical finding details --- .../src/accepted-discovery-normalization.ts | 153 +++++++ .../mcp-app/src/artifact-contracts.ts | 68 +++ .../mcp-app/src/artifact-deep-reducer.ts | 4 +- .../mcp-app/src/artifact-scan-draft.ts | 124 ++++-- .../src/deep-scan/artifact-validation.ts | 18 +- .../mcp-app/src/deep-scan/coordinator.ts | 2 + .../mcp-app/tests/legacy-accepted-finding.ts | 70 +++ .../tests/test_artifact_deep_reducer.ts | 66 ++- .../tests/test_compact_artifact_server.ts | 20 +- .../test_deep_scan_artifact_validation.ts | 61 +++ .../tests/test_deep_scan_coordinator.ts | 30 +- .../tests/test_deep_scan_finalization.mjs | 52 ++- .../test_deep_scan_publication_integration.ts | 158 ++++++- .../tests/test_deep_scan_stdio_lifecycle.ts | 4 +- .../scripts/finalize_scan_contract.py | 9 +- .../codex-security/scripts/workbench_db.py | 30 +- .../scripts/workbench_saved_results.py | 45 +- .../scripts/workbench_validation.py | 70 +++ ...test_selected_publication_compatibility.py | 405 +++++++++++++++++- 19 files changed, 1252 insertions(+), 137 deletions(-) create mode 100644 plugins/codex-security/mcp-app/src/accepted-discovery-normalization.ts create mode 100644 plugins/codex-security/mcp-app/tests/legacy-accepted-finding.ts diff --git a/plugins/codex-security/mcp-app/src/accepted-discovery-normalization.ts b/plugins/codex-security/mcp-app/src/accepted-discovery-normalization.ts new file mode 100644 index 0000000000..2d736694de --- /dev/null +++ b/plugins/codex-security/mcp-app/src/accepted-discovery-normalization.ts @@ -0,0 +1,153 @@ +import { isRecord as isObject } from "./record.js"; + +type JsonObject = Record; + +/** Earlier discovery admission normalized a clone while retaining the raw file. */ +export function normalizeAcceptedDiscoveryFinding(finding: JsonObject): void { + const canonicalEvidence = Array.isArray(finding.codeEvidence) + ? finding.codeEvidence + : []; + const evidenceIds = new Set( + canonicalEvidence.flatMap((evidence) => { + if (!isObject(evidence)) return []; + return isNonEmptyString(evidence.id) ? [evidence.id] : []; + }), + ); + if (Array.isArray(finding.code_evidence)) { + const compatibleEvidence: JsonObject[] = []; + for (const evidence of finding.code_evidence) { + if (!isObject(evidence)) continue; + const { id, code } = evidence; + if ( + !isNonEmptyString(id) || + !isNonEmptyString(code) || + evidenceIds.has(id) + ) + continue; + evidenceIds.add(id); + compatibleEvidence.push(evidence); + } + finding.code_evidence = compatibleEvidence; + } else { + delete finding.code_evidence; + } + + for (const [sectionName, listFields] of [ + ["rootCause", ["evidenceRefs", "evidence_refs"]], + ["root_cause", ["evidenceRefs", "evidence_refs"]], + [ + "validation", + [ + "assertions", + "counterEvidence", + "evidence", + "evidenceRefs", + "evidence_refs", + "limitations", + ], + ], + [ + "attackPath", + [ + "assumptions", + "blindspots", + "controls", + "evidenceRefs", + "evidence_refs", + "limitations", + "preconditions", + "steps", + ], + ], + ] satisfies Array<[string, string[]]>) { + const section = finding[sectionName]; + if (isObject(section)) normalizeStringLists(section, listFields); + } + + if (isObject(finding.rootCause)) { + if (!isNonEmptyString(finding.rootCause.summary)) delete finding.rootCause; + else removeUnsupportedStrings(finding.rootCause, ["code", "language"]); + } + if (isObject(finding.root_cause)) { + removeUnsupportedStrings(finding.root_cause, [ + "summary", + "code", + "language", + ]); + } else if (!isNonEmptyString(finding.root_cause)) { + delete finding.root_cause; + } + if (isObject(finding.validation)) { + removeUnsupportedStrings(finding.validation, [ + "method", + "status", + "summary", + "disposition", + "result", + ]); + } + if (!isObject(finding.attackPath)) return; + const attackPath = finding.attackPath; + removeUnsupportedStrings(attackPath, ["summary"]); + for (const field of ["dataFlow", "data_flow", "dataflow", "reachability"]) { + const detail = attackPath[field]; + if (!isObject(detail)) { + if (!isNonEmptyString(detail)) delete attackPath[field]; + continue; + } + removeUnsupportedStrings(detail, [ + "summary", + "source", + "sink", + "outcome", + ...(field === "reachability" ? ["attacker", "entrypoint"] : []), + ]); + normalizeStringLists(detail, [ + "evidenceRefs", + "evidence_refs", + "transformations", + ...(field === "reachability" ? ["preconditions"] : []), + ]); + } + for (const field of ["impact", "likelihood"]) { + const detail = attackPath[field]; + if (isObject(detail)) { + removeUnsupportedStrings(detail, ["level", "rationale", "why"]); + } else if ( + detail !== undefined && + detail !== null && + !isNonEmptyString(detail) + ) { + delete attackPath[field]; + } + } + + function normalizeStringLists(section: JsonObject, fields: string[]): void { + for (const field of fields) { + const value = section[field]; + const normalized = isNonEmptyString(value) + ? [value] + : Array.isArray(value) + ? value.filter(isNonEmptyString) + : undefined; + if (normalized !== undefined && normalized.length > 0) + section[field] = normalized; + else delete section[field]; + } + for (const field of ["evidenceRefs", "evidence_refs"]) { + const refs = section[field]; + if (Array.isArray(refs)) + section[field] = refs.filter((ref) => evidenceIds.has(ref)); + } + } +} + +function removeUnsupportedStrings(section: JsonObject, fields: string[]): void { + for (const field of fields) { + if (!isNonEmptyString(section[field])) delete section[field]; + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/plugins/codex-security/mcp-app/src/artifact-contracts.ts b/plugins/codex-security/mcp-app/src/artifact-contracts.ts index c31269773e..6e59b479e2 100644 --- a/plugins/codex-security/mcp-app/src/artifact-contracts.ts +++ b/plugins/codex-security/mcp-app/src/artifact-contracts.ts @@ -381,6 +381,74 @@ const draft_scanDraftInput = z.strictObject({ coverage: draft_coverage, }); +// Immutable Deep results can predate the current producer contract. Preserve +// their finding fields when reading already accepted worker history. +const acceptedTextOrSummary = z.union([ + nonEmptyText, + draft_textOrSummary.options[1].extend({ + evidence_refs: draft_textList.optional(), + }), +]); +const acceptedReachability = z.union([ + nonEmptyText, + draft_findingReachability.options[1].extend({ + evidence_refs: draft_textList.optional(), + }), +]); +export const acceptedDeepScanDraftInputSchema = draft_scanDraftInput.extend({ + findings: z.array( + draft_finding.extend({ + code_evidence: z + .array(z.looseObject({ id: nonEmptyText, code: nonEmptyText })) + .optional(), + rootCause: z + .union([ + nonEmptyText, + z.looseObject({ + summary: nonEmptyText, + code: nonEmptyText.optional(), + language: nonEmptyText.optional(), + evidenceRefs: draft_textList.optional(), + evidence_refs: draft_textList.optional(), + }), + ]) + .optional(), + root_cause: z + .union([ + nonEmptyText, + z.looseObject({ + summary: nonEmptyText.optional(), + code: nonEmptyText.optional(), + language: nonEmptyText.optional(), + evidenceRefs: draft_textList.optional(), + evidence_refs: draft_textList.optional(), + }), + ]) + .optional(), + validation: z + .union([ + draft_findingValidation.extend({ + evidence_refs: draft_textList.optional(), + }), + z.null(), + ]) + .optional(), + attackPath: z + .union([ + draft_findingAttackPath.extend({ + evidence_refs: draft_textList.optional(), + dataflow: acceptedTextOrSummary.optional(), + dataFlow: acceptedTextOrSummary.optional(), + data_flow: acceptedTextOrSummary.optional(), + reachability: acceptedReachability.optional(), + }), + z.null(), + ]) + .optional(), + }), + ), +}); + /** Native tool contracts; JSON resources are generated compatibility exports. */ export const artifactSchemaDocuments = { "definitions/artifact-common": { diff --git a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts index 7ab8c598fe..92ff0800ed 100644 --- a/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts +++ b/plugins/codex-security/mcp-app/src/artifact-deep-reducer.ts @@ -1,7 +1,7 @@ import { dirname, join, relative, sep } from "node:path"; import type { ArtifactContext } from "./artifact-context.js"; import { - parsePersistedScanDraft, + parseAcceptedDiscoveryDraft, saveScanDraftCheckpoint, } from "./artifact-scan-draft.js"; import { artifactSchemaDocuments } from "./artifact-contracts.js"; @@ -58,7 +58,7 @@ export async function readDeepReductionSources( await readJsonObject(worker.resultPath), "Accepted Standard worker " + worker.id, bound.scanId, - parsePersistedScanDraft, + parseAcceptedDiscoveryDraft, " has an invalid Standard scan result: ", " belongs to a different scan.", ); diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index d79d75a4a0..e249e14bfd 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -14,9 +14,13 @@ import { readArtifactTextWithMetadata, replaceArtifactJson, } from "./artifact-io.js"; -import { artifactSchemaDocuments } from "./artifact-contracts.js"; +import { + acceptedDeepScanDraftInputSchema, + artifactSchemaDocuments, +} from "./artifact-contracts.js"; import { projectArtifactToolSchema } from "./artifact-schema-loader.js"; import { saveThreatModelDocument } from "./threat-model-document.js"; +import { normalizeAcceptedDiscoveryFinding } from "./accepted-discovery-normalization.js"; const draftDefinitions = artifactSchemaDocuments["tools/scan-draft"].$defs; export const scanDraftInputSchema = projectArtifactToolSchema( @@ -34,7 +38,8 @@ const canonicalFileScanDraftInputSchema = fileScanDraftInputSchema.extend({ }), }); export const completedScanInputSchema = draftDefinitions.completedScanInput; -export type ScanDraftInput = z.infer; +// Host drafts include accepted historical findings as well as current inputs. +export type ScanDraftInput = z.infer; type DraftCoverage = ScanDraftInput["coverage"]; export type CompletedScanInput = z.infer; @@ -79,7 +84,9 @@ export async function recordCodexSecurityScanDraftViaWorkbench( signal?: AbortSignal, publication?: DeepScanPublication, ): Promise { - const parsed = parseScanDraft(input); + const parsed = publication + ? normalizeParsedScanDraft(parseAcceptedDeepScanDraft(input)) + : parseScanDraft(input); requireBoundScan(context, parsed, true); const finalDeepDraft = context.mode === "deep" && parsed.complete !== false; if (finalDeepDraft && resolvedDeferred(parsed.coverage).length > 0) { @@ -1263,10 +1270,12 @@ async function preserveDeepThreatModel( input: previous?.threatModel === undefined ? input - : scanDraftInputSchema.parse({ + : { ...input, - threatModel: previous.threatModel, - }), + ...scanDraftInputSchema.pick({ threatModel: true }).parse({ + threatModel: previous.threatModel, + }), + }, previousDigest: digest, }; } @@ -1840,7 +1849,10 @@ function parseCanonicalCheckpoint( } export function parseScanDraft(input: unknown): ScanDraftInput { - const parsed = parsePersistedScanDraft(input); + return normalizeParsedScanDraft(parsePersistedScanDraft(input)); +} + +function normalizeParsedScanDraft(parsed: ScanDraftInput): ScanDraftInput { const deferredIds = new Set(); const ambiguousIds = ambiguousGenericDeferredIds([parsed]); for (const row of parsed.coverage.deferred) { @@ -1877,6 +1889,25 @@ export function parsePersistedScanDraft( return parsed; } +/** Replay host-accepted Deep findings while preserving their original details. */ +export function parseAcceptedDeepScanDraft(input: unknown): ScanDraftInput { + const parsed = acceptedDeepScanDraftInputSchema.parse(input); + validateFindingSemantics(parsed.findings, true); + delete parsed.coverage.completeness; + return parsed; +} + +/** Replay the read contract used when the raw discovery bytes were accepted. */ +export function parseAcceptedDiscoveryDraft(input: unknown): ScanDraftInput { + const compatible = structuredClone(input); + if (isObject(compatible) && Array.isArray(compatible.findings)) { + for (const finding of compatible.findings) { + if (isObject(finding)) normalizeAcceptedDiscoveryFinding(finding); + } + } + return parseAcceptedDeepScanDraft(compatible); +} + function parsePersistedCheckpoint( input: Record, schema: z.ZodType = fileScanDraftInputSchema, @@ -2276,7 +2307,10 @@ function inventoryStrategy( return "repository"; } -export function validateFindingSemantics(findings: JsonObject[]): void { +export function validateFindingSemantics( + findings: JsonObject[], + accepted = false, +): void { for (const [findingIndex, finding] of findings.entries()) { const severity = finding.severity as JsonObject; if ( @@ -2302,25 +2336,30 @@ export function validateFindingSemantics(findings: JsonObject[]): void { } const evidenceIds = new Set(); - for (const [evidenceIndex, evidence] of ( - (finding.codeEvidence as JsonObject[] | undefined) ?? [] - ).entries()) { - const id = evidence.id as string; - if (evidenceIds.has(id)) { - throw new Error( - `scan draft: findings[${findingIndex}].codeEvidence[${evidenceIndex}].id ` + - `duplicates ${id}.`, - ); - } - evidenceIds.add(id); - if ( - typeof evidence.endLine === "number" && - evidence.endLine < (evidence.startLine as number) - ) { - throw new Error( - `scan draft: findings[${findingIndex}].codeEvidence[${evidenceIndex}].endLine ` + - "must not precede startLine.", - ); + const catalogs = accepted + ? ["codeEvidence", "code_evidence"] + : ["codeEvidence"]; + for (const catalog of catalogs) { + for (const [evidenceIndex, evidence] of ( + (finding[catalog] as JsonObject[] | undefined) ?? [] + ).entries()) { + const id = evidence.id as string; + if (evidenceIds.has(id)) { + throw new Error( + `scan draft: findings[${findingIndex}].${catalog}[${evidenceIndex}].id ` + + `duplicates ${id}.`, + ); + } + evidenceIds.add(id); + if ( + typeof evidence.endLine === "number" && + evidence.endLine < (evidence.startLine as number) + ) { + throw new Error( + `scan draft: findings[${findingIndex}].${catalog}[${evidenceIndex}].endLine ` + + "must not precede startLine.", + ); + } } } @@ -2329,8 +2368,12 @@ export function validateFindingSemantics(findings: JsonObject[]): void { ["validation", finding.validation], ["attackPath", finding.attackPath], ]; + if (accepted) referencedSections.push(["root_cause", finding.root_cause]); if (isObject(finding.attackPath)) { - for (const sectionName of ["dataflow", "reachability"]) { + const names = accepted + ? ["dataflow", "dataFlow", "data_flow", "reachability"] + : ["dataflow", "reachability"]; + for (const sectionName of names) { referencedSections.push([ `attackPath.${sectionName}`, finding.attackPath[sectionName], @@ -2339,16 +2382,21 @@ export function validateFindingSemantics(findings: JsonObject[]): void { } for (const [sectionName, section] of referencedSections) { if (!isObject(section)) continue; - const references = section.evidenceRefs; - if (references === undefined) continue; - if ( - !Array.isArray(references) || - references.some((reference) => !evidenceIds.has(reference)) - ) { - throw new Error( - `scan draft: findings[${findingIndex}].${sectionName}.evidenceRefs ` + - "must refer to that finding's existing code-evidence IDs.", - ); + const fields = accepted + ? ["evidenceRefs", "evidence_refs"] + : ["evidenceRefs"]; + for (const field of fields) { + const references = section[field]; + if (references === undefined) continue; + if ( + !Array.isArray(references) || + references.some((reference) => !evidenceIds.has(reference)) + ) { + throw new Error( + `scan draft: findings[${findingIndex}].${sectionName}.${field} ` + + "must refer to that finding's existing code-evidence IDs.", + ); + } } } } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index fb58bc74ff..06b4a2a84c 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -6,6 +6,8 @@ import type { JsonObject } from "../types.js"; import { artifactSchemaDocuments } from "../artifact-contracts.js"; import { projectArtifactToolSchema } from "../artifact-schema-loader.js"; import { + parseAcceptedDeepScanDraft, + parseAcceptedDiscoveryDraft, parsePersistedScanDraft, preserveFindingDetails, saveScanDraftCheckpoint, @@ -122,14 +124,14 @@ export function parseDeepReduction( }; const parsed = persisted ? (({ coverage: _coverage, ...draft }) => draft)( - parsePersistedScanDraft(standard), + parseAcceptedDeepScanDraft(standard), ) : deepReductionInputSchema.parse(submitted); - validateFindingSemantics(parsed.findings); + validateFindingSemantics(parsed.findings, persisted); if (persisted && sourceCoverage !== undefined) { return { ...parsed, - sourceCoverage: parsePersistedScanDraft({ + sourceCoverage: parseAcceptedDeepScanDraft({ ...standard, coverage: sourceCoverage, }).coverage, @@ -144,12 +146,14 @@ export async function validateDiscoveryArtifacts( resultPath: string, expectedScanId: string, validateWriteups = true, + accepted = false, ): Promise { const result = await readDiscoveryAuditDraft( artifacts, resultPath, expectedScanId, validateWriteups, + accepted, ); if (result.complete === false) throw new Error( @@ -163,13 +167,14 @@ export async function readDiscoveryAuditDraft( resultPath: string, expectedScanId: string, validateWriteups = true, + accepted = false, ): Promise { await requireRegularFile(resultPath, artifacts.workersRoot); const result = parseStoredScanDraft( await readJsonObject(resultPath), "Standard scan worker", expectedScanId, - parsePersistedScanDraft, + accepted ? parseAcceptedDiscoveryDraft : parsePersistedScanDraft, ); if (result.complete !== false && validateWriteups) await validateWriteupFiles(artifacts, result); @@ -208,6 +213,7 @@ export async function validateReducerArtifacts( previousReducerResultPath?: string; sources?: DeepReductionSources; persistSourceCoverage?: boolean; + accepted?: boolean; }, expectedScanId?: string, ): Promise { @@ -224,7 +230,7 @@ export async function validateReducerArtifacts( await readJsonObject(resultPath), reducerId, expectedScanId, - parseDeepReduction, + (value) => parseDeepReduction(value, input.accepted), ); if (result.complete === false) throw new Error( @@ -238,7 +244,7 @@ export async function validateReducerArtifacts( await readJsonObject(previousReducerResultPath), "Previous successful reducer", result.scanId, - parseDeepReduction, + (value) => parseDeepReduction(value, true), ); } diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index 19a1208866..4b941ed693 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -1263,6 +1263,7 @@ export class DeepScanCoordinator { resultPath, this.state.scanId, this.state.workflowVersion === "deep-security-scan/v2", + true, ); recovered.push({ id: worker.id, @@ -1348,6 +1349,7 @@ export class DeepScanCoordinator { artifactDir: worker.artifactDir, resultPath, reducerId: worker.id, + accepted: true, previousReducerResultPath: claim ? claim.previousResultPath : latestResultPath, diff --git a/plugins/codex-security/mcp-app/tests/legacy-accepted-finding.ts b/plugins/codex-security/mcp-app/tests/legacy-accepted-finding.ts new file mode 100644 index 0000000000..2084e6d8fc --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/legacy-accepted-finding.ts @@ -0,0 +1,70 @@ +import { finding } from "./scan-draft-fixture.ts"; + +/** Finding fields accepted before the current producer contract was narrowed. */ +export function legacyAcceptedFinding( + base = finding("historical-checkpoint", "fixture.py"), +) { + const referenced = { + summary: "Synthetic source reaches the response.", + evidenceRefs: ["legacy-snippet"], + evidence_refs: ["canonical-snippet"], + }; + return { + ...base, + codeEvidence: [ + { + id: "canonical-snippet", + label: "Response", + path: "fixture.py", + startLine: 1, + code: "return value", + explanation: "The synthetic value reaches the response.", + }, + ], + code_evidence: [{ id: "legacy-snippet", code: "return value" }], + rootCause: { + ...referenced, + code: "return value", + language: "python", + }, + root_cause: { ...referenced, code: "return value", language: "python" }, + validation: { ...referenced, status: "confirmed" }, + attackPath: { + ...referenced, + dataflow: { ...referenced }, + dataFlow: { ...referenced }, + data_flow: { ...referenced }, + reachability: { ...referenced }, + }, + }; +} + +/** Older discovery readers admitted these bytes without rewriting the file. */ +export function legacyRawDiscoveryFinding( + base = finding("historical-checkpoint", "fixture.py"), +): ReturnType & Record { + const raw: ReturnType & Record = + legacyAcceptedFinding(base); + const attackPath = raw.attackPath as Record; + for (const detail of [ + raw.rootCause, + raw.root_cause, + raw.validation, + attackPath, + attackPath.dataflow, + attackPath.dataFlow, + attackPath.data_flow, + attackPath.reachability, + ]) { + const section = detail as Record; + for (const field of ["evidenceRefs", "evidence_refs"]) + section[field] = (section[field] as string[])[0]; + } + (raw.code_evidence as unknown[]).push( + { id: "legacy-snippet", code: "duplicate()" }, + { id: "canonical-snippet", code: "duplicate()" }, + { id: "invalid", code: " " }, + null, + ); + return raw; +} diff --git a/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.ts b/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.ts index a40c393a49..b1a2a8d9af 100644 --- a/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.ts +++ b/plugins/codex-security/mcp-app/tests/test_artifact_deep_reducer.ts @@ -2,6 +2,10 @@ import { readJson, writeJson } from "./support/json.ts"; import { sourceReferences } from "./support/source-references.ts"; import { temporaryDirectory } from "./support/temporary-directories.ts"; import { finding, scanId, workerDraft } from "./scan-draft-fixture.ts"; +import { + legacyAcceptedFinding, + legacyRawDiscoveryFinding, +} from "./legacy-accepted-finding.ts"; import assert from "node:assert/strict"; import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -74,6 +78,7 @@ assert.equal( const root = await temporaryDirectory("codex-security-deep-reducer-", true); try { + await testHistoricalAssignedInputs(root); const scanRoot = path.join(root, "scan"); const workersRoot = path.join( scanRoot, @@ -198,8 +203,8 @@ try { previous: null, }, ); - assert.equal(inputs.discoveries[0]!.coverage!.completeness, "partial"); - assert.equal(inputs.discoveries[1]!.coverage!.completeness, "unknown"); + assert.equal(inputs.discoveries[0]!.coverage!.completeness, undefined); + assert.equal(inputs.discoveries[1]!.coverage!.completeness, undefined); assert.deepEqual( await getModelInputs(context), { @@ -531,6 +536,63 @@ try { console.log("artifact deep reducer tests passed"); +async function testHistoricalAssignedInputs(root: string) { + const scanRoot = path.join(root, "historical"); + const workersRoot = path.join( + scanRoot, + "artifacts", + "deep_discovery", + "workers", + ); + const dedupRoot = path.join(scanRoot, "artifacts", "deep_discovery", "dedup"); + const current = finding("historical-checkpoint", "fixture.py"); + const legacy = legacyAcceptedFinding(current); + const worker = await createWorker({ + workersRoot, + label: "discovery-history", + id: "worker-history", + result: workerDraft([legacyRawDiscoveryFinding(current)]), + }); + const output = path.join(dedupRoot, "dedup-history", "output"); + await mkdir(output, { recursive: true }); + const previousReducerResultPath = path.join(output, "previous.json"); + await writeJson(previousReducerResultPath, reduction([legacy])); + const context = { + root: output, + repoRoot: root, + scanId, + layout: "reducer" as const, + deepReducer: { + scanRoot, + claimedWorkers: [worker], + previousReducerResultPath, + }, + }; + const sourceBytes = await readFile(worker.resultPath, "utf8"); + const previousBytes = await readFile(previousReducerResultPath, "utf8"); + const sources = await readDeepReductionSources(context); + assert.deepEqual( + sources.discoveries[0].result.findings[0].root_cause, + legacy.root_cause, + ); + assert.deepEqual(sources.previous!.findings[0], legacy); + await assert.rejects( + recordCodexSecurityDeepReduction(context, reduction([legacy])), + ); + await recordCodexSecurityDeepReduction(context, reduction([current])); + const saved = await readJson(path.join(output, "result.json")); + assert.equal(saved.findings[0].root_cause, undefined); + assert.deepEqual( + saved.findings[0].provenance.sourceFindings[0].finding.root_cause, + legacy.root_cause, + ); + assert.equal(await readFile(worker.resultPath, "utf8"), sourceBytes); + assert.equal( + await readFile(previousReducerResultPath, "utf8"), + previousBytes, + ); +} + async function createWorker({ workersRoot, label, diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts index d137b6f3a3..a2d3ada52e 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts @@ -20,7 +20,6 @@ type CompletedResult = { })[]; }; coverage: Record & { - completeness: string; surfaces: { disposition: string }[]; deferred: unknown[]; explicitExclusions: unknown[]; @@ -1166,7 +1165,6 @@ async function assertCompletionCoverageAndReplay( coverage: CompletedResult["coverage"], ) { const expected = { - completeness: coverage.completeness, surfaceCount: coverage.surfaces.length, deferredCount: coverage.deferred.length, explicitExclusionCount: coverage.explicitExclusions.length, @@ -1186,23 +1184,9 @@ async function assertCompletionCoverageAndReplay( assert.equal(structured.scan.progress.status, "complete"); const content = (result.content as TextContent[])[0]; assert.equal(content.type, "text"); - assert.match( - content.text, - new RegExp(`Canonical coverage: ${expected.completeness}\\b`), - ); - assert.match( - content.text, - new RegExp(`\\b${expected.surfaceCount} surfaces\\b`), - ); - assert.match( - content.text, - new RegExp(`\\b${expected.deferredCount} deferred items\\b`), - ); - assert.match( + assert.equal( content.text, - new RegExp( - `\\b${expected.explicitExclusionCount} explicit exclusions\\b`, - ), + "Validated and indexed the completed Codex Security scan.", ); assert.deepEqual(structured.coverageSummary, expected); assert.deepEqual(structured.scan.usage, scan.usage); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts index f5e23778c1..35b32992ed 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts @@ -1,5 +1,9 @@ import { readJson, writeJsonLine as writeResult } from "./support/json.ts"; import { finding, scanId, workerDraft as draft } from "./scan-draft-fixture.ts"; +import { + legacyAcceptedFinding, + legacyRawDiscoveryFinding, +} from "./legacy-accepted-finding.ts"; import { temporaryDirectory } from "./support/temporary-directories.ts"; import assert from "node:assert/strict"; import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; @@ -22,12 +26,69 @@ try { await testReducerValidation(root); await testEmptyDiscoveryAndReduction(root); await testLongDeferredCandidateRecovery(root); + await testAcceptedHistoricalFindings(root); } finally { await rm(root, { recursive: true, force: true }); } console.log("deep scan artifact validation tests passed"); +async function testAcceptedHistoricalFindings(root: string) { + const current = finding("historical-checkpoint", "fixture.py"); + const legacy = legacyAcceptedFinding(current); + const { artifacts, ...worker } = await createWorker( + path.join(root, "accepted-history"), + "worker-history", + draft([legacyRawDiscoveryFinding(current)]), + ); + const workerBytes = await readFile(worker.resultPath, "utf8"); + await assert.rejects( + validateDiscoveryArtifacts(artifacts, worker.resultPath, scanId), + ); + const recovered = await validateDiscoveryArtifacts( + artifacts, + worker.resultPath, + scanId, + true, + true, + ); + assert.deepEqual(recovered.findings, [legacy]); + assert.equal(await readFile(worker.resultPath, "utf8"), workerBytes); + + const artifactDir = path.join(artifacts.dedupRoot, "dedup-history", "output"); + const resultPath = path.join(artifactDir, "result.json"); + await mkdir(artifactDir, { recursive: true }); + await writeResult(resultPath, reduction([legacy])); + const input = { + artifacts, + artifactDir, + resultPath, + reducerId: "dedup-history", + }; + const previousBytes = await readFile(resultPath, "utf8"); + await assert.rejects(validateReducerArtifacts(input, scanId)); + assert.deepEqual( + (await validateReducerArtifacts({ ...input, accepted: true }, scanId)) + .result.findings, + [legacy], + ); + assert.equal(await readFile(resultPath, "utf8"), previousBytes); + + const currentPath = path.join(artifactDir, "current.json"); + await writeResult(currentPath, reduction([current])); + const next = await validateReducerArtifacts( + { + ...input, + resultPath: currentPath, + previousReducerResultPath: resultPath, + }, + scanId, + ); + assert.equal(next.newFindings, 0); + assert.deepEqual(next.result.findings, [current]); + assert.equal(await readFile(resultPath, "utf8"), previousBytes); +} + async function testDiscoveryValidation(root: string) { const result = draft([finding("shared", "src/a.js")], { threatModel: { summary: "Requests reach shared code." }, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts index ff5400f20e..3775624ae0 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_coordinator.ts @@ -1,4 +1,8 @@ import { testSelectedFinalizationOwner } from "./deep_scan_selected_finalization_cases.ts"; +import { + legacyAcceptedFinding, + legacyRawDiscoveryFinding, +} from "./legacy-accepted-finding.ts"; import { readJson, writeJson } from "./support/json.ts"; import type { CoordinatorOptions } from "../src/deep-scan/coordinator.js"; import type { ScanDraftInput } from "../src/artifact-scan-draft.js"; @@ -3148,11 +3152,12 @@ async function testResumedManifestPreservesCompletedReducer( includeUnstartedReducer = false, removeHistoricalPrompts = true, includePaths?: string[], + historicalFindings = false, ) { const fixture = await fixtureRun({ workers: 2, stopAfterNoNew: 2, - maxDiscoveryRuns: 3, + maxDiscoveryRuns: historicalFindings ? 2 : 3, }); fixture.run.includePaths = includePaths; const store = new FakeStore(fixture.run); @@ -3162,7 +3167,11 @@ async function testResumedManifestPreservesCompletedReducer( store, new FakeExecutor({ blockDiscoveryAfterCalls: 2, - discoveryCandidateId: includePaths ? "scope-candidate" : undefined, + discoveryCandidateId: includePaths + ? "scope-candidate" + : historicalFindings + ? "historical-checkpoint" + : undefined, }), { run: store.run }, ); @@ -3214,6 +3223,17 @@ async function testResumedManifestPreservesCompletedReducer( const acceptedWorkers = store.run.persistedWorkers!.filter( (worker) => worker.status === "succeeded", ); + if (historicalFindings) { + for (const worker of acceptedWorkers) { + const result = await readJson(worker.resultManifestPath!); + result.findings = result.findings.map( + worker.kind === "discovery" + ? legacyRawDiscoveryFinding + : legacyAcceptedFinding, + ); + await writeJson(worker.resultManifestPath!, result); + } + } const acceptedResults = await Promise.all( acceptedWorkers.map((worker) => readFile(worker.resultManifestPath!, "utf8"), @@ -4365,6 +4385,12 @@ try { await testResumedManifestPreservesCompletedReducer(false, false); await testResumedManifestPreservesCompletedReducer(); await testResumedManifestPreservesCompletedReducer(true); + await testResumedManifestPreservesCompletedReducer( + false, + true, + undefined, + true, + ); await testResumedManifestPreservesCompletedReducer(false, true, [ "api", "background jobs", diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs b/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs index c94bdad2c5..bcdbb65dde 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_finalization.mjs @@ -20,7 +20,9 @@ const bundle = await build({ stdin: { contents: `export * from "./src/deep-scan/finalization.ts"; export * from "./src/deep-scan/artifacts.ts"; - export * from "./src/artifact-scan-draft.ts";`, + export * from "./src/artifact-scan-draft.ts"; + export { parseDeepReduction } from "./src/deep-scan/artifact-validation.ts"; + export { legacyAcceptedFinding } from "./tests/legacy-accepted-finding.ts";`, resolveDir: path.resolve(import.meta.dirname, ".."), }, bundle: true, @@ -34,11 +36,59 @@ const { readSelectedDeepScanDraft, createDeepScanArtifacts, saveScanDraftCheckpoint, + parseDeepReduction, + parseScanDraft, + legacyAcceptedFinding, } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); const scanId = "4e7b4acb-ac80-4d68-98cd-3d5ac5581cd1"; +for (const rootCause of ["object", "text"]) { + test(`replays accepted legacy finding details with ${rootCause} root cause`, async () => { + const scanDir = await realpath( + await mkdtemp(path.join(tmpdir(), "selected-legacy-finding-")), + ); + try { + const finding = legacyAcceptedFinding(); + if (rootCause === "text") finding.root_cause = "Synthetic root cause"; + const saved = { + scanId, + findings: [finding], + sourceCoverage: { surfaces: [], explicitExclusions: [], deferred: [] }, + }; + const contents = JSON.stringify(saved); + const selected = path.join(scanDir, "selected.json"); + await writeFile(selected, contents); + const draft = await readSelectedDeepScanDraft( + createDeepScanArtifacts(scanDir), + scanId, + { + version: 1, + resultPath: "selected.json", + resultSha256: createHash("sha256").update(contents).digest("hex"), + terminalReason: "capped", + omittedWorkerIds: [], + selectedAt: "2030-01-01T00:00:00Z", + }, + ); + assert.deepEqual(draft.findings, saved.findings); + assert.equal(await readFile(selected, "utf8"), contents); + assert.throws(() => parseScanDraft(draft)); + assert.throws(() => parseDeepReduction(saved)); + + const invalid = structuredClone(saved); + invalid.findings[0].attackPath.dataFlow.evidence_refs = ["missing"]; + assert.throws( + () => parseDeepReduction(invalid, true), + /attackPath\.dataFlow\.evidence_refs.*existing code-evidence IDs/, + ); + } finally { + await rm(scanDir, { recursive: true, force: true }); + } + }); +} + for (const mode of ["normal", "symlink", "swapped", "changed", "canceled"]) { test(`selected reducer read preserves accepted-file boundary: ${mode}`, async (t) => { const root = await realpath( diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_publication_integration.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_publication_integration.ts index d8f47dc20b..b007c19c59 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_publication_integration.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_publication_integration.ts @@ -18,6 +18,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { buildServer } from "./build-server.ts"; import { importModule } from "./import-module.ts"; +import { legacyAcceptedFinding } from "./legacy-accepted-finding.ts"; import type { DeepScanRunState, DeepScanWorkerMutation, @@ -42,11 +43,24 @@ type HostModule = Pick< Pick< typeof import("../src/artifact-scan-draft.ts"), "recordCodexSecurityScanDraftViaWorkbench" + > & + Pick< + typeof import("../src/deep-scan/finalization.ts"), + "readSelectedDeepScanDraft" + > & + Pick< + typeof import("../src/deep-scan/artifacts.ts"), + "createDeepScanArtifacts" + > & + Pick< + typeof import("../src/deep-scan/artifact-validation.ts"), + "parseDeepReduction" | "deepReductionToScanDraft" >; type ToolResult = Awaited>; interface FixturePaths { target?: string; scanRoot?: string; + maxDiscoveryRuns?: number; } type Scenario = readonly [ label: string, @@ -91,6 +105,9 @@ const host = (await importModule({ 'export { DeepScanCoordinator } from "./src/deep-scan/coordinator.ts";', 'export { createScanArtifactContext } from "./src/artifact-context.ts";', 'export { recordCodexSecurityScanDraftViaWorkbench } from "./src/artifact-scan-draft.ts";', + 'export { readSelectedDeepScanDraft } from "./src/deep-scan/finalization.ts";', + 'export { createDeepScanArtifacts } from "./src/deep-scan/artifacts.ts";', + 'export { parseDeepReduction, deepReductionToScanDraft } from "./src/deep-scan/artifact-validation.ts";', ].join("\n"), resolveDir: mcpAppRoot, }, @@ -101,8 +118,126 @@ const { DeepScanCoordinator, createScanArtifactContext, recordCodexSecurityScanDraftViaWorkbench, + readSelectedDeepScanDraft, + createDeepScanArtifacts, + parseDeepReduction, + deepReductionToScanDraft, } = host; +for (const version of [ + "deep-security-scan/v2", + "deep-security-scan/v1", + "deep-scan-mcp/v1", +]) { + test(`historical accepted findings survive ${version} publication and public completion`, async (t) => { + const fixture = await createFixture(t, { maxDiscoveryRuns: 4 }, version); + const { run, store, call, runWorkbench } = fixture; + const legacy = legacyAcceptedFinding(); + const threatModel = { + summary: "Previously recorded synthetic threat model", + }; + assertSuccess( + await call("record_codex_security_scan_draft", { + ...partial(run, "previous-review"), + threatModel, + }), + ); + const claimed = await store.claimCoordinator({ + scanId: run.scanId, + threadId: owner, + }); + // Seed the accepted worker records and bytes produced by the earlier contract. + const results = await commitReducers( + fixture, + [1, 2], + [highId, lowId], + undefined, + { + findings: [legacy], + omitThreatModel: true, + }, + ); + const manifestPath = path.join(run.scanDir, "scan-manifest.json"); + let publicationRun = await store.get(run.scanId, owner); + const acceptedReducer = publicationRun.persistedWorkers?.find( + (worker) => worker.id === lowId, + ); + let resultPath = + acceptedReducer?.acceptedResultPath ?? + acceptedReducer?.resultManifestPath; + assert.ok(resultPath); + let draft: ScanDraftInput; + if (version === "deep-security-scan/v2") { + publicationRun = await store.selectFinalization({ + scanId: run.scanId, + coordinatorGeneration: claimed.run.coordinatorGeneration, + reason: "capped", + manifestPath, + resultPath: results[1], + omittedWorkerIds: [], + }); + assert.ok(publicationRun.finalizationInput?.resultPath); + resultPath = path.resolve( + run.scanDir, + publicationRun.finalizationInput.resultPath, + ); + draft = await readSelectedDeepScanDraft( + createDeepScanArtifacts(run.scanDir), + run.scanId, + publicationRun.finalizationInput, + ); + } else { + draft = deepReductionToScanDraft( + parseDeepReduction( + JSON.parse(await readFile(resultPath, "utf8")), + true, + ), + ); + } + const original = await readFile(resultPath, "utf8"); + assert.deepEqual(draft.findings, [legacy]); + assertToolError(await call("record_codex_security_scan_draft", draft)); + const context = await createScanArtifactContext(run.scanId, runWorkbench, { + requireRunning: true, + }); + await recordCodexSecurityScanDraftViaWorkbench( + context, + draft, + runWorkbench, + undefined, + { + coordinatorGeneration: publicationRun.coordinatorGeneration, + resultPath, + }, + ); + await store.finish({ + scanId: run.scanId, + reason: "capped", + manifestPath, + omittedWorkerIds: [], + }); + assertSuccess( + await call("complete_codex_security_scan", { scanId: run.scanId }), + ); + const saved = JSON.parse( + await readFile(path.join(run.scanDir, "findings.json"), "utf8"), + ); + for (const field of [ + "rootCause", + "root_cause", + "codeEvidence", + "code_evidence", + "validation", + "attackPath", + ] as const) { + assert.deepEqual(saved.findings[0][field], legacy[field]); + } + assert.equal(await readFile(resultPath, "utf8"), original); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + assert.deepEqual(manifest.scan.threatModel, threatModel); + }); +} + test("public partial drafts retain checkpoints after coordinator adoption", async (t) => { const fixture = await createFixture(t); const { run, store, call, runWorkbench } = fixture; @@ -724,6 +859,14 @@ async function createFixture( }).filter((entry): entry is [string, string] => entry[1] !== undefined), ); delete environment.CODEX_SECURITY_DEEP_SCAN_CONFIG_PATH; + if (paths.maxDiscoveryRuns !== undefined) { + const directory = path.join(environment.CODEX_HOME, "codex-security"); + await mkdir(directory, { recursive: true }); + await writeFile( + path.join(directory, "config.toml"), + `[deep_scan]\nmax_discovery_runs = ${paths.maxDiscoveryRuns}\n`, + ); + } const instant = new Date().toISOString(); let now = instant; const runWorkbench = async ( @@ -798,8 +941,13 @@ async function commitReducers( offsets: readonly [number, number], ids: readonly [string, string], reducerLabels?: readonly [string, string], + historical?: { + findings: Record[]; + omitThreatModel: boolean; + }, ) { const { run, store } = fixture; + const findings = historical?.findings ?? []; const results = []; for (let batch = 0; batch < 2; batch++) { const workerIds = []; @@ -819,7 +967,7 @@ async function commitReducers( const resultManifestPath = path.join(artifact.artifactDir, "result.json"); await writeFile( resultManifestPath, - JSON.stringify({ scanId: run.scanId, findings: [], coverage }), + JSON.stringify({ scanId: run.scanId, findings, coverage }), ); await store.updateWorker({ ...update, @@ -846,8 +994,10 @@ async function commitReducers( resultManifestPath, JSON.stringify({ scanId: run.scanId, - findings: [], - threatModel: { summary: label }, + findings, + ...(historical?.omitThreatModel + ? {} + : { threatModel: { summary: label } }), }), ); fixture.setTime(offsets[batch]); @@ -855,7 +1005,7 @@ async function commitReducers( id, scanId: run.scanId, resultManifestPath, - newFindings: 0, + newFindings: batch === 0 ? findings.length : 0, }); fixture.setTime(0); results.push(resultManifestPath); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts index 33f87ea535..e1854a2eee 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_stdio_lifecycle.ts @@ -180,9 +180,9 @@ async function testDeepScanDetachedCompletion(mode: string) { ` import sqlite3, sys with sqlite3.connect(sys.argv[1]) as connection: - connection.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL)") + connection.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, cwd TEXT, created_at INTEGER)") connection.execute("CREATE TABLE thread_spawn_edges (parent_thread_id TEXT NOT NULL, child_thread_id TEXT NOT NULL)") - connection.execute("INSERT INTO threads VALUES (?, ?)", (sys.argv[2], sys.argv[3])) + connection.execute("INSERT INTO threads (id, rollout_path) VALUES (?, ?)", (sys.argv[2], sys.argv[3])) `, path.join(codexHome, "state_5.sqlite"), threadId, diff --git a/plugins/codex-security/scripts/finalize_scan_contract.py b/plugins/codex-security/scripts/finalize_scan_contract.py index 7a4b9d31ab..927d6813d5 100644 --- a/plugins/codex-security/scripts/finalize_scan_contract.py +++ b/plugins/codex-security/scripts/finalize_scan_contract.py @@ -2964,6 +2964,7 @@ def _prepare_scan_finalization( draft_documents: tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None = None, finding_scope: list[tuple[str, bool]] | None = None, require_complete: bool = False, + accepted_findings: bool = False, ) -> PreparedScanFinalization: """Prepare validated documents and warnings without writing any output files.""" @@ -3037,8 +3038,12 @@ def _prepare_scan_finalization( if not was_sealed and scan["status"] == "completed": try: _populate_unsealed_finding_identities(manifest, findings) - _validate_findings(manifest, findings) - _validate_current_findings(findings, schema_dir) + if accepted_findings: + # SHA-verified Deep selections retain the historical artifact read contract. + findings_for_validation = _legacy_sealed_findings_for_validation(findings) + _validate_findings(manifest, findings_for_validation) + if not accepted_findings: + _validate_current_findings(findings, schema_dir) except ContractError as exc: raise RecoverableContractError( "Scan result contains invalid findings; repair the finding records " diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 187c72f5ee..d07e4a3355 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -130,6 +130,7 @@ require_uuid, sqlite_busy, user_context_argument, + validate_completed_scan_artifacts, ) FINDING_ARTIFACT_DIRECTORIES_LIMIT = 80 @@ -1263,8 +1264,10 @@ def complete_budget_exhausted_scan( draft_documents = None sealed_replay = False try: + accepted_findings = saved_results.has_verified_selected_result( + _WORKBENCH_DB_CONTEXT, connection, scan + ) if selection is not None: - saved_results._require_selected_result(scan, selection) if "publicationSha256" in selection: draft_documents = tuple( saved_results._read_scan_local_json(scan_dir, name, name) @@ -1279,6 +1282,7 @@ def complete_budget_exhausted_scan( expected_coverage_mode=expected_coverage_mode(scan), completion_binding=workbench_completion_binding(scan, now(), manifest), draft_documents=None if sealed else draft_documents, + accepted_findings=accepted_findings, ) saved_results.require_selected_publication( _WORKBENCH_DB_CONTEXT, connection, scan, validated @@ -1299,6 +1303,7 @@ def complete_budget_exhausted_scan( accepted_result=accepted, unmerged_workers=unmerged, draft_documents=draft_documents, + accepted_findings=accepted_findings or accepted is not None, ), ) else: @@ -1309,6 +1314,7 @@ def complete_budget_exhausted_scan( warning, unmerged_workers=deep_scan.budget_unmerged_workers(connection, scan_id), draft_documents=draft_documents, + accepted_findings=accepted_findings, ) if selection is not None: if sealed_replay: @@ -1422,6 +1428,7 @@ def budget_exhausted_draft( accepted_result: dict[str, Any] | None = None, unmerged_workers: list[sqlite3.Row] = (), draft_documents: tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None = None, + accepted_findings: bool = False, ) -> PreparedScanFinalization: documents = saved_results.budget_exhausted_draft( _WORKBENCH_DB_CONTEXT, @@ -1443,6 +1450,7 @@ def budget_exhausted_draft( completion_binding=workbench_completion_binding(scan, now(), manifest), draft_documents=None if sealed else documents, finding_scope=requested_finding_scope(scan), + accepted_findings=accepted_findings, ) @@ -1458,21 +1466,6 @@ def completed_scan_context( return context -def validate_completed_scan_artifacts(scan: sqlite3.Row) -> None: - scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) - require_recorded_manifest_digest(scan, scan_dir) - verify_manifest_binding(scan, read_json_object(scan_dir / ARTIFACTS["manifest"])) - try: - manifest = _prepare_scan_finalization( - scan_dir, - expected_coverage_mode=expected_coverage_mode(scan), - )[2] - except ContractError as exc: - raise SystemExit(str(exc)) from exc - verify_manifest_binding(scan, manifest) - published_manifest_digest(scan_dir, manifest) - - def complete_scan_locked( connection: sqlite3.Connection, scan_id: str, @@ -1582,6 +1575,9 @@ def add_warning() -> None: draft_documents=draft_documents, finding_scope=requested_finding_scope(scan) if scan["mode"] == "deep" else None, require_complete=True, + accepted_findings=saved_results.has_verified_selected_result( + _WORKBENCH_DB_CONTEXT, connection, scan + ), ) if prepared[2]["scan"].get("complete") is False: raise RecoverableContractError( @@ -3500,7 +3496,7 @@ def main( ): scan = require_scan(connection, result["deepScan"]["scanId"]) if scan["status"] == "complete": - validate_completed_scan_artifacts(scan) + validate_completed_scan_artifacts(_WORKBENCH_DB_CONTEXT, scan) elif args.command == "get-deep-scan": result = deep_scan.get_deep_scan(connection, args) elif args.command == "claim-deep-scan-coordinator": diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 6c0ff781fe..205a4ede51 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -47,7 +47,11 @@ from workbench_constants import PHASES from workbench_source_coverage import retain_unmerged_review_coverage from workbench_target import committed_diff_snapshot_digest -from workbench_validation import path_within_scope +from workbench_validation import ( + has_verified_selected_result, + latest_publication_reducer, + path_within_scope, +) _PUBLISHED_OUTPUTS = ( "findings.json", @@ -2981,7 +2985,7 @@ def require_selected_publication(db: Any, connection: Any, scan: Any, prepared: def _require_current_deep_publication( db: Any, connection: Any, scan: Any, draft: dict[str, Any] -) -> None: +) -> bool: scan_id = scan["id"] run = connection.execute( "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) @@ -2994,7 +2998,7 @@ def _require_current_deep_publication( and draft["manifest"]["scan"].get("complete") is False and (run is None or run["status"] == "running") ): - return + return False if run is None: run = db.deep_scan.require_deep_scan_run(connection, scan_id) # Finished discovery-only runs hand remaining publication back to the parent. @@ -3004,24 +3008,26 @@ def _require_current_deep_publication( and run["status"] != "running" and run["manifest_path"] != str(Path(scan["scan_dir"]) / "scan-manifest.json") ): - return + return False db.deep_scan.require_current_coordinator( run, argparse.Namespace( coordinator_generation=publication.get("coordinatorGeneration") if publication else None ), ) + accepted_findings = False if selection is not None: if publication is None: raise SystemExit("Deep Scan publication requires its committed selection.") scan = db.require_scan(connection, scan_id) - _require_selected_result(scan, selection) + accepted_findings = has_verified_selected_result(db, connection, scan) if "publicationSha256" in selection: prepared = _prepare_scan_finalization( Path(scan["scan_dir"]), expected_coverage_mode=db.expected_coverage_mode(scan), completion_binding=db.workbench_completion_binding(scan, db.now()), draft_documents=(draft["manifest"], draft["findings"], draft["coverage"]), + accepted_findings=accepted_findings, ) require_selected_publication(db, connection, scan, prepared) selected_result = ( @@ -3037,28 +3043,15 @@ def _require_current_deep_publication( Path(scan["scan_dir"]) / "scan-manifest.json" ): raise SystemExit("Deep Scan is terminal; drafts cannot replace its publication.") - return - - # Publication follows the coordinator's durable reducer sequence. Stopped - # legacy recovery separately retains its completion-time source ordering. - def reducer_order(worker: Any) -> tuple[int, str]: - match = re.fullmatch(r"dedup-([0-9]+)", Path(worker["prompt_path"]).parent.name) - return (int(match[1]) if match else 0, worker["id"]) - - reducer = max( - ( - worker - for worker in _saved_workers(connection, scan_id) - if worker["kind"] == "dedup" - and worker["status"] == "succeeded" - and worker["result_manifest_path"] - ), - key=reducer_order, - default=None, - ) + return False + + reducer = latest_publication_reducer(db, connection, scan_id) selected_result = reducer["result_manifest_path"] if reducer is not None else None if publication["resultPath"] != selected_result: raise SystemExit("Deep Scan aggregate belongs to a superseded publication selection.") + if selection is None: + accepted_findings = has_verified_selected_result(db, connection, scan, reducer=reducer) + return accepted_findings def write_scan_draft(db: Any, connection: Any, args: Any) -> dict[str, Any]: @@ -3117,8 +3110,9 @@ def write_scan_draft(db: Any, connection: Any, args: Any) -> dict[str, Any]: is not None ) run = None + accepted_findings = False if scan["mode"] == "deep" and not checkpoint_only: - _require_current_deep_publication(db, connection, scan, draft) + accepted_findings = _require_current_deep_publication(db, connection, scan, draft) run = db.deep_scan.find_supported_deep_scan_run(connection, scan_id) if checkpoint_contents is not None: checkpoint_digest = hashlib.sha256(checkpoint_contents).hexdigest() @@ -3165,6 +3159,7 @@ def write_scan_draft(db: Any, connection: Any, args: Any) -> dict[str, Any]: expected_coverage_mode=db.expected_coverage_mode(scan), completion_binding=binding, draft_documents=(manifest, findings, coverage), + accepted_findings=accepted_findings, ) record_selected_publication(db, connection, scan, prepared) # Accepted Standard drafts are evidence of review or report assembly, diff --git a/plugins/codex-security/scripts/workbench_validation.py b/plugins/codex-security/scripts/workbench_validation.py index 629e2e968f..8c5ac2b75d 100644 --- a/plugins/codex-security/scripts/workbench_validation.py +++ b/plugins/codex-security/scripts/workbench_validation.py @@ -51,6 +51,76 @@ def sqlite_busy(error: sqlite3.OperationalError) -> bool: path_within_scope = finalizer.path_within_scope +def latest_publication_reducer(db: Any, connection: Any, scan_id: str) -> Any | None: + # Publication follows the durable reducer sequence, not completion timestamps. + def reducer_order(worker: Any) -> tuple[int, str]: + match = re.fullmatch(r"dedup-([0-9]+)", Path(worker["prompt_path"]).parent.name) + return (int(match[1]) if match else 0, worker["id"]) + + return max( + ( + worker + for worker in db.saved_results._saved_workers(connection, scan_id) + if worker["kind"] == "dedup" + and worker["status"] == "succeeded" + and worker["result_manifest_path"] + ), + key=reducer_order, + default=None, + ) + + +def has_verified_selected_result( + db: Any, connection: Any, scan: Any, *, reducer: Any = None +) -> bool: + """Verify persisted Deep publication authority before using the artifact read contract.""" + if scan["mode"] != "deep": + return False + run = db.deep_scan.find_supported_deep_scan_run(connection, scan["id"]) + selection = db.deep_scan.deep_scan_finalization_input(run) if run is not None else None + try: + if selection is not None: + db.saved_results._require_selected_result(scan, selection) + return selection["resultPath"] is not None + if run is None or run["workflow_version"] not in { + "deep-scan-mcp/v1", + "deep-security-scan/v1", + }: + return False + if reducer is None: + reducer = latest_publication_reducer(db, connection, scan["id"]) + accepted = reducer["accepted_result_path"] if reducer is not None else None + digests = db.saved_results._accepted_source_digests(connection, scan["id"]) + if accepted is None or accepted not in digests: + return False + scan_dir = Path(scan["scan_dir"]) + db.saved_results._read_saved_result( + scan_dir, + Path(accepted).relative_to(scan_dir).as_posix(), + scan["id"], + kind="dedup", + accepted_source_digests=digests, + ) + except (finalizer.ContractError, ValueError) as exc: + raise finalizer.RecoverableContractError(str(exc)) from exc + return True + + +def validate_completed_scan_artifacts(db: Any, scan: sqlite3.Row) -> None: + scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) + db.require_recorded_manifest_digest(scan, scan_dir) + db.verify_manifest_binding(scan, db.read_json_object(scan_dir / db.ARTIFACTS["manifest"])) + try: + manifest = db._prepare_scan_finalization( + scan_dir, + expected_coverage_mode=db.expected_coverage_mode(scan), + )[2] + except db.ContractError as exc: + raise SystemExit(str(exc)) from exc + db.verify_manifest_binding(scan, manifest) + db.published_manifest_digest(scan_dir, manifest) + + def require_close_note(close_reason: str | None, note: str | None) -> None: if note is None and close_reason == "false_positive": raise SystemExit("Explain why this finding is a false positive.") diff --git a/plugins/codex-security/tests/test_selected_publication_compatibility.py b/plugins/codex-security/tests/test_selected_publication_compatibility.py index 99f2b3c402..16296a5b09 100644 --- a/plugins/codex-security/tests/test_selected_publication_compatibility.py +++ b/plugins/codex-security/tests/test_selected_publication_compatibility.py @@ -6,19 +6,17 @@ import hashlib import json from argparse import Namespace +from functools import partial import pytest +from test_accepted_publication_references import accept_reducer from test_deep_scan_publication_authority import stage_publication from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_publication_stop_interleavings import saved_selection +from test_workbench_db import BUDGET_COST -def selected_publication(api, connection, scan, *, legacy_completeness=None): - if legacy_completeness is not None: - scan.coverage["completeness"] = legacy_completeness - coverage_path = scan.scan_dir / "coverage.json" - coverage = json.loads(coverage_path.read_bytes()) - coverage["completeness"] = legacy_completeness - coverage_path.write_text(json.dumps(coverage)) +def selected_result(connection, scan): result = scan.scan_dir / "accepted.json" result.write_text( json.dumps( @@ -30,11 +28,39 @@ def selected_publication(api, connection, scan, *, legacy_completeness=None): } ) ) + selection = { + "version": 1, + "resultPath": result.name, + "resultSha256": hashlib.sha256(result.read_bytes()).hexdigest(), + "terminalReason": "saturated", + "omittedWorkerIds": [], + "selectedAt": scan.timestamp, + } + with connection: + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "coordinator_generation = 2, finalization_input_json = ? WHERE scan_id = ?", + (json.dumps(selection), scan.scan_id), + ) + return result + + +def selected_publication(api, connection, scan, *, legacy_completeness=None): + if legacy_completeness is not None: + scan.coverage["completeness"] = legacy_completeness + coverage_path = scan.scan_dir / "coverage.json" + coverage = json.loads(coverage_path.read_bytes()) + coverage["completeness"] = legacy_completeness + coverage_path.write_text(json.dumps(coverage)) + result = selected_result(connection, scan) row = api["require_scan"](connection, scan.scan_id) prepared = api["_prepare_scan_finalization"]( scan.scan_dir, expected_coverage_mode=api["expected_coverage_mode"](row), completion_binding=api["workbench_completion_binding"](row, api["now"]()), + accepted_findings=api["saved_results"].has_verified_selected_result( + api["_WORKBENCH_DB_CONTEXT"], connection, row + ), ) manifest = copy.deepcopy(prepared[2]) coverage = copy.deepcopy(prepared[4]) @@ -49,24 +75,367 @@ def selected_publication(api, connection, scan, *, legacy_completeness=None): encoded = json.dumps( [manifest, prepared[3], coverage], sort_keys=True, separators=(",", ":") ).encode() - selection = { - "version": 1, - "resultPath": result.name, - "resultSha256": hashlib.sha256(result.read_bytes()).hexdigest(), - "publicationSha256": hashlib.sha256(encoded).hexdigest(), - "terminalReason": "saturated", - "omittedWorkerIds": [], - "selectedAt": scan.timestamp, - } + selection = json.loads( + connection.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + ) + selection["publicationSha256"] = hashlib.sha256(encoded).hexdigest() with connection: connection.execute( - "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " - "coordinator_generation = 2, finalization_input_json = ? WHERE scan_id = ?", + "UPDATE deep_scan_runs SET finalization_input_json = ? WHERE scan_id = ?", (json.dumps(selection), scan.scan_id), ) return result +def legacy_finding(scan): + finding = scan.findings[0] + finding.pop("rootCause", None) + finding["root_cause"] = { + "summary": "Previously accepted root cause.", + "evidence_refs": ["legacy-source"], + } + finding["code_evidence"] = [{"id": "legacy-source", "code": "return synthetic_input;"}] + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": scan.findings})) + return {key: copy.deepcopy(finding[key]) for key in ("root_cause", "code_evidence")} + + +def assert_legacy_finding_published(scan, expected): + finding = json.loads((scan.scan_dir / "findings.json").read_text())["findings"][0] + assert {key: finding[key] for key in expected} == expected + report = (scan.scan_dir / "report.md").read_text() + assert "Previously accepted root cause." in report + assert "return synthetic_input;" in report + + +@pytest.mark.parametrize("workflow", ["deep-scan-mcp/v1", "deep-security-scan/v1"]) +@pytest.mark.parametrize("capped", [False, True]) +def test_legacy_v1_accepted_findings_publish_and_resume_completion( + workbench_api, workbench_db, publication_scan, monkeypatch, workflow, capped +): + scan = publication_scan() + expected = legacy_finding(scan) + _, accepted, _ = accept_reducer(workbench_db, scan) + accepted_bytes = accepted.read_bytes() + with workbench_db: + workbench_db.execute("UPDATE deep_scan_runs SET workflow_version = ?", (workflow,)) + if capped: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + staged = stage_publication( + scan, generation=3, result_path=accepted, title=scan.findings[0]["title"] + ) + workbench_api["saved_results"].write_scan_draft( + workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged + ) + + def interrupted(*args, **kwargs): + raise RuntimeError("Interrupted legacy completion") + + if capped: + complete = workbench_api["complete_budget_exhausted_scan"] + args = Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None) + cut = "complete_scan_locked" + else: + complete = workbench_api["complete_scan"] + args = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + cut = "_write_prepared_scan_finalization" + with monkeypatch.context() as patch: + patch.setitem(complete.__globals__, cut, interrupted) + with pytest.raises(RuntimeError, match="Interrupted legacy completion"): + complete(workbench_db, args) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + complete(workbench_db, args) + assert_legacy_finding_published(scan, expected) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + assert ( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + is None + ) + sealed = {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert {path: path.read_bytes() for path in sealed} == sealed + assert accepted.read_bytes() == accepted_bytes + + +def test_legacy_v1_authority_uses_reducer_sequence_instead_of_completion_order( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + expected = legacy_finding(scan) + older_result, older_accepted, _ = accept_reducer(workbench_db, scan) + latest_result, latest_accepted, _ = accept_reducer(workbench_db, scan) + with workbench_db: + for sequence, result in enumerate((older_result, latest_result), start=1): + workbench_db.execute( + "UPDATE deep_scan_workers SET prompt_path = ?, completed_at = ? WHERE id = ?", + ( + str(scan.scan_dir / f"dedup-{sequence:04d}" / "prompt.md"), + f"2026-01-0{3 - sequence}", + result.parent.name, + ), + ) + older_accepted.unlink() + staged = stage_publication( + scan, generation=3, result_path=latest_accepted, title=scan.findings[0]["title"] + ) + workbench_api["saved_results"].write_scan_draft( + workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged + ) + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert_legacy_finding_published(scan, expected) + + +@pytest.mark.parametrize( + "damage", + [ + "source-before-publication", + "source-before-completion", + "unaccepted", + "no-digest", + "descriptor", + ], +) +def test_legacy_v1_compatibility_requires_the_matching_accepted_source( + workbench_api, workbench_db, publication_scan, damage +): + scan = publication_scan() + legacy_finding(scan) + result, accepted, _ = accept_reducer(workbench_db, scan) + selected = accepted + expected_error = "changed after acceptance" + if damage == "unaccepted": + result.write_bytes(accepted.read_bytes()) + with workbench_db: + workbench_db.execute("DELETE FROM deep_scan_attempts") + selected = result + expected_error = "invalid findings" + elif damage == "no-digest": + with workbench_db: + workbench_db.execute("UPDATE deep_scan_attempts SET accepted_result_sha256 = NULL") + expected_error = "invalid findings" + elif damage == "descriptor": + selected = result + expected_error = "aggregate" + staged = stage_publication( + scan, generation=3, result_path=selected, title=scan.findings[0]["title"] + ) + saved = workbench_api["saved_results"] + completing = damage in {"source-before-completion", "unaccepted", "no-digest"} + if completing: + saved.write_scan_draft(workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged) + if damage.startswith("source-"): + accepted.write_bytes(accepted.read_bytes() + b"\n") + files = {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} + state = "\n".join(workbench_db.iterdump()) + operation = ( + partial( + workbench_api["complete_scan"], + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None), + ) + if completing + else partial( + saved.write_scan_draft, workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged + ) + ) + with pytest.raises((SystemExit, workbench_api["ContractError"]), match=expected_error): + operation() + assert "\n".join(workbench_db.iterdump()) == state + assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} == files + + +def test_selected_legacy_findings_publish_before_the_first_publication_receipt( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + expected = legacy_finding(scan) + accepted = selected_result(workbench_db, scan) + accepted_bytes = accepted.read_bytes() + staged = stage_publication( + scan, generation=2, result_path=accepted, title=scan.findings[0]["title"] + ) + saved = workbench_api["saved_results"] + saved.write_scan_draft(workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged) + receipt = workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[ + 0 + ] + assert len(json.loads(receipt)["publicationSha256"]) == 64 + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert_legacy_finding_published(scan, expected) + workbench_api["validate_completed_scan_artifacts"]( + workbench_api["_WORKBENCH_DB_CONTEXT"], + workbench_api["require_scan"](workbench_db, scan.scan_id), + ) + assert accepted.read_bytes() == accepted_bytes + assert ( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + == receipt + ) + + +@pytest.mark.parametrize("cut", ["before", "after"]) +def test_selected_historical_findings_resume_across_interrupted_sealing( + workbench_api, workbench_db, publication_scan, monkeypatch, cut +): + scan = publication_scan() + expected = legacy_finding(scan) + accepted = selected_publication( + workbench_api, workbench_db, scan, legacy_completeness="partial" + ) + accepted_bytes = accepted.read_bytes() + receipt = workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[ + 0 + ] + namespace = workbench_api["complete_scan_locked"].__globals__ + write = namespace["_write_prepared_scan_finalization"] + + def interrupted_write(*args, **kwargs): + if cut == "after": + write(*args, **kwargs) + raise RuntimeError("Interrupted historical publication") + + completion = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + with monkeypatch.context() as patch: + patch.setitem(namespace, "_write_prepared_scan_finalization", interrupted_write) + with pytest.raises(RuntimeError, match="Interrupted historical publication"): + workbench_api["complete_scan"](workbench_db, completion) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + workbench_api["complete_scan"](workbench_db, completion) + assert_legacy_finding_published(scan, expected) + sealed = {p: p.read_bytes() for p in scan.scan_dir.rglob("*") if p.is_file()} + workbench_api["complete_scan"](workbench_db, completion) + assert {p: p.read_bytes() for p in sealed} == sealed + assert accepted.read_bytes() == accepted_bytes + assert ( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + == receipt + ) + + +@pytest.mark.parametrize("damage", ["canonical-finding", "staged-finding", "accepted-source"]) +def test_selected_legacy_finding_compatibility_retains_publication_integrity( + workbench_api, workbench_db, publication_scan, damage +): + scan = publication_scan() + legacy_finding(scan) + accepted = selected_publication( + workbench_api, workbench_db, scan, legacy_completeness="partial" + ) + if damage == "accepted-source": + accepted.write_bytes(accepted.read_bytes() + b"\n") + elif damage == "canonical-finding": + path = scan.scan_dir / "findings.json" + document = json.loads(path.read_text()) + document["findings"][0]["root_cause"]["summary"] = "Substituted accepted explanation." + path.write_text(json.dumps(document)) + else: + scan.findings[0]["code_evidence"][0]["code"] = "return substituted_input;" + staged = stage_publication( + scan, generation=2, result_path=accepted, title=scan.findings[0]["title"] + ) + before = {p: p.read_bytes() for p in scan.scan_dir.rglob("*") if p.is_file()} + state = "\n".join(workbench_db.iterdump()) + if damage == "staged-finding": + with pytest.raises(workbench_api["ContractError"], match="publication"): + workbench_api["saved_results"].write_scan_draft( + workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged + ) + else: + with pytest.raises(SystemExit, match="publication|changed after acceptance"): + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert {p: p.read_bytes() for p in before} == before + assert "\n".join(workbench_db.iterdump()) == state + + +@pytest.mark.parametrize("mode", ["standard", "deep"]) +def test_new_producers_still_reject_legacy_finding_fields( + workbench_api, workbench_db, publication_scan, mode +): + scan = publication_scan(mode=mode) + legacy_finding(scan) + with pytest.raises(SystemExit, match="invalid findings.*unexpected schema property"): + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + + +def test_zero_result_selection_does_not_admit_legacy_findings( + workbench_api, workbench_db, publication_scan +): + scan = publication_scan() + legacy_finding(scan) + selected_result(workbench_db, scan) + selection = json.loads( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + ) + selection["resultPath"] = None + selection["resultSha256"] = None + selection["terminalReason"] = "capped" + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET finalization_input_json = ?, terminal_reason = 'capped'", + (json.dumps(selection),), + ) + with pytest.raises(SystemExit, match="invalid findings.*unexpected schema property"): + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + + +@pytest.mark.parametrize("preselected", [False, True]) +def test_budget_replays_legacy_accepted_findings_after_publication_commit( + workbench_api, workbench_db, publication_scan, monkeypatch, preselected +): + scan = publication_scan() + expected = legacy_finding(scan) + _, accepted, _ = accept_reducer(workbench_db, scan) + accepted_bytes = accepted.read_bytes() + for name in ("scan-manifest.json", "findings.json", "coverage.json"): + (scan.scan_dir / name).unlink() + with workbench_db: + recipe = json.loads(workbench_db.execute("SELECT recipe_json FROM scans").fetchone()[0]) + recipe["maxCostUsd"] = 0.005 + workbench_db.execute("UPDATE scans SET recipe_json = ?", (json.dumps(recipe),)) + workbench_db.execute( + "UPDATE deep_scan_runs SET status = 'running', workflow_version = 'deep-security-scan/v2'" + ) + if preselected: + saved_selection(workbench_db, scan, accepted, reason="capped") + budget = workbench_api["complete_budget_exhausted_scan"] + + def interrupted_completion(*args, **kwargs): + raise RuntimeError("Interrupted after accepted budget publication") + + args = Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None) + with monkeypatch.context() as patch: + patch.setitem(budget.__globals__, "complete_scan_locked", interrupted_completion) + with pytest.raises(RuntimeError, match="Interrupted after accepted budget publication"): + budget(workbench_db, args) + receipt = workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[ + 0 + ] + assert "publicationSha256" in json.loads(receipt) + budget(workbench_db, args) + assert_legacy_finding_published(scan, expected) + assert accepted.read_bytes() == accepted_bytes + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + assert ( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + == receipt + ) + + @pytest.mark.parametrize("legacy_completeness", ["complete", "partial", "unknown"]) @pytest.mark.parametrize("cut", ["before", "after"]) def test_selected_replays_historical_publication_across_normalization( From 98ca2a34ec84b1d42e64c78a1f2dee8203193292 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 21:49:54 +0000 Subject: [PATCH 247/251] fix(sdk): preserve accepted replay and execution outcomes --- .../mcp-app/src/artifact-scan-draft.ts | 60 ++++++++++++------- .../codex-security/scripts/workbench_db.py | 9 ++- ...test_selected_publication_compatibility.py | 19 ++++++ sdk/typescript/src/api.ts | 44 +++++++++++--- .../tests-ts/api-audit-admission.test.ts | 37 ++++++++++++ .../tests-ts/deep-finalization.test.ts | 54 ++++++++++++++++- sdk/typescript/tests-ts/hosted-scan.test.ts | 25 ++++++++ 7 files changed, 217 insertions(+), 31 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts index e249e14bfd..9af9543022 100644 --- a/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts +++ b/plugins/codex-security/mcp-app/src/artifact-scan-draft.ts @@ -37,6 +37,10 @@ const canonicalFileScanDraftInputSchema = fileScanDraftInputSchema.extend({ ), }), }); +const acceptedCanonicalFileScanDraftInputSchema = + canonicalFileScanDraftInputSchema.extend({ + findings: acceptedDeepScanDraftInputSchema.shape.findings, + }); export const completedScanInputSchema = draftDefinitions.completedScanInput; // Host drafts include accepted historical findings as well as current inputs. export type ScanDraftInput = z.infer; @@ -1796,12 +1800,15 @@ function safeRelativePath(value: string, context: string): string { } /** Admit file-authored drafts through the current contract after removing host fields. */ -export function parseCanonicalScanDraft(input: { - scanId?: string; - manifest: JsonObject; - findings: JsonObject; - coverage: JsonObject; -}): ScanDraftInput { +export function parseCanonicalScanDraft( + input: { + scanId?: string; + manifest: JsonObject; + findings: JsonObject; + coverage: JsonObject; + }, + acceptedFindings = false, +): ScanDraftInput { const scan = requireObject(input.manifest.scan, "scan draft manifest.scan"); for (const scanId of [ scan.id, @@ -1814,24 +1821,31 @@ export function parseCanonicalScanDraft(input: { ); } } - return parseCanonicalCheckpoint({ - scanId: input.scanId, - ...(scan.complete === undefined ? {} : { complete: scan.complete }), - ...(scan.scope === undefined ? {} : { scope: scan.scope }), - ...(scan.threatModel === undefined - ? {} - : { threatModel: scan.threatModel }), - findings: input.findings.findings, - coverage: input.coverage, - }); + return parseCanonicalCheckpoint( + { + scanId: input.scanId, + ...(scan.complete === undefined ? {} : { complete: scan.complete }), + ...(scan.scope === undefined ? {} : { scope: scan.scope }), + ...(scan.threatModel === undefined + ? {} + : { threatModel: scan.threatModel }), + findings: input.findings.findings, + coverage: input.coverage, + }, + acceptedFindings, + ); } function parseCanonicalCheckpoint( input: Record, + acceptedFindings = false, ): ScanDraftInput { const parsed = parsePersistedCheckpoint( input, - canonicalFileScanDraftInputSchema, + acceptedFindings + ? acceptedCanonicalFileScanDraftInputSchema + : canonicalFileScanDraftInputSchema, + acceptedFindings, ); for (const [index, finding] of parsed.findings.entries()) { for (const field of ["locations", "codeEvidence"] as const) { @@ -1890,8 +1904,11 @@ export function parsePersistedScanDraft( } /** Replay host-accepted Deep findings while preserving their original details. */ -export function parseAcceptedDeepScanDraft(input: unknown): ScanDraftInput { - const parsed = acceptedDeepScanDraftInputSchema.parse(input); +export function parseAcceptedDeepScanDraft( + input: unknown, + schema: z.ZodType = acceptedDeepScanDraftInputSchema, +): ScanDraftInput { + const parsed = schema.parse(input); validateFindingSemantics(parsed.findings, true); delete parsed.coverage.completeness; return parsed; @@ -1911,6 +1928,7 @@ export function parseAcceptedDiscoveryDraft(input: unknown): ScanDraftInput { function parsePersistedCheckpoint( input: Record, schema: z.ZodType = fileScanDraftInputSchema, + acceptedFindings = false, ): ScanDraftInput { const compatible = structuredClone(input); if (isObject(compatible.scope)) { @@ -1939,7 +1957,9 @@ function parsePersistedCheckpoint( delete finding.fingerprints; } } - return parsePersistedScanDraft(compatible, schema); + return acceptedFindings + ? parseAcceptedDeepScanDraft(compatible, schema) + : parsePersistedScanDraft(compatible, schema); } function requireBoundScan( diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index d07e4a3355..23583edab6 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1543,6 +1543,7 @@ def add_warning() -> None: scan["mode"] != "deep" and current_manifest_path is not None and not already_sealed ) wrote = False + accepted = False try: with ( saved_results.preserve_parent_head_on_error(scan_dir) @@ -1566,6 +1567,9 @@ def add_warning() -> None: if merge_parent_draft else None ) + accepted = saved_results.has_verified_selected_result( + _WORKBENCH_DB_CONTEXT, connection, scan + ) prepared = _prepare_scan_finalization( scan_dir, expected_coverage_mode=expected_coverage_mode(scan), @@ -1575,9 +1579,7 @@ def add_warning() -> None: draft_documents=draft_documents, finding_scope=requested_finding_scope(scan) if scan["mode"] == "deep" else None, require_complete=True, - accepted_findings=saved_results.has_verified_selected_result( - _WORKBENCH_DB_CONTEXT, connection, scan - ), + accepted_findings=accepted, ) if prepared[2]["scan"].get("complete") is False: raise RecoverableContractError( @@ -1635,6 +1637,7 @@ def add_warning() -> None: raise SystemExit("Only a running scan can be prepared for completion.") context = scan_context(connection, scan["id"]) context["targetWarnings"] = target_warnings + context["acceptedFindings"] = accepted return context if cost_json is None: diff --git a/plugins/codex-security/tests/test_selected_publication_compatibility.py b/plugins/codex-security/tests/test_selected_publication_compatibility.py index 16296a5b09..5b0925b08f 100644 --- a/plugins/codex-security/tests/test_selected_publication_compatibility.py +++ b/plugins/codex-security/tests/test_selected_publication_compatibility.py @@ -145,6 +145,12 @@ def interrupted(*args, **kwargs): with pytest.raises(RuntimeError, match="Interrupted legacy completion"): complete(workbench_db, args) assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + prepared = workbench_api["complete_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None), + prepare_only=True, + ) + assert prepared["acceptedFindings"] is True complete(workbench_db, args) assert_legacy_finding_published(scan, expected) assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" @@ -307,6 +313,8 @@ def interrupted_write(*args, **kwargs): with pytest.raises(RuntimeError, match="Interrupted historical publication"): workbench_api["complete_scan"](workbench_db, completion) assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + prepared = workbench_api["complete_scan"](workbench_db, completion, prepare_only=True) + assert prepared["acceptedFindings"] is True workbench_api["complete_scan"](workbench_db, completion) assert_legacy_finding_published(scan, expected) sealed = {p: p.read_bytes() for p in scan.scan_dir.rglob("*") if p.is_file()} @@ -361,12 +369,21 @@ def test_new_producers_still_reject_legacy_finding_fields( workbench_api, workbench_db, publication_scan, mode ): scan = publication_scan(mode=mode) + findings_path = scan.scan_dir / "findings.json" + original_findings = findings_path.read_bytes() legacy_finding(scan) with pytest.raises(SystemExit, match="invalid findings.*unexpected schema property"): workbench_api["complete_scan"]( workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) ) assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "running" + findings_path.write_bytes(original_findings) + prepared = workbench_api["complete_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None), + prepare_only=True, + ) + assert prepared["acceptedFindings"] is False def test_zero_result_selection_does_not_admit_legacy_findings( @@ -645,6 +662,8 @@ def test_selected_zero_discovery_deadline_preserves_the_published_draft( assert workbench_db.execute("SELECT status FROM deep_scan_runs").fetchone()[0] == "succeeded" assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} == before completion = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + prepared = workbench_api["complete_scan"](workbench_db, completion, prepare_only=True) + assert prepared["acceptedFindings"] is False workbench_api["complete_scan"](workbench_db, completion) assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" sealed = {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index c496efcf3e..2562493f73 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -2662,7 +2662,9 @@ export class CodexSecurity { if (!savedCompletion) scanTurnStatus = "in_progress"; checkOpen(); + let preparedAcceptedFindings = false; let result = await runScanEvents({ + acceptedFindings: () => preparedAcceptedFindings, savedCompletion: savedCompletion ?? undefined, recoverCompletion: recoverSelectedCompletion, reconcileCheckpoint: @@ -2842,6 +2844,7 @@ export class CodexSecurity { } throw error; } + preparedAcceptedFindings = preparation["acceptedFindings"] === true; preparedTargetWarnings = Array.isArray(preparation["targetWarnings"]) ? preparation["targetWarnings"].filter( (warning): warning is string => typeof warning === "string", @@ -2955,7 +2958,24 @@ export class CodexSecurity { { ...workbenchOptions, signal: undefined }, scanDir, ); + const savedDocument = (name: string) => + JSON.parse( + completedArtifacts + .find((artifact) => artifact.name === name)! + .contents.toString("utf8"), + ); + const acceptedDocuments = preparedAcceptedFindings + ? { + manifest: savedDocument("scan-manifest.json"), + findings: savedDocument("findings.json"), + coverage: savedDocument("coverage.json"), + } + : undefined; const followUpResult = await runScanEvents({ + // A new model edit cannot inherit the saved publication's authority. + acceptedFindings: (documents) => + preparedAcceptedFindings && + isDeepStrictEqual(documents, acceptedDocuments), thread, events: (await followUp()).events, signal, @@ -4690,6 +4710,12 @@ async function removeTargetPathsFile(path: string | null): Promise { } } +interface CanonicalScanDraftDocuments { + manifest: JsonObject; + findings: JsonObject; + coverage: JsonObject; +} + interface ScanEventRunOptions extends Pick { scanId?: string; savedCompletion?: Awaited>; @@ -4697,6 +4723,7 @@ interface ScanEventRunOptions extends Pick { ReturnType > | null>; reconcileCheckpoint?: () => Promise; + acceptedFindings?: (documents: CanonicalScanDraftDocuments) => boolean; thread: Pick & Partial>; events: AsyncGenerator; @@ -4855,12 +4882,11 @@ export async function runScanEvents( pathToFileURL(join(await bundledPluginRoot(), "mcp/helpers.mjs")).href ) ).default; - const draft: ScanDraftInput = helper.parseCanonicalScanDraft({ - scanId: options.scanId ?? manifest.scan.id, - manifest, - findings, - coverage, - }); + const documents = { manifest, findings, coverage }; + const draft: ScanDraftInput = helper.parseCanonicalScanDraft( + { scanId: options.scanId ?? manifest.scan.id, ...documents }, + options.acceptedFindings?.(documents) === true, + ); return auditEvidence(draft); }; let audit; @@ -4918,7 +4944,11 @@ export async function runScanEvents( if (options.signal.reason instanceof ScanCostLimitExceededError) { throw options.signal.reason; } - if (options.signal.aborted && !(error instanceof ScanInterruptedError)) { + if ( + options.signal.aborted && + !(error instanceof ScanInterruptedError) && + !(error instanceof ScanExecutionError) + ) { throw new ScanInterruptedError( `Codex Security scan was interrupted; partial output remains at ${options.scanDir}.`, options.scanDir, diff --git a/sdk/typescript/tests-ts/api-audit-admission.test.ts b/sdk/typescript/tests-ts/api-audit-admission.test.ts index 0c0182ddec..d2a724ff24 100644 --- a/sdk/typescript/tests-ts/api-audit-admission.test.ts +++ b/sdk/typescript/tests-ts/api-audit-admission.test.ts @@ -452,6 +452,43 @@ test("canonical admission still rejects reversed typed evidence bounds", async ( expect(outcome.finalizations).toBe(0); }); +test.each(["standard", "deep"] as const)( + "managed %s preparation without accepted authority rejects historical findings", + async (mode) => { + const scanDir = await copyCompletedScan(await temporaryDirectory()); + const file = join(scanDir, "findings.json"); + const findings = JSON.parse(await readFile(file, "utf8")); + findings.findings[0].root_cause = { summary: "Unaccepted legacy detail." }; + const original = JSON.stringify(findings); + await writeFile(file, original); + let preparations = 0; + await expect( + runScanEvents({ + thread: { id: "synthetic-thread" }, + events: completedEvents("synthetic-thread"), + signal: new AbortController().signal, + scanDir, + pluginRoot: PLUGIN_ROOT, + expectation: { + repository: "/repository", + repositoryRevision: "deadbeef", + target: { kind: "repository", paths: [] }, + mode, + pluginVersion: "0.1.0", + }, + workbenchValidated: true, + acceptedFindings: () => false, + onFinalize: async () => { + preparations++; + return null; + }, + }), + ).rejects.toThrow("root_cause"); + expect(preparations).toBe(1); + expect(await readFile(file, "utf8")).toBe(original); + }, +); + test("recovered admission does not repeat successful managed finalization", async () => { const scanDir = await copyCompletedScan(await temporaryDirectory()); const file = join(scanDir, "findings.json"); diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index e3990154ea..ceeeb17777 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -143,8 +143,18 @@ const cases: { statusReadFault?: boolean; resumeStatusFault?: "transport" | "missing-selection" | "stopped"; cancellationFault?: "status-read" | "deep-state-read" | "cancel-response"; + historicalFindings?: boolean; + changedHistoricalFollowUp?: boolean; }[] = [ ...outcomes.map((outcome) => ({ outcome })), + { outcome: "restart", historicalFindings: true }, + { outcome: "completed", historicalFindings: true }, + { + outcome: "completed", + historicalFindings: true, + changedHistoricalFollowUp: true, + }, + { outcome: "restart-corrupt-selection", historicalFindings: true }, { outcome: "restart", statusReadFault: true }, { outcome: "restart", resumeStatusFault: "transport" }, { outcome: "restart", resumeStatusFault: "missing-selection" }, @@ -218,6 +228,8 @@ for (const { unpricedUsage, statusReadFault, resumeStatusFault, + historicalFindings, + changedHistoricalFollowUp, } of cases) { const resumedStop = outcome.includes("-resumed-"); const restart = outcome.startsWith("restart") || resumedStop; @@ -232,7 +244,7 @@ for (const { const name = outcome === "followup-canceled" ? "SDK preserves a selected aggregate when its follow-up is canceled" - : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${statusReadFault ? " (selection status unavailable)" : ""}${resumeStatusFault ? ` (resume status ${resumeStatusFault})` : ""}${initialResumeUsage ? " (initial resume cost)" : ""}${unpricedUsage ? " (unpriced remainder)" : ""}`; + : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${statusReadFault ? " (selection status unavailable)" : ""}${resumeStatusFault ? ` (resume status ${resumeStatusFault})` : ""}${initialResumeUsage ? " (initial resume cost)" : ""}${unpricedUsage ? " (unpriced remainder)" : ""}${historicalFindings ? " (historical findings)" : ""}${changedHistoricalFollowUp ? " (changed follow-up)" : ""}`; const runCase = async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -557,6 +569,13 @@ for (const { async runStreamed(input: string) { modelInputs.push(input); if (input === followUp) { + if (changedHistoricalFollowUp) { + const file = join(scanDir, "findings.json"); + const document = JSON.parse(await readFile(file, "utf8")); + document.findings[0].root_cause.summary = + "New unaccepted model detail."; + await writeFile(file, JSON.stringify(document)); + } if (canceledFollowUp) { const reportPath = join(scanDir, "report.md"); acceptedReport = await readFile(reportPath, "utf8"); @@ -608,6 +627,21 @@ for (const { source: "local_plugin", candidateId: "archive-entry", }, + ...(historicalFindings + ? { + root_cause: { + summary: + "The original destination is unchecked.", + evidence_refs: ["legacy-proof"], + }, + code_evidence: [ + { + id: "legacy-proof", + code: "write(destination)", + }, + ], + } + : {}), }, ], coverage: { @@ -1211,6 +1245,24 @@ for (const { expect(result.findings.findings[0]?.remediation).toBe( "Validate the resolved destination before writing.", ); + if (historicalFindings) { + const published = JSON.parse( + await readFile(join(scanDir, "findings.json"), "utf8"), + ); + expect(published.findings[0]).toMatchObject({ + root_cause: { + summary: "The original destination is unchecked.", + evidence_refs: ["legacy-proof"], + }, + code_evidence: [{ id: "legacy-proof", code: "write(destination)" }], + }); + expect(await readFile(selectedPath)).toEqual(selectedBytes!); + expect( + warnings.some((warning) => + warning.startsWith("Could not run post-scan instructions:"), + ), + ).toBe(changedHistoricalFollowUp === true); + } // postScanPrompt retains its existing behavior on each caller invocation. expect(modelInputs.filter((input) => input !== followUp).length).toBe(1); expect(modelInputs.filter((input) => input === followUp).length).toBe( diff --git a/sdk/typescript/tests-ts/hosted-scan.test.ts b/sdk/typescript/tests-ts/hosted-scan.test.ts index 763b3b979f..d55c1ed119 100644 --- a/sdk/typescript/tests-ts/hosted-scan.test.ts +++ b/sdk/typescript/tests-ts/hosted-scan.test.ts @@ -937,6 +937,31 @@ test("SDK cancellation waits for the executor receipt without losing known sessi }); }); +test("SDK cancellation retains an unknown executor acceptance receipt", async () => { + const controller = new AbortController(); + let requestId = ""; + const executor = { + run: mock(async (request: ScanExecutionRequest) => { + requestId = request.requestId; + controller.abort(); + await Promise.resolve(); + throw new Error("Synthetic response lost after dispatch"); + }), + }; + const result = await runHostedScan(await fixture(), { + signal: controller.signal, + executor, + }); + expect(executor.run).toHaveBeenCalledTimes(1); + expect(requestId).not.toBe(""); + expect(result.status).toBe("acceptance_unknown"); + expect(result.execution).toEqual({ + requestId, + status: "acceptance_unknown", + message: "Synthetic response lost after dispatch", + }); +}); + test("mismatched execution receipts are never accepted or retried", async () => { const input = await fixture(); const executor = { From 04c32cfda41c30ba400ef0c5f699da0df841b1a2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 22:11:28 +0000 Subject: [PATCH 248/251] fix(deep-scan): preserve authority on repeated preparation --- .../codex-security/scripts/workbench_db.py | 2 +- .../scripts/workbench_saved_results.py | 8 ++- ...test_selected_publication_compatibility.py | 61 +++++++++++++++++-- .../tests-ts/deep-finalization.test.ts | 50 ++++++++++++++- 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 23583edab6..319ed4e056 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1481,7 +1481,7 @@ def complete_scan_locked( if scan["status"] == "complete": deep_scan.find_supported_deep_scan_run(connection, scan_id) return saved_results.refresh_completed_scan( - _WORKBENCH_DB_CONTEXT, connection, scan, cost_json + _WORKBENCH_DB_CONTEXT, connection, scan, cost_json, prepare_only=prepare_only ) if scan["status"] != "running": raise SystemExit("Only a running scan can be completed.") diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 205a4ede51..5b3275b5a9 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -98,12 +98,15 @@ def refresh_completed_scan( connection: sqlite3.Connection, scan: sqlite3.Row, cost_json: str | None, + *, + prepare_only: bool = False, ) -> dict[str, Any]: warnings = json.loads(scan["completion_warnings_json"]) scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) db.require_recorded_manifest_digest(scan, scan_dir) db.verify_manifest_binding(scan, db.read_json_object(scan_dir / db.ARTIFACTS["manifest"])) try: + accepted = prepare_only and has_verified_selected_result(db, connection, scan) manifest, _, coverage = finalize_scan( scan_dir, expected_coverage_mode=db.expected_coverage_mode(scan), @@ -122,7 +125,10 @@ def refresh_completed_scan( "UPDATE scans SET completion_warnings_json = ? WHERE id = ?", (json.dumps(warnings), scan["id"]), ) - return db.completed_scan_context(connection, scan["id"], coverage) + context = db.completed_scan_context(connection, scan["id"], coverage) + if prepare_only: + context["acceptedFindings"] = accepted + return context def validate_sealed_budget_draft( diff --git a/plugins/codex-security/tests/test_selected_publication_compatibility.py b/plugins/codex-security/tests/test_selected_publication_compatibility.py index 5b0925b08f..3af0027ea5 100644 --- a/plugins/codex-security/tests/test_selected_publication_compatibility.py +++ b/plugins/codex-security/tests/test_selected_publication_compatibility.py @@ -107,6 +107,18 @@ def assert_legacy_finding_published(scan, expected): assert "return synthetic_input;" in report +def assert_completed_preparation(api, connection, scan, *, accepted): + files = {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} + state = "\n".join(connection.iterdump()) + completion = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + prepared = api["complete_scan"](connection, completion, prepare_only=True) + assert prepared["acceptedFindings"] is accepted + completed = api["complete_scan"](connection, completion) + assert "acceptedFindings" not in completed + assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} == files + assert "\n".join(connection.iterdump()) == state + + @pytest.mark.parametrize("workflow", ["deep-scan-mcp/v1", "deep-security-scan/v1"]) @pytest.mark.parametrize("capped", [False, True]) def test_legacy_v1_accepted_findings_publish_and_resume_completion( @@ -159,13 +171,49 @@ def interrupted(*args, **kwargs): is None ) sealed = {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} - workbench_api["complete_scan"]( - workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) - ) + assert_completed_preparation(workbench_api, workbench_db, scan, accepted=True) assert {path: path.read_bytes() for path in sealed} == sealed assert accepted.read_bytes() == accepted_bytes +@pytest.mark.parametrize( + "workflow", ["deep-scan-mcp/v1", "deep-security-scan/v1", "deep-security-scan/v2"] +) +@pytest.mark.parametrize("damage", ["accepted-source", "sealed-findings"]) +def test_completed_preparation_rejects_changed_accepted_or_sealed_artifacts( + workbench_api, workbench_db, publication_scan, workflow, damage +): + scan = publication_scan() + legacy_finding(scan) + if workflow.endswith("/v2"): + accepted = selected_publication(workbench_api, workbench_db, scan) + else: + _, accepted, _ = accept_reducer(workbench_db, scan) + with workbench_db: + workbench_db.execute("UPDATE deep_scan_runs SET workflow_version = ?", (workflow,)) + staged = stage_publication( + scan, generation=3, result_path=accepted, title=scan.findings[0]["title"] + ) + workbench_api["saved_results"].write_scan_draft( + workbench_api["_WORKBENCH_DB_CONTEXT"], workbench_db, staged + ) + completion = Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + workbench_api["complete_scan"](workbench_db, completion) + if damage == "accepted-source": + accepted.write_bytes(accepted.read_bytes() + b"\n") + expected_error = "changed after acceptance" + else: + findings = scan.scan_dir / "findings.json" + findings.write_bytes(findings.read_bytes() + b"\n") + expected_error = "sealed artifact changed" + files = {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} + state = "\n".join(workbench_db.iterdump()) + with pytest.raises(SystemExit, match=expected_error): + workbench_api["complete_scan"](workbench_db, completion, prepare_only=True) + assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} == files + assert "\n".join(workbench_db.iterdump()) == state + + def test_legacy_v1_authority_uses_reducer_sequence_instead_of_completion_order( workbench_api, workbench_db, publication_scan ): @@ -318,7 +366,7 @@ def interrupted_write(*args, **kwargs): workbench_api["complete_scan"](workbench_db, completion) assert_legacy_finding_published(scan, expected) sealed = {p: p.read_bytes() for p in scan.scan_dir.rglob("*") if p.is_file()} - workbench_api["complete_scan"](workbench_db, completion) + assert_completed_preparation(workbench_api, workbench_db, scan, accepted=True) assert {p: p.read_bytes() for p in sealed} == sealed assert accepted.read_bytes() == accepted_bytes assert ( @@ -384,6 +432,10 @@ def test_new_producers_still_reject_legacy_finding_fields( prepare_only=True, ) assert prepared["acceptedFindings"] is False + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert_completed_preparation(workbench_api, workbench_db, scan, accepted=False) def test_zero_result_selection_does_not_admit_legacy_findings( @@ -667,6 +719,7 @@ def test_selected_zero_discovery_deadline_preserves_the_published_draft( workbench_api["complete_scan"](workbench_db, completion) assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" sealed = {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} + assert_completed_preparation(workbench_api, workbench_db, scan, accepted=False) deep.finish_deep_scan(workbench_db, finish) workbench_api["complete_scan"](workbench_db, completion) assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*.json")} == sealed diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index ceeeb17777..d3be776106 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -145,9 +145,15 @@ const cases: { cancellationFault?: "status-read" | "deep-state-read" | "cancel-response"; historicalFindings?: boolean; changedHistoricalFollowUp?: boolean; + completeBeforePreparation?: boolean; }[] = [ ...outcomes.map((outcome) => ({ outcome })), { outcome: "restart", historicalFindings: true }, + { + outcome: "restart", + historicalFindings: true, + completeBeforePreparation: true, + }, { outcome: "completed", historicalFindings: true }, { outcome: "completed", @@ -230,6 +236,7 @@ for (const { resumeStatusFault, historicalFindings, changedHistoricalFollowUp, + completeBeforePreparation, } of cases) { const resumedStop = outcome.includes("-resumed-"); const restart = outcome.startsWith("restart") || resumedStop; @@ -244,7 +251,7 @@ for (const { const name = outcome === "followup-canceled" ? "SDK preserves a selected aggregate when its follow-up is canceled" - : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${statusReadFault ? " (selection status unavailable)" : ""}${resumeStatusFault ? ` (resume status ${resumeStatusFault})` : ""}${initialResumeUsage ? " (initial resume cost)" : ""}${unpricedUsage ? " (unpriced remainder)" : ""}${historicalFindings ? " (historical findings)" : ""}${changedHistoricalFollowUp ? " (changed follow-up)" : ""}`; + : `SDK handles selected aggregate: ${outcome}${budgetCompletionFault ? ` (budget completion ${budgetCompletionFault})` : ""}${cancellationFault ? ` (cancellation ${cancellationFault})` : ""}${statusReadFault ? " (selection status unavailable)" : ""}${resumeStatusFault ? ` (resume status ${resumeStatusFault})` : ""}${initialResumeUsage ? " (initial resume cost)" : ""}${unpricedUsage ? " (unpriced remainder)" : ""}${historicalFindings ? " (historical findings)" : ""}${changedHistoricalFollowUp ? " (changed follow-up)" : ""}${completeBeforePreparation ? " (concurrent completion)" : ""}`; const runCase = async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -273,6 +280,8 @@ for (const { let publicationFails = restart && !statusReadFault; let selectionReadLost = false; let resumeStatusInjected = false; + let completedBeforePreparation = false; + let concurrentlyCompletedArtifacts: Buffer[] | undefined; const resumeStatusError = new Error( "Synthetic selected resume status unavailable", ); @@ -396,6 +405,32 @@ for (const { if (args[0] === "get-cli-scan-resume") originalResumeSignal = options.signal; commands.push(args[0]!); + if ( + completeBeforePreparation && + resumedStartup && + !completedBeforePreparation && + args[0] === "prepare-scan-completion" + ) { + // Another resume can complete after registration, before this call acquires its lock. + completedBeforePreparation = true; + await runWorkbench(options, ["complete-scan", ...args.slice(1)]); + const saved = await runWorkbench(options, [ + "get-scan", + "--scan-id", + scanId, + ]); + expect(saved["scan"]).toMatchObject({ + progress: { status: "complete" }, + }); + concurrentlyCompletedArtifacts = await Promise.all( + [ + "scan-manifest.json", + "findings.json", + "coverage.json", + "report.md", + ].map((name) => readFile(join(scanDir, name))), + ); + } if ( args[0] === "get-deep-scan" && resumedStartup && @@ -1218,6 +1253,19 @@ for (const { ...(restart ? { resumeScanId: scanId, outputDir: scanDir } : {}), }); expect(result.threadId).toBe(threadId); + if (completeBeforePreparation) { + expect(completedBeforePreparation).toBe(true); + expect( + await Promise.all( + [ + "scan-manifest.json", + "findings.json", + "coverage.json", + "report.md", + ].map((name) => readFile(join(scanDir, name))), + ), + ).toEqual(concurrentlyCompletedArtifacts!); + } if (recoveryBudget) expect(commands).toContain("complete-budget-exhausted-scan"); if (outcome === "restart-changed-target") From c27c3c57e5ca7ab290276da5581f1eeb43329299 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 22:47:05 +0000 Subject: [PATCH 249/251] fix(deep-scan): align helper help and migration fixtures --- plugins/codex-security/scripts/workbench_source_coverage.py | 5 +++++ .../codex-security/tests/test_deep_scan_recovery_settings.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/scripts/workbench_source_coverage.py b/plugins/codex-security/scripts/workbench_source_coverage.py index a80d581320..35fe77464c 100644 --- a/plugins/codex-security/scripts/workbench_source_coverage.py +++ b/plugins/codex-security/scripts/workbench_source_coverage.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import copy import sqlite3 from pathlib import Path @@ -97,3 +98,7 @@ def retain(field: str, item: dict[str, Any]) -> None: "reason": "This accepted discovery was not merged before the scan reached its cost limit.", }, ) + + +if __name__ == "__main__": + argparse.ArgumentParser(description=__doc__).parse_args() diff --git a/plugins/codex-security/tests/test_deep_scan_recovery_settings.py b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py index 93ace39d82..a1fdff5470 100644 --- a/plugins/codex-security/tests/test_deep_scan_recovery_settings.py +++ b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py @@ -105,7 +105,8 @@ def test_supported_old_run_snapshots_context_on_upgrade( )["deepScan"] with sqlite3.connect(state / "workbench.sqlite3") as connection: connection.execute("ALTER TABLE deep_scan_runs DROP COLUMN discovery_user_context") - connection.execute("DELETE FROM schema_migrations WHERE version = 48") + connection.execute("UPDATE deep_scan_runs SET discovery_user_context_json = NULL") + connection.execute("DELETE FROM schema_migrations WHERE version = 50") upgraded = run_workbench( state, "get-deep-scan", From ad00cd59bde0434c985b1ef4e498d3fbdb252653 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 20 Oct 2026 18:04:56 +0000 Subject: [PATCH 250/251] Align recovery tests with merged usage and source contracts --- .../tests/test_budget_selection_publication.py | 10 +++++++++- .../tests/test_stopped_source_coverage.py | 6 +++++- .../tests/test_workbench_saved_source_order.py | 5 +++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/plugins/codex-security/tests/test_budget_selection_publication.py b/plugins/codex-security/tests/test_budget_selection_publication.py index 0f5175ed2d..8176053243 100644 --- a/plugins/codex-security/tests/test_budget_selection_publication.py +++ b/plugins/codex-security/tests/test_budget_selection_publication.py @@ -158,7 +158,15 @@ def test_cost_before_selection_retains_only_merged_findings( assert "cost" not in stored and "estimatedUsd" not in stored assert stored["usage"]["coverage"] == "unavailable" else: - assert stored == BUDGET_COST + assert stored == { + "cost": BUDGET_COST, + "usage": { + "coverage": "unavailable", + "source": "codex_rollout", + "threadCount": 0, + "warnings": ["codex_state_unavailable"], + }, + } assert run["terminal_reason"] == "capped" assert run["error_message"] == args.message assert args.message in json.loads(row["completion_warnings_json"]) diff --git a/plugins/codex-security/tests/test_stopped_source_coverage.py b/plugins/codex-security/tests/test_stopped_source_coverage.py index e09a2c4f3d..4850f07a51 100644 --- a/plugins/codex-security/tests/test_stopped_source_coverage.py +++ b/plugins/codex-security/tests/test_stopped_source_coverage.py @@ -167,4 +167,8 @@ def test_stopped_recovery_keeps_unmerged_coverage_after_accepted_review( coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) assert "completeness" not in coverage assert coverage["reviews"] == reviews - assert deferred in coverage["deferred"] + assert { + **deferred, + "id": f"{pending.parent.name}/{deferred['id']}", + "workerId": pending.parent.name, + } in coverage["deferred"] diff --git a/plugins/codex-security/tests/test_workbench_saved_source_order.py b/plugins/codex-security/tests/test_workbench_saved_source_order.py index 719f296181..e4757095ac 100644 --- a/plugins/codex-security/tests/test_workbench_saved_source_order.py +++ b/plugins/codex-security/tests/test_workbench_saved_source_order.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) import workbench_db import workbench_saved_results as saved +from workbench import saved_sources def call_workbench(monkeypatch, state, codex_home, *args): @@ -105,7 +106,7 @@ def test_head_capture_includes_checkpoint_published_after_enumeration( result.write_bytes(result.read_bytes()) os.utime(result, ns=(300, 300)) directory = (output / "checkpoints").relative_to(scan_dir).as_posix() - children = saved._children + children = saved_sources._children published = [] def publish_after_listing(root, relative): @@ -117,7 +118,7 @@ def publish_after_listing(root, relative): published.append(checkpoint) return names - monkeypatch.setattr(saved, "_children", publish_after_listing) + monkeypatch.setattr(saved_sources, "_children", publish_after_listing) call_workbench( monkeypatch, state, From 3312be7b196971e3aaba8b94a28026d05834692d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 20 Oct 2026 19:07:52 +0000 Subject: [PATCH 251/251] fix(deep-scan): preserve recovery and usage contracts --- .../src/deep-scan/artifact-validation.ts | 110 +++++++++++------- .../mcp-app/src/deep-scan/coordinator.ts | 1 + .../test_deep_scan_artifact_validation.ts | 2 +- .../test_deep_scan_finite_publication.ts | 17 +++ .../tests/test_deep_scan_host_shutdown.ts | 27 ++++- .../tests/test_deep_scan_store_integration.ts | 17 +++ .../scripts/workbench/saved_coverage.py | 58 +++++++++ .../scripts/workbench_saved_results.py | 35 ++++-- sdk/typescript/src/api.ts | 5 +- .../tests-ts/direct-deep-scan.test.ts | 18 ++- 10 files changed, 221 insertions(+), 69 deletions(-) diff --git a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts index 2965f774cd..cf927ff04f 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/artifact-validation.ts @@ -69,10 +69,31 @@ export function deepReductionForPersistence( return legacy; } +function workerCoverageIds( + rows: readonly JsonObject[], + workerId: string, +): string[] { + const usedIds = new Set( + rows.flatMap((row) => (typeof row.id === "string" ? [row.id] : [])), + ); + const emittedIds = new Set(); + return rows.map((row, index) => { + const baseId = typeof row.id === "string" ? row.id : String(index); + let id = baseId; + if (typeof row.id !== "string" || emittedIds.has(id)) { + for (let suffix = 2; usedIds.has(id); suffix++) + id = `${baseId}-${suffix}`; + } + usedIds.add(id); + emittedIds.add(id); + return `${workerId}/${id}`; + }); +} + /** Reduction combines findings; it does not complete the source workers' review. */ export function aggregateDiscoveryCoverage( artifacts: DeepScanArtifacts, - discoveries: Pick[], + discoveries: Pick[], ): ScanDraftInput["coverage"] { const coverage: ScanDraftInput["coverage"] = { surfaces: [], @@ -81,7 +102,7 @@ export function aggregateDiscoveryCoverage( }; for (const worker of discoveries) { const source = structuredClone(worker.result.coverage); - const prefix = relative(artifacts.scanDir, dirname(worker.resultPath)) + const prefix = relative(artifacts.scanDir, worker.artifactDir) .split(sep) .join("/"); const scopedId = (id: string) => `${worker.id}/${id}`; @@ -91,24 +112,12 @@ export function aggregateDiscoveryCoverage( "deferred", ] as const) { const rows = source[field] as JsonObject[]; - const usedIds = new Set( - rows.flatMap((row) => (typeof row.id === "string" ? [row.id] : [])), - ); - const emittedIds = new Set(); + const ids = workerCoverageIds(rows, worker.id); for (const [index, row] of rows.entries()) { const retained: JsonObject = { ...row, workerId: worker.id }; // Independent reviews reuse local IDs. Preserve their links without // allowing one worker's disposition to stand in for another's work. - const baseId = typeof row.id === "string" ? row.id : String(index); - let id = baseId; - if (typeof row.id !== "string" || emittedIds.has(id)) { - for (let suffix = 2; usedIds.has(id); suffix++) { - id = `${baseId}-${suffix}`; - } - } - usedIds.add(id); - emittedIds.add(id); - retained.id = scopedId(id); + retained.id = ids[index]; if (field === "deferred" && Array.isArray(row.surfaceIds)) { retained.surfaceIds = (row.surfaceIds as string[]).map(scopedId); } @@ -171,6 +180,7 @@ export async function validateDiscoveryArtifacts( expectedScanId: string, validateWriteups = true, accepted = false, + artifactDir = dirname(resultPath), ): Promise { const result = await readDiscoveryAuditDraft( artifacts, @@ -178,13 +188,35 @@ export async function validateDiscoveryArtifacts( expectedScanId, validateWriteups, accepted, + artifactDir, ); if (result.complete === false) throw new Error( "Standard scan worker wrote only a checkpoint; its audit is not complete.", ); + return result; +} + +export async function readDiscoveryAuditDraft( + artifacts: DeepScanArtifacts, + resultPath: string, + expectedScanId: string, + validateWriteups = true, + accepted = false, + artifactDir = dirname(resultPath), +): Promise { + await requireRegularFile(resultPath, artifacts.workersRoot); + const result = parseStoredScanDraft( + await readJsonObject(resultPath), + "Standard scan worker", + expectedScanId, + accepted ? parseAcceptedDiscoveryDraft : parsePersistedScanDraft, + ); + // Unfinished drafts remain resumable; complete drafts must retain their evidence. + if (result.complete === false) return result; + if (validateWriteups) await validateWriteupFiles(artifacts, result); validateRetainedCoverage(result.coverage); - const root = dirname(resultPath); + const root = artifactDir; const receiptPaths = new Map(); for (const row of result.coverage.surfaces as JsonObject[]) { for (const ref of (row.receiptRefs as string[] | undefined) ?? []) { @@ -207,25 +239,6 @@ export async function validateDiscoveryArtifacts( return result; } -export async function readDiscoveryAuditDraft( - artifacts: DeepScanArtifacts, - resultPath: string, - expectedScanId: string, - validateWriteups = true, - accepted = false, -): Promise { - await requireRegularFile(resultPath, artifacts.workersRoot); - const result = parseStoredScanDraft( - await readJsonObject(resultPath), - "Standard scan worker", - expectedScanId, - accepted ? parseAcceptedDiscoveryDraft : parsePersistedScanDraft, - ); - if (result.complete !== false && validateWriteups) - await validateWriteupFiles(artifacts, result); - return result; -} - async function validateWriteupFiles( artifacts: DeepScanArtifacts, result: Pick, @@ -460,14 +473,18 @@ export function projectDiscoveryCoverage( workerId: worker.id, ...(worker.attempt === undefined ? {} : { attempt: worker.attempt }), }; - const scopedId = (id: string) => `${worker.id}/${id}`; const surfaces = coverage.surfaces; - const surfaceIds = new Map( - surfaces.map((surface, index) => [ - surface.id, - scopedId(surface.id ?? String(index)), - ]), + const projectedSurfaceIds = workerCoverageIds(surfaces, worker.id); + const exclusionIds = workerCoverageIds( + coverage.explicitExclusions, + worker.id, ); + const deferredIds = workerCoverageIds(coverage.deferred, worker.id); + const surfaceIds = new Map(); + for (const [index, surface] of surfaces.entries()) { + if (surface.id !== undefined && !surfaceIds.has(surface.id)) + surfaceIds.set(surface.id, projectedSurfaceIds[index]); + } const project = >(item: Item) => { const projected = structuredClone(item); const sourceProvenance = @@ -496,7 +513,7 @@ export function projectDiscoveryCoverage( reviews: [{ ...provenance }], surfaces: surfaces.map((surface, index) => ({ ...project(surface), - id: scopedId(surface.id ?? String(index)), + id: projectedSurfaceIds[index], receiptRefs: ((surface.receiptRefs as string[] | undefined) ?? []).map( (ref) => ref.startsWith(archivePrefix) || ref.startsWith(`${artifactPrefix}/`) @@ -504,10 +521,13 @@ export function projectDiscoveryCoverage( : `${artifactPrefix}/${ref}`, ), })), - explicitExclusions: coverage.explicitExclusions.map(project), + explicitExclusions: coverage.explicitExclusions.map((item, index) => ({ + ...project(item), + id: exclusionIds[index], + })), deferred: coverage.deferred.map((item, index) => ({ ...project(item), - id: scopedId(typeof item.id === "string" ? item.id : String(index)), + id: deferredIds[index], ...(item.candidateId === undefined ? {} : { diff --git a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts index d7c31056ce..9ff60f5014 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -1270,6 +1270,7 @@ export class DeepScanCoordinator { this.state.scanId, this.state.workflowVersion === "deep-security-scan/v2", true, + worker.artifactDir, ); recovered.push({ id: worker.id, diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts index 9625d70ab8..85e1e63ec9 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_artifact_validation.ts @@ -1479,7 +1479,7 @@ async function createWorker(scanDir: string, id: string, result: unknown) { await mkdir(output, { recursive: true }); const resultPath = path.join(output, "result.json"); await writeResult(resultPath, result); - return { artifacts, id, resultPath }; + return { artifacts, id, artifactDir: output, resultPath }; } async function testSharedMissingReceiptRepairHistory(root: string) { diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_finite_publication.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_finite_publication.ts index b296f26d04..fae9184377 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_finite_publication.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_finite_publication.ts @@ -18,6 +18,9 @@ import { finding, workerDraft } from "./scan-draft-fixture.ts"; const { startDeepScanEngine } = await importSource("src/deep-scan/engine.ts", { loader: { ".md": "text" }, + banner: { + js: `import { createRequire as fixtureRequire } from "node:module"; const require = fixtureRequire(${JSON.stringify(import.meta.url)});`, + }, define: { "import.meta.url": JSON.stringify( new URL("../src/deep-scan/engine.ts", import.meta.url).href, @@ -31,6 +34,13 @@ after(() => temporaryDirectories.cleanup()); test("Deep engine preserves stopped results for an option-shaped thread ID", async () => { const fixture = await fixtureRun(); + fixture.run.executionSettings = { + version: 1, + settings: { + codexPath: process.execPath, + codexHome: join(fixture.pluginRoot, "codex-home"), + }, + }; const store = new FakeStore(fixture.run); store.run.coordinatorGeneration = 7; const handoffClaimToken = randomUUID(); @@ -76,6 +86,13 @@ test("Deep engine preserves stopped results for an option-shaped thread ID", asy for (const route of ["mcp", "file"] as const) { test(`Deep engine publishes historical metadata inherited by a ${route} reducer`, async () => { const fixture = await fixtureRun({ maxDiscoveryRuns: 1 }); + fixture.run.executionSettings = { + version: 1, + settings: { + codexPath: process.execPath, + codexHome: join(fixture.pluginRoot, "codex-home"), + }, + }; const store = new FakeStore(fixture.run); const registry = new DeepScanCoordinatorRegistry(); const run = store.run; diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_host_shutdown.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_host_shutdown.ts index 25ea53ebd0..be01c9ef02 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_host_shutdown.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_host_shutdown.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { mock, test } from "node:test"; import { fileURLToPath } from "node:url"; +import type { DeepScanExecutionSettings } from "../src/deep-scan/recovery-settings.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { importModule } from "./import-module.ts"; @@ -47,12 +49,17 @@ const { createCodexSecurityServer } = await importModule({ name: "synthetic-worker", setup(build) { build.onLoad({ filter: /[/\\]deep-scan[/\\]executor\.ts$/ }, () => ({ - contents: `export class CodexSdkWorkerExecutor { constructor() { return ${fixtureAccess}.executor; } }`, + contents: `export class CodexSdkWorkerExecutor { constructor() { return ${fixtureAccess}.executor; } } + export const resolveCodexPath = () => process.execPath; + export const workerRuntimeSettingsFromConfig = () => ({});`, loader: "ts", })); }, }, ], + banner: { + js: `import { createRequire as fixtureRequire } from "node:module"; const require = fixtureRequire(${JSON.stringify(import.meta.url)});`, + }, define: { __dirname: JSON.stringify(applicationRoot), "import.meta.url": JSON.stringify( @@ -77,6 +84,12 @@ try { const signals: AbortSignal[] = []; let claims = 0; let now = 0; + const originalEnvironment = process.env; + process.env = { + ...process.env, + CODEX_HOME: join(fixture.pluginRoot, "codex-home"), + }; + delete process.env.CODEX_SECURITY_CONFIG_PATH; const hold = async () => { entered.resolve(); await release.promise; @@ -86,7 +99,16 @@ try { registry, executor, store: Object.assign(store, { - begin: async () => { + begin: async ({ + executionSettings, + }: { + executionSettings?: DeepScanExecutionSettings | null; + }) => { + assert.ok(executionSettings); + store.run.executionSettings ??= { + version: 1, + settings: executionSettings, + }; if (stage === "begin" || stage === "queued") await hold(); return structuredClone(store.run); }, @@ -205,6 +227,7 @@ try { await Promise.allSettled([coordinator?.settled()]); await server.close(); clock.mock.restore(); + process.env = originalEnvironment; Reflect.deleteProperty(globalThis, fixtureKey); } }); diff --git a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts index 4dc5f9cee0..87f4ffac05 100644 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts +++ b/plugins/codex-security/mcp-app/tests/test_deep_scan_store_integration.ts @@ -590,6 +590,11 @@ async function testWorkerRetryThreadsRemainInScanUsage() { try { const threadId = "retry-owner"; const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, threadId, scanRoot: path.join(fixtureRoot, "scans"), @@ -1863,6 +1868,11 @@ async function testReceiptRepairSurvivesStoppedRecovery( "[deep_scan]\nworkers = 1\nmax_discovery_runs = 1\n", ); const run = await store.begin({ + executionSettings: await captureDeepScanExecutionSettings( + {}, + { filesystemDenies: [] }, + environment, + ), targetPath, threadId, scanRoot: path.join(fixtureRoot, "scans"), @@ -2097,12 +2107,19 @@ async function testReceiptRepairSurvivesStoppedRecovery( id: string; disposition: string; receiptRefs: string[]; + provenance?: unknown; }[]; assert.equal(surfaces.length, crossSurface ? 2 : 1); assert.deepEqual( surfaces.map((row) => row.id).sort(), before.surfaces.map((row: { id: string }) => row.id).sort(), ); + for (const surface of surfaces) { + const original = before.surfaces.find( + (row: { id: string }) => row.id === surface.id, + ); + assert.deepEqual(surface.provenance, original.provenance); + } assert.ok(surfaces.every((row) => row.disposition === "no_issue_found")); assert.equal( recovered.deferred.some((row: { id: string }) => diff --git a/plugins/codex-security/scripts/workbench/saved_coverage.py b/plugins/codex-security/scripts/workbench/saved_coverage.py index d4a439ee97..1059283a90 100644 --- a/plugins/codex-security/scripts/workbench/saved_coverage.py +++ b/plugins/codex-security/scripts/workbench/saved_coverage.py @@ -98,6 +98,7 @@ def _prepare_worker_coverage_recovery( source_order: dict[str, tuple[int, int]], *, enabled: bool, + preserve_workers: set[str] | None = None, ) -> tuple[ bool, list[tuple[dict[str, Any], str, dict[str, Any], dict[str, Any], dict[str, Any] | None]] ]: @@ -173,6 +174,10 @@ def _prepare_worker_coverage_recovery( row["receiptRefs"] = refs if field == "surfaces": row.setdefault("receiptRefs", []) + # Accepted reviews keep their host projection while historical + # worker paths remain available for receipt repair. + if preserve_workers and owner in preserve_workers: + continue aggregate_rows = _aggregate_worker_coverage_rows(rows, owner, field) projected[field].extend(aggregate_rows) if field in {"surfaces", "deferred"}: @@ -232,3 +237,56 @@ def _prepare_worker_coverage_recovery( if _coverage_comparison_row(row, field) not in projected[field] ] return True, surface_links + + +def _merge_reviewed_surface_receipts( + coverage: dict[str, Any], + worker_row_owners: dict[int, str], + reviewed_workers: set[str], + reviewed_attempts: set[tuple[Any, Any]], +) -> None: + # Receipt recovery may run before the accepted reducer copy is emitted. + # Combine its repaired evidence after every source has been accumulated. + surfaces = coverage.get("surfaces", []) + merged_surfaces: set[int] = set() + for retained in surfaces if isinstance(surfaces, list) else []: + if not isinstance(retained, dict): + continue + owner = worker_row_owners.get(id(retained)) + if owner not in reviewed_workers: + continue + for existing in surfaces: + provenance = existing.get("provenance") if isinstance(existing, dict) else None + if not ( + id(existing) not in worker_row_owners + and isinstance(provenance, dict) + and provenance.get("workerId") == owner + and (owner, provenance.get("attempt")) in reviewed_attempts + and isinstance(provenance.get("sourceId"), str) + and retained.get("id") == f"{owner}/{provenance['sourceId']}" + and isinstance(existing.get("receiptRefs"), list) + and isinstance(retained.get("receiptRefs"), list) + ): + continue + source_provenance = retained.get("provenance") + projected_provenance = ( + dict(source_provenance) if isinstance(source_provenance, dict) else {} + ) + for key in ("workerId", "attempt", "sourceId", "candidateId"): + projected_provenance.pop(key, None) + if key in provenance: + projected_provenance[key] = provenance[key] + projected = { + **retained, + "id": existing.get("id"), + "provenance": projected_provenance, + "receiptRefs": existing["receiptRefs"], + } + if projected == existing: + for ref in retained["receiptRefs"]: + if ref not in existing["receiptRefs"]: + existing["receiptRefs"].append(ref) + merged_surfaces.add(id(retained)) + break + if merged_surfaces: + coverage["surfaces"] = [row for row in surfaces if id(row) not in merged_surfaces] diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 9223c4f94d..927e07c89e 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -45,6 +45,7 @@ write_threat_model_projection_if_possible, ) from workbench.saved_coverage import ( + _merge_reviewed_surface_receipts, _prepare_worker_coverage_recovery, _project_worker_coverage_row, _saved_coverage_id, @@ -1007,7 +1008,14 @@ def checkpoints(directory: str, worker_id: str | None, attempt: int = 0) -> None for review in accepted_coverage.get("reviews", []) if isinstance(review, dict) } - workers_by_id = {worker["id"]: worker for worker in workers} + reviewed_workers = { + worker["id"] + for worker in workers + if {"status", "merge_state", "attempt"}.issubset(worker.keys()) + and worker["status"] == "succeeded" + and worker["merge_state"] == "merged" + and (worker["id"], worker["attempt"]) in reviewed_attempts + } latest_reducer_key = ( (reducer["completed_at"] or "", reducer["id"], int(reducer["attempt"] or 0)) if reducer is not None and latest_reducer in drafts_by_path @@ -1045,7 +1053,11 @@ def checkpoints(directory: str, worker_id: str | None, attempt: int = 0) -> None parent = drafts_by_path[latest_reducer] project_worker_coverage, surface_links = _prepare_worker_coverage_recovery( - sources, parent, source_order, enabled=project_worker_coverage + sources, + parent, + source_order, + enabled=project_worker_coverage, + preserve_workers=reviewed_workers, ) # Keep the actual retained row even when recovery adds previous findings. linked_surface_sources = {id(surface) for _, _, surface, _, _ in surface_links} @@ -1754,6 +1766,8 @@ def coverage_row_is_retained( # Reopened work survives a superseded checkpoint, but current candidate # outcomes still apply. Parent closures cannot remove another worker's row. for owner, item in reopened_rows: + if owner in reviewed_workers: + continue if (identity := _deferred_candidate_id(item, owner, ambiguous_deferred)) is not None and ( owner, identity, @@ -1766,7 +1780,8 @@ def coverage_row_is_retained( surfaces = draft["coverage"].get("surfaces", []) for surface in surfaces if isinstance(surfaces, list) else []: if ( - isinstance(surface, dict) + owner not in reviewed_workers + and isinstance(surface, dict) and isinstance(surface.get("id"), str) and (owner, surface["id"]) in ambiguous_surface_ids and surface.get("disposition") == "needs_follow_up" @@ -1961,15 +1976,7 @@ def coverage_row_is_retained( continue finding_positions[key] = len(findings) findings.append(finding) - worker = workers_by_id.get(worker_id) - reviewed = ( - worker is not None - and {"status", "merge_state", "attempt"}.issubset(worker.keys()) - and worker["status"] == "succeeded" - and worker["merge_state"] == "merged" - and (worker_id, worker["attempt"]) in reviewed_attempts - ) - if reviewed or ( + if worker_id in reviewed_workers or ( superseded and relative != accepted_reducer and not selected_candidates @@ -2053,6 +2060,10 @@ def coverage_row_is_retained( if field == "surfaces" and id(source_item) in linked_surface_sources: linked_surface_outputs[id(source_item)] = retained + _merge_reviewed_surface_receipts( + coverage, worker_row_owners, reviewed_workers, reviewed_attempts + ) + identities: dict[str, str] = {} for finding in findings: if not valid_finding(finding): diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index db9ab77835..f2e5f99a93 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -4972,9 +4972,8 @@ export async function runScanEvents( completedTurn !== undefined && !managedFinalized ) { - completedTurn.usage = - (await options.onFinalize(completedTurn.usage)) ?? - completedTurn.usage; + const finalizedUsage = await options.onFinalize(completedTurn.usage); + if (finalizedUsage !== undefined) completedTurn.usage = finalizedUsage; managedFinalized = true; } options.signal.throwIfAborted(); diff --git a/sdk/typescript/tests-ts/direct-deep-scan.test.ts b/sdk/typescript/tests-ts/direct-deep-scan.test.ts index 93a2b243cc..4f63bb7b10 100644 --- a/sdk/typescript/tests-ts/direct-deep-scan.test.ts +++ b/sdk/typescript/tests-ts/direct-deep-scan.test.ts @@ -272,14 +272,15 @@ test("preserves complete worker usage and cost after direct engine completion", scanDirectory: scanDir, model: "gpt-6.1-sol", }); + const executionThreadIds = [ + "engine-session", + "discovery-session", + "reducer-session", + ]; tracker.setAttributionReader(async () => ({ formatVersion: 1, workerCodexHome: codexHome, - executionThreadIds: [ - "engine-session", - "discovery-session", - "reducer-session", - ], + executionThreadIds, owner: { threadId: "engine-session", turnId: null, @@ -292,6 +293,7 @@ test("preserves complete worker usage and cost after direct engine completion", tracker.recordUsage(DIRECT_DEEP_SCAN_CONTROL_USAGE, "engine-session"); tracker.start("engine-session"); const running = await tracker.refresh(); + assert.ok(running.usage && typeof running.usage === "object"); await writeSession( codexHome, "waiting-session", @@ -300,7 +302,11 @@ test("preserves complete worker usage and cost after direct engine completion", parent: "engine-session", }, ); - assert.deepEqual(await tracker.refresh(), { usage: null, cost: null }); + executionThreadIds.push("waiting-session"); + assert.deepEqual(await tracker.refresh(), { + usage: { ...running.usage, coverage: "partial" }, + cost: { ...running.cost, coverage: "partial" }, + }); tracker.recordUsage( { input_tokens: 0,