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/133] 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 905c0c9a8..b3eff3a04 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 82f119979..51738be8f 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/133] 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 98b4f00d1..b98b052e3 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 8a144ece4..0c7a3ce75 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 e88dcb63a..47495d7d5 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/133] 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 574ff2c53..f7318fe38 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 8bf6b0bf7..ed5094e27 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 dea2d9431..612ec4c4d 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 000000000..0ce73fbd8 --- /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 09eb1f884..afb3d5712 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 853094a0a..89e5e9922 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 000000000..2a26d2450 --- /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 1df7f704a..abe6edcbb 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/133] 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 89e5e9922..42859ff2c 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 000000000..bd9fe1392 --- /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/133] 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 648924d92..e3296d6c8 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 06b7ea425..ccbb6e072 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/133] 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 a32b96513..51a89f6dc 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 000000000..e074a9203 --- /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/133] 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 ed5094e27..71391f849 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 612ec4c4d..b284f6808 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 afb3d5712..a6b86f374 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 42859ff2c..4bbc0788a 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 66baa2b4f..37758b135 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 2a26d2450..faf97dc05 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 000000000..ba6136125 --- /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 f68ccdef9..a9196ec69 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 679ee77c1..a0c79f055 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/133] 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 000000000..01ca05699 --- /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/133] 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 161756ee0..b53a506ec 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 0c7a3ce75..b56839a32 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/133] 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 000000000..0fed15b23 --- /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 000000000..26b6a6163 --- /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 000000000..2b8709773 --- /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/133] 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 c58a274e2..2da2ca020 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/133] 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 a21fbb627..2c35f3c7d 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 4c560822c..125a02ce9 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 b3eff3a04..ffa641d97 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 72f815fc3..aa5859ad2 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 01c37dbaa..167e5cffe 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 a93384919..589f7874f 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 51a89f6dc..e7fdc2847 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 000000000..b4ff3a0bd --- /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 000000000..af7a629be --- /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/133] 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 5e44d1796..4221c95ee 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 167e5cffe..01c37dbaa 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 b56839a32..b9c9c9e14 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 2da2ca020..bc8de8058 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/133] 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 f7318fe38..f8127c2f2 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 71391f849..5a71591ed 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 b284f6808..52d427159 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 0ce73fbd8..3263052fa 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 a6b86f374..6b42bf36f 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 4bbc0788a..5d1970a24 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 37758b135..1c9fc88e7 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 000000000..2c409463e --- /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 a9196ec69..2f3ad542b 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 a0c79f055..09d969b7d 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/133] 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 788745ae4..6aff6aa42 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 76112891d..0955f3eb8 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 425e486c4..8540ec881 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 000000000..0112681ed --- /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/133] 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 6aff6aa42..1f3147c17 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 0955f3eb8..946e362b9 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 68fa92e1a..f2ee81ecc 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/133] 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 e2a0fd3cd..7d8ca664a 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/133] 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 f2ee81ecc..227d13709 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/133] 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 5d1970a24..bbd8fd16a 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 1c9fc88e7..e3ce1647c 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 faf97dc05..b9305b87a 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 2f3ad542b..5ad21c2c8 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 09d969b7d..6a793f66a 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/133] 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 000000000..4d645dfb9 --- /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 000000000..248d6031b --- /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 000000000..e59440558 --- /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 000000000..ceb6156f2 --- /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 60945f75d..39f834a5d 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/133] 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 f78271374..8fd657e58 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/133] 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 e3ce1647c..9c69dcbfc 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 5ad21c2c8..697bb0e36 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 6a793f66a..f551a3964 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/133] 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 e7fdc2847..9fdf30101 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 e074a9203..a079f280c 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/133] 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 9fdf30101..73d21ba22 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 000000000..57b8e6534 --- /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/133] 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 2c35f3c7d..de40ad38c 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 000000000..e232bd670 --- /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 000000000..439618f3b --- /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 7d8ca664a..8067869a7 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 8540ec881..f2a39f7bc 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 000000000..5c1c59cf9 --- /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/133] 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 b8e147ed9..ec1d7a77f 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 3208f6d8a..a296e4046 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 291557a9e..fe1687fbd 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 ffa641d97..3ecac4ced 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 51738be8f..14ba7938d 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 000000000..f8c3fa761 --- /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 4221c95ee..e0a12fd2e 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 5466999cc..035a499f9 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 b98b052e3..cc404e5f8 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 000000000..5ddcb502b --- /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/133] 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 ec1d7a77f..7e1604e8e 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 3ecac4ced..07fce30f1 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 14ba7938d..c977fa79d 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 035a499f9..d2e424c32 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/133] 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 f8c3fa761..7da03f017 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 5ddcb502b..64fc85603 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/133] 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 07fce30f1..7a20c4077 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 c977fa79d..0a60a174b 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 7da03f017..dca951ff9 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 64fc85603..4d4996bad 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/133] 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 7e1604e8e..06d0efa7f 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 a296e4046..0a4299467 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 fe1687fbd..4daaafc99 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 0a60a174b..cafef6744 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 dca951ff9..e5bab7e47 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 e0a12fd2e..ede7a814a 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 d2e424c32..c9cb7533d 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 946e362b9..896a136a8 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/133] 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 896a136a8..4ca19fc54 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 8fd657e58..f58c83247 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/133] 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 1f3147c17..51db8dedd 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 f2a39f7bc..9a46638e0 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 0112681ed..630df3625 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/133] 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 2b0bd4391..74ad7e755 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 4ca19fc54..7aae76435 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/133] 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 7aae76435..9e9958a14 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/133] 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 4daaafc99..099add31b 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 cafef6744..36a39effd 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 01ca05699..138fd0176 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 000000000..6d540361f --- /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 9a46638e0..5d0f2e843 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/133] 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 f58c83247..12ce6dffb 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/133] 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 e232bd670..4fea69243 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 439618f3b..5b1864714 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/133] 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 c23adfb4d..a2a74d36d 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/133] 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 de40ad38c..ea0d56917 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 4fea69243..555425066 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 5b1864714..7e242d990 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/133] 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 06d0efa7f..7749d0ceb 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 0a4299467..66a7e8f38 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 7a20c4077..e14f11df9 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 36a39effd..cac40ad80 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 e5bab7e47..f6c164b5b 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 000000000..1df47d525 --- /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 9e9958a14..a51a053be 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/133] 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 f6c164b5b..e88449336 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 a51a053be..ac7db4fff 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 000000000..71ea1af4f --- /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/133] 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 18c8611be..db7c7e963 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 94022da4f..6b15baa7a 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 ea0d56917..51e77e4f2 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 e14f11df9..864131c2d 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 000000000..8e2fb6f4b --- /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 f8127c2f2..3bbac0a2a 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 5a71591ed..a9f87734c 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 52d427159..34af68ff6 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 ede7a814a..9afb20ddf 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 01c37dbaa..199685cd0 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 000000000..8d725ef84 --- /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 000000000..f982f021f --- /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 000000000..4f9300233 --- /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 589f7874f..430e71600 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 bbd8fd16a..5f035154c 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 b53a506ec..64b560144 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 73d21ba22..0e4aa1b87 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/133] 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 cac40ad80..96e372760 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 138fd0176..8d1e108d1 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 6d540361f..a5a679bea 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 5d0f2e843..dde6431fa 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 000000000..e18bf9f58 --- /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 28d9b1212..2f7d79c73 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 000000000..890c9857d --- /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 000000000..0a7759b21 --- /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/133] 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 ac7db4fff..c3b8cf0dd 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 12ce6dffb..a751d59de 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/133] 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 dde6431fa..0688f683c 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 890c9857d..b4b35c656 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 0a7759b21..5b741021c 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/133] 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 bd86a0fc3..98d72cbd0 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 0688f683c..edcdf27a9 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 f24dcd4a2..fe2370682 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 2263f136f..28c13a9ab 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 851a8f2da..dbe33be22 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 676f7a10b..47b4c9091 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 a70986bb2..d2cf81023 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 000000000..6265ebf0b --- /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/133] 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 64b560144..d48a83ed8 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 e3296d6c8..68cdaea92 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 a65aff1e7..f4f68f5f4 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 ccbb6e072..4b95d3b7c 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/133] 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 a9f87734c..4246962f7 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 34af68ff6..9e0a22fe8 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 96e372760..6bf41818a 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 000000000..2356fe7ac --- /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 199685cd0..3625954d9 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 6b42bf36f..d56bee20a 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 5f035154c..306057099 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 bd9fe1392..0f2f0e67e 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 bc8de8058..9d598c4bb 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 4b95d3b7c..b8fdb3aa1 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/133] 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 68cdaea92..b98168e2f 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 000000000..9b5cb237c --- /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 b8fdb3aa1..de46cd405 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/133] 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 d48a83ed8..2ded9cd58 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 b98168e2f..1fb1d7e0a 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/133] 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 0e4aa1b87..c5d761a9e 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 000000000..457185553 --- /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/133] 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 c5d761a9e..088147d40 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 000000000..e7d12d871 --- /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/133] 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 864131c2d..1e98eff65 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 2356fe7ac..7eda3581e 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/133] 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 edcdf27a9..2833406ac 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 b4b35c656..b26efdfe7 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/133] 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 af7a629be..e7c361569 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 000000000..46b4a3797 --- /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 457185553..b3bd24ea2 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/133] 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 000000000..40b7e91b5 --- /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/133] 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 46b4a3797..d1f4c8323 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/133] 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 6bf41818a..98266396e 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 7eda3581e..0fbc88da1 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/133] 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 0fbc88da1..70500c8f0 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/133] 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 227d13709..87b9fff2b 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/133] 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 b26efdfe7..feea67026 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/133] 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 87b9fff2b..d8910cf3a 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/133] 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 2833406ac..11fe28da6 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 feea67026..93c013f07 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/133] 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 e88449336..a896170f7 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 000000000..8a7693dc6 --- /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/133] 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 c3b8cf0dd..41b9d052d 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 7e242d990..4ef6ecce8 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/133] 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 3263052fa..d001192dd 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 b9305b87a..4d84e96c3 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/133] 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 8067869a7..67e099348 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/133] 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 555425066..884486a7f 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 4246962f7..357e910ed 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 9e0a22fe8..505b3722d 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 41b9d052d..3f98f6974 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 4ef6ecce8..f36d04433 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 d56bee20a..cebf94c9c 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/133] 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 67e099348..33a81f946 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/133] 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 248d6031b..c774c9567 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/133] 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 de2b5ec22..144cf2a33 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/133] 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 1e98eff65..03f9d58fb 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/133] 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 a896170f7..fba7de5b0 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 000000000..d805d6f74 --- /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/133] 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 51e77e4f2..93204e9da 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 884486a7f..c14bacfcc 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 3f98f6974..c52af0f95 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 f36d04433..7d2c0ecf8 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/133] 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 000000000..c5ffafa61 --- /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 144cf2a33..a88e1680b 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/133] 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 c14bacfcc..38b967570 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 c52af0f95..a38eb10ee 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 7d2c0ecf8..419291d43 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/133] 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 a751d59de..6c9209972 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/133] 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 357e910ed..ba411a9b5 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 306057099..7cd2a7e73 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 2c409463e..6e1cd3a02 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 98d72cbd0..67ee33cfd 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/133] 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 db7c7e963..9793f9d43 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 125a02ce9..651ff1f10 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 a5a679bea..ee526a955 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 11fe28da6..828457aa7 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 000000000..0bfc8c269 --- /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/133] 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 088147d40..2a6321ddb 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 000000000..3777f85c0 --- /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/133] 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 828457aa7..a269224b6 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 93c013f07..c14fac6e0 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/133] 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 c14fac6e0..66c610d5d 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/133] 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 fba7de5b0..05cfb0b3a 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 cebf94c9c..1faa30291 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/133] 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 c774c9567..702704903 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/133] 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 70500c8f0..b18a93105 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/133] 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 419291d43..9df41f4fc 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/133] 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 33a81f946..dac6ff9ad 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 ba6136125..37976c3e1 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/133] 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 38b967570..e5ff33d3e 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 a38eb10ee..ee1fe651a 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 9df41f4fc..ee4fef893 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/133] 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 a269224b6..4eebe0a1d 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 66c610d5d..4db9760aa 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/133] 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 ee1fe651a..6c3a92cb1 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/133] 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 4eebe0a1d..4e88ab862 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 4db9760aa..6e6905b77 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/133] 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 5ca186a39..65235e6af 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 ecd012ee1..b32160aec 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/133] 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 1fb1d7e0a..2abbf45b3 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 de46cd405..81ed88337 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/133] 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 d8910cf3a..0e31f97ad 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/133] 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 e5ff33d3e..6477e30c1 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 ee4fef893..75b5e4b82 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/133] 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 4e88ab862..3b110f68e 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 6e6905b77..d52947088 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/133] 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 0e5b33efd..3a3009955 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 3ce725944..53059a459 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 aa7937bd5..af2138c5b 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 6c3a92cb1..369e9da47 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 75b5e4b82..6a0612797 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 dac6ff9ad..e5e540758 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 e6cea4f54..960a30d0d 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/133] 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 b32160aec..1e70ee07f 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/133] 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 2a6321ddb..175e3b304 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 000000000..50e42749b --- /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 d33362cfaac1baa05f2a42558d321f726461c6dc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:22:27 +0000 Subject: [PATCH 100/133] Stage legacy Deep Scan execution with compatible recovery readers --- .../mcp-app/src/deep-scan/coordinator.ts | 12 - .../src/deep-scan/recovery-settings.ts | 5 - .../mcp-app/src/deep-scan/store.ts | 2 +- .../mcp-app/src/deep-scan/worker-runner.ts | 2 +- .../tests/test_reader_release_settings.mjs | 49 +++ .../scripts/deep_scan_workbench.py | 328 ++++-------------- .../scripts/workbench_schema.py | 14 - .../tests/test_reader_release_persistence.py | 256 ++++++++++++++ .../tests/test_workbench_scan_usage.py | 16 + .../tests-ts/deep-scan-workbench.test.ts | 1 + 10 files changed, 386 insertions(+), 299 deletions(-) create mode 100644 plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs create mode 100644 plugins/codex-security/tests/test_reader_release_persistence.py 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 03f9d58fb..7dd264617 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -275,18 +275,6 @@ export class DeepScanCoordinator { 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({ 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 6477e30c1..daa4ad988 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"; @@ -147,7 +146,6 @@ export async function loadOrCaptureDeepScanExecutionSettings( } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; settings = executionSettings(await capture()); - await writeJsonAtomic(path, { version: 1, settings }); return settings; } if (!original || (settings.model !== undefined && settings.reasoningEffort !== undefined @@ -169,9 +167,6 @@ export async function loadOrCaptureDeepScanExecutionSettings( ...(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; } 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 ba411a9b5..b6a8da658 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-security-scan/v2"; +const WORKFLOW_VERSION = "deep-security-scan/v1"; const MAX_IDEMPOTENT_PERSISTENCE_ATTEMPTS = 3; const PERSISTENCE_RETRY_BASE_DELAY_MS = 100; 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 98266396e..b118f2efe 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 @@ -302,7 +302,7 @@ export class DeepScanWorkerRunner { count: consumed.length }); - const persistSourceCoverage = "workflowVersion" in run && run.workflowVersion === "deep-security-scan/v2"; + const persistSourceCoverage = false; const artifactContext = { root: artifactDir, repoRoot: run.targetPath, diff --git a/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs b/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs new file mode 100644 index 000000000..72a71f8a8 --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const bundle = await build({ + bundle: true, + entryPoints: [fileURLToPath(new URL("../src/deep-scan/recovery-settings.ts", import.meta.url))], + platform: "node", + format: "esm", + write: false, +}); +const { loadOrCaptureDeepScanExecutionSettings } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` +); + +for (const state of ["absent", "saved", "unsupported"]) { + test(`reader release uses ${state} settings without persisting new metadata`, async () => { + const root = await mkdtemp(join(tmpdir(), "reader-settings-")); + const path = join(root, "artifacts", "deep_discovery", "execution-settings.json"); + try { + const settings = { codexPath: join(root, "codex"), codexHome: root, model: "original-model", parentSandbox: { filesystemDenies: [] } }; + let captures = 0; + const capture = async () => { captures++; return settings; }; + const original = { model: "original-model", reasoningEffort: "high", createdAt: "2026-01-01T00:00:00Z", usageOwner: null }; + let bytes; + if (state !== "absent") { + await mkdir(join(root, "artifacts", "deep_discovery"), { recursive: true }); + bytes = JSON.stringify({ version: state === "unsupported" ? 99 : 1, settings: { codexPath: join(root, "codex"), codexHome: root, parentSandbox: { filesystemDenies: [] } } }); + await writeFile(path, bytes); + } + if (state === "unsupported") { + await assert.rejects(loadOrCaptureDeepScanExecutionSettings(root, capture, original), /unsupported/); + } else { + const loaded = await loadOrCaptureDeepScanExecutionSettings(root, capture, original); + assert.equal(loaded.model, "original-model"); + if (state === "saved") assert.equal(loaded.reasoningEffort, "high"); + } + assert.equal(captures, state === "absent" ? 1 : 0); + if (state === "absent") await assert.rejects(stat(path), { code: "ENOENT" }); + else assert.equal(await readFile(path, "utf8"), bytes); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 7cd2a7e73..213f5bde5 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -19,10 +19,9 @@ 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 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, @@ -39,9 +38,9 @@ "invalid_discovery_artifacts", ) DEEP_SCAN_TERMINAL_REASONS = ("saturated", "capped") -DEEP_SCAN_WORKFLOW_VERSION = "deep-security-scan/v2" +DEEP_SCAN_WORKFLOW_VERSION = "deep-security-scan/v1" SUPPORTED_DEEP_SCAN_WORKFLOWS = { - DEEP_SCAN_WORKFLOW_VERSION, + "deep-security-scan/v2", "deep-scan-mcp/v1", "deep-security-scan/v1", } @@ -664,6 +663,14 @@ def effective_deep_scan_config(args: argparse.Namespace) -> dict[str, int | floa return resolve_deep_scan_config(available_parallelism) +def require_legacy_deep_scan_creation(connection: sqlite3.Connection) -> None: + if any( + row["name"] == "discovery_user_context" + for row in connection.execute("PRAGMA table_info(deep_scan_runs)") + ): + raise SystemExit("This Deep Scan database requires a newer version to start a scan.") + + def ensure_deep_scan_run( connection: sqlite3.Connection, scan: sqlite3.Row, @@ -677,6 +684,9 @@ def ensure_deep_scan_run( if existing is not None: require_supported_deep_scan(existing) return existing + if workflow_version == "deep-security-scan/v2": + raise SystemExit("This Deep Scan requires a newer version to start this workflow.") + require_legacy_deep_scan_creation(connection) if scan["mode"] != "deep": raise SystemExit("Deep Scan orchestration requires a scan in deep mode.") if scan["status"] != "running": @@ -686,9 +696,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, discovery_user_context, + max_discovery_runs, max_time_hours, created_at, updated_at - ) VALUES (?, 1, ?, 'running', 'setup', ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, 1, ?, 'running', 'setup', ?, ?, ?, ?, ?, ?, ?, ?) """, ( scan["id"], @@ -699,19 +709,11 @@ def ensure_deep_scan_run( config["stopAfterConsecutiveErrors"], config["maxDiscoveryRuns"], config["maxTimeHours"], - scan["user_context"], timestamp, timestamp, ), ) - 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 + return require_deep_scan_run(connection, scan["id"]) def existing_deep_scan_for_target( @@ -803,7 +805,11 @@ def begin_deep_scan_for_scan( "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) ).fetchone() if existing is not None: - require_supported_deep_scan(existing) + require_legacy_deep_scan_execution(connection, existing) + if existing is None and args.workflow_version == "deep-security-scan/v2": + raise SystemExit("This Deep Scan requires a newer version to start this workflow.") + if existing is None: + require_legacy_deep_scan_creation(connection) workspace = require_workspace(connection, candidate["workspace_id"]) if ( candidate["mode"] == "deep" @@ -861,7 +867,7 @@ def begin_deep_scan_for_scan( "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) ).fetchone() if existing is not None: - require_supported_deep_scan(existing) + require_legacy_deep_scan_execution(connection, existing) connection.commit() return deep_scan_result(connection, scan_id, start_disposition="joined") if model is not None or reasoning_effort is not None: @@ -911,7 +917,7 @@ def begin_deep_scan_for_target( "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (existing["id"],) ).fetchone() if existing_run is not None: - require_supported_deep_scan(existing_run) + require_legacy_deep_scan_execution(connection, existing_run) if existing_run is None: config = effective_deep_scan_config(args) workflow_version = optional_text(args.workflow_version, maximum=256) @@ -950,6 +956,7 @@ def begin_deep_scan_for_target( terminal["id"], start_disposition="joined", ) + require_legacy_deep_scan_creation(connection) config = effective_deep_scan_config(args) workflow_version = optional_text(args.workflow_version, maximum=256) if workflow_version is None: @@ -1052,6 +1059,8 @@ def begin_deep_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> thread_id = optional_text(args.thread_id, maximum=512) if thread_id is None: raise SystemExit("thread-id is required.") + if workflow_version == "deep-security-scan/v2" and not args.scan_id: + raise SystemExit("This Deep Scan requires a newer version to start this workflow.") if args.scan_id: if args.user_context is not None or args.user_context_stdin or args.scope != ".": raise SystemExit("scan-id cannot be combined with target setup fields.") @@ -1298,11 +1307,25 @@ def require_deep_scan_worker(connection: sqlite3.Connection, worker_id: str) -> return row +def require_legacy_deep_scan_execution(connection: sqlite3.Connection, run: sqlite3.Row) -> None: + require_supported_deep_scan(run) + if deep_scan_finalization_input(run) is not None: + return + if ( + run["workflow_version"] == "deep-security-scan/v2" + or connection.execute( + "SELECT 1 FROM deep_scan_attempts WHERE scan_id = ? LIMIT 1", (run["scan_id"],) + ).fetchone() + is not None + ): + raise SystemExit("This Deep Scan requires a newer version to resume execution.") + + 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) + require_legacy_deep_scan_execution(connection, 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.") @@ -1323,16 +1346,14 @@ 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]: +def validate_accepted_checkpoint(scan: sqlite3.Row, worker: sqlite3.Row) -> None: 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) + semantic = json.loads(Path(source).read_bytes()) if isinstance(semantic, dict): semantic.pop("handoffClaimToken", None) - directory = Path(worker["artifact_dir"]) / "checkpoints" + scan_dir = Path(scan["scan_dir"]) head = ( _worker_checkpoint_head( scan_dir, Path(worker["artifact_dir"]).relative_to(scan_dir).as_posix(), scan["id"] @@ -1340,113 +1361,14 @@ def snapshot_accepted_result(scan: sqlite3.Row, worker: sqlite3.Row) -> tuple[st 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: + if head: + checkpoint = deep_scan_path( + scan, str(scan_dir / head), "Accepted worker checkpoint", kind="file" + ) + if json.loads(Path(checkpoint).read_bytes()) != semantic: 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( @@ -1558,15 +1480,6 @@ 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 result @@ -1599,6 +1512,10 @@ def upsert_deep_scan_worker( result = deep_scan_result(connection, scan_id) if receipt is not None and receipt["receipt_json"]: result["deepScan"]["workerReceipt"] = json.loads(receipt["receipt_json"]) + else: + result["deepScan"]["workerReceipt"] = next( + worker for worker in result["deepScan"]["workers"] if worker["id"] == worker_id + ) connection.commit() return result attempt = args.attempt if args.attempt is not None else existing["attempt"] @@ -1696,25 +1613,14 @@ 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, - ) + if args.status == "succeeded" and result_manifest_path is not None: + validate_accepted_checkpoint(scan, require_deep_scan_worker(connection, worker_id)) 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() @@ -1834,41 +1740,14 @@ 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, - result_manifest_path, result_manifest_sha256, attempt - ) VALUES (?, ?, ?, ?, ?, ?, ?) + scan_id, dedup_worker_id, discovery_worker_id, input_order + ) VALUES (?, ?, ?, ?) """, - ( - scan_id, - worker_id, - input_id, - input_order, - accepted_path, - accepted_sha, - discovery["attempt"], - ), + (scan_id, worker_id, input_id, input_order), ) connection.execute( f""" @@ -2035,26 +1914,8 @@ 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) + validate_accepted_checkpoint(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() @@ -2111,6 +1972,8 @@ def finish_deep_scan_locked( raise SystemExit( "Deep Scan finalization must retain its selected reason and omissions." ) + if selecting and finalization is None: + raise SystemExit("This Deep Scan requires a newer version to select finalization.") manifest_path = ( deep_scan_output_path(scan, args.manifest_path, "Deep Scan coordinator manifest path") if args.staged_manifest_path or selecting @@ -2338,14 +2201,6 @@ def finish_deep_scan_locked( "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: @@ -2378,60 +2233,6 @@ 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): @@ -2614,11 +2415,6 @@ 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/scripts/workbench_schema.py b/plugins/codex-security/scripts/workbench_schema.py index 9c69dcbfc..5d3f7079d 100644 --- a/plugins/codex-security/scripts/workbench_schema.py +++ b/plugins/codex-security/scripts/workbench_schema.py @@ -867,20 +867,6 @@ ); """, ), - ( - 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", diff --git a/plugins/codex-security/tests/test_reader_release_persistence.py b/plugins/codex-security/tests/test_reader_release_persistence.py new file mode 100644 index 000000000..757e6047a --- /dev/null +++ b/plugins/codex-security/tests/test_reader_release_persistence.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pytest +from test_workbench_deep_scan import commit_reducer, dispatch_discovery_worker, upsert_worker +from workbench_test_support import SCRIPT, run_workbench + + +def introduced_metadata(state: Path, scan_dir: Path) -> dict: + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.row_factory = sqlite3.Row + tables = { + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + counts = { + name: connection.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0] + for name in ( + "deep_scan_attempts", + "deep_scan_attempt_sessions", + "deep_scan_merge_claims", + ) + if name in tables + } + run = connection.execute("SELECT * FROM deep_scan_runs").fetchone() + context = { + name: run[name] + for name in ("discovery_user_context", "usage_owner_json", "finalization_input_json") + if name in run.keys() + } + inputs = list(connection.execute("SELECT * FROM deep_scan_dedup_inputs")) + references = [ + { + name: row[name] + for name in ("result_manifest_path", "result_manifest_sha256", "attempt") + if name in row.keys() and row[name] is not None + } + for row in inputs + ] + return { + "tables": counts, + "run": context, + "input_references": [row for row in references if row], + "accepted_copies": sorted( + str(path.relative_to(scan_dir)) for path in scan_dir.rglob("checkpoints/*.json") + ), + } + + +@pytest.mark.parametrize("version", ["deep-security-scan/v1", "deep-scan-mcp/v1"]) +def test_legacy_execution_defers_new_persistence(tmp_path: Path, version: str) -> None: + state, home, target = tmp_path / "state", tmp_path / "codex", tmp_path / "target" + target.mkdir() + (home / "codex-security").mkdir(parents=True) + (home / "codex-security/config.toml").write_text("[deep_scan]\nmax_time_hours = 3\n") + run = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "reader-owner", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + "--workflow-version", + version, + "--user-context", + "Review the supplied input.", + environment={"CODEX_HOME": str(home)}, + )["deepScan"] + scan_id, scan_dir = run["scanId"], Path(run["scanDir"]) + stages = {"begin": introduced_metadata(state, scan_dir)} + workers = [] + for index in range(2): + worker, prompt, artifacts, result = dispatch_discovery_worker( + state, + home, + scan_id=scan_id, + scan_dir=scan_dir, + name=f"discovery-{index}", + succeed=False, + ) + upsert_worker( + state, + home, + scan_id=scan_id, + worker_id=worker, + kind="discovery", + status="running", + prompt_path=prompt, + artifact_dir=artifacts, + attempt=2, + thread_id=f"replacement-{index}", + ) + result.write_text("{}\n") + upsert_worker( + state, + home, + scan_id=scan_id, + worker_id=worker, + kind="discovery", + status="succeeded", + prompt_path=prompt, + artifact_dir=artifacts, + attempt=2, + thread_id=f"replacement-{index}", + result_path=result, + ) + workers.append(worker) + stages["discovery"] = introduced_metadata(state, scan_dir) + committed = commit_reducer( + state, + home, + scan_id=scan_id, + scan_dir=scan_dir, + name="dedup-1", + input_worker_ids=workers, + new_findings_count=0, + ) + stages["merge"] = introduced_metadata(state, scan_dir) + assert committed["noNewStreak"] == 2 + assert [item["discoveryWorkerId"] for item in committed["dedupInputs"]] == workers + assert all( + item["mergeState"] == "merged" + for item in committed["workers"] + if item["kind"] == "discovery" + ) + stopped = run_workbench( + state, + "fail-deep-scan", + "--scan-id", + scan_id, + "--message", + "Original reader stop.", + environment={"CODEX_HOME": str(home)}, + )["deepScan"] + stages["stop"] = introduced_metadata(state, scan_dir) + assert stopped["status"] == "failed" + assert "Original reader stop." in stopped["error"] + assert stopped["workflowVersion"] == version + assert stopped["createdAt"] == run["createdAt"] + assert stopped["config"]["maxTimeHours"] == 3 + assert stopped["userContext"] == "Review the supplied input." + print(json.dumps({"version": version, "stages": stages}, sort_keys=True)) + for stage in stages.values(): + assert all(count == 0 for count in stage["tables"].values()), stages + assert all(value is None for value in stage["run"].values()), stages + assert stage["input_references"] == [], stages + assert stage["accepted_copies"] == [], stages + + +def test_upgraded_context_schema_rejects_creation_before_mutation(tmp_path: Path) -> None: + state, target, next_target = tmp_path / "state", tmp_path / "target", tmp_path / "next" + target.mkdir() + next_target.mkdir() + scan_root = tmp_path / "scans" + environment = {"CODEX_HOME": str(tmp_path / "home")} + run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "reader-owner", + "--target-path", + str(target), + "--scan-root", + str(scan_root), + environment=environment, + ) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + # An upgraded database distinguishes captured NULL from an uncaptured legacy field. + connection.execute("ALTER TABLE deep_scan_runs ADD COLUMN discovery_user_context TEXT") + before = "\n".join(connection.iterdump()) + before_paths = sorted(str(path.relative_to(scan_root)) for path in scan_root.rglob("*")) + rejected = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "reader-owner", + "--target-path", + str(next_target), + "--scan-root", + str(scan_root), + "--user-context", + "Explicit new review input.", + check=False, + environment=environment, + ) + assert rejected["returncode"] != 0 + assert "newer version" in rejected["stderr"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + assert "\n".join(connection.iterdump()) == before + assert sorted(str(path.relative_to(scan_root)) for path in scan_root.rglob("*")) == before_paths + + +def test_selected_replay_keeps_publication_path_validation(tmp_path: Path) -> None: + state, target = tmp_path / "state", tmp_path / "target" + target.mkdir() + environment = {"CODEX_HOME": str(tmp_path / "home")} + run = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "reader-owner", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + environment=environment, + )["deepScan"] + selection = { + "version": 1, + "resultPath": None, + "resultSha256": None, + "terminalReason": "capped", + "omittedWorkerIds": [], + "selectedAt": "2000-01-01T00:00:00Z", + } + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "finalization_input_json = ?, phase = 'terminal', " + "created_at = '2000-01-01T00:00:00Z'", + (json.dumps(selection),), + ) + before = "\n".join(connection.iterdump()) + rejected = subprocess.run( + [ + sys.executable, + "-I", + "-B", + "-c", + "import runpy,sys; p=sys.argv.pop(1); runpy.run_path(p)['main'](select_finalization=True)", + str(SCRIPT), + "finish-deep-scan", + "--scan-id", + run["scanId"], + "--terminal-reason", + "capped", + "--manifest-path", + str(Path(run["scanDir"]) / "wrong-manifest.json"), + ], + capture_output=True, + text=True, + input=json.dumps({"resultPath": None}), + env={**os.environ, **environment, "CODEX_SECURITY_STATE_DIR": str(state)}, + ) + assert rejected.returncode != 0 + assert "parent manifest" in rejected.stderr + with sqlite3.connect(state / "workbench.sqlite3") as connection: + assert "\n".join(connection.iterdump()) == before diff --git a/plugins/codex-security/tests/test_workbench_scan_usage.py b/plugins/codex-security/tests/test_workbench_scan_usage.py index 81ed88337..63d80348c 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -591,6 +591,22 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N environment, "deep", ) + # Read an owner binding already persisted by a newer writer release. + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET usage_owner_json = ? WHERE scan_id = ?", + ( + json.dumps( + { + "threadId": "scan-parent", + "turnId": None, + "startedAt": fixture.started_at.isoformat(), + "dedicated": False, + } + ), + scan_id, + ), + ) counted = fixture.started_at + timedelta(microseconds=1) artifact = scan_dir / "artifacts" / "usage-worker" artifact.mkdir(parents=True) diff --git a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts index abe6edcbb..6d2dc7374 100644 --- a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts @@ -35,6 +35,7 @@ const deepScanOwnershipProbe = [ "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, schema_version INTEGER NOT NULL DEFAULT 1, workflow_version TEXT NOT NULL DEFAULT 'deep-scan-mcp/v1');", + "CREATE TABLE deep_scan_attempts (scan_id TEXT NOT NULL);", "''')", "scan_id = '11111111-1111-4111-8111-111111111111'", "connection.execute(\"INSERT INTO workspaces VALUES ('workspace', NULL, 'before')\")", From 69a267011c479a7d451c818ea58b19906ff13479 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:45:07 +0000 Subject: [PATCH 101/133] Read existing frozen checkpoints before enabling publication writers --- .../scripts/workbench_saved_results.py | 57 +++--- .../tests/test_reader_checkpoint_replay.py | 189 ++++++++++++++++++ 2 files changed, 222 insertions(+), 24 deletions(-) create mode 100644 plugins/codex-security/tests/test_reader_checkpoint_replay.py diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index 175e3b304..cd8999e27 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -349,9 +349,11 @@ def has_saved_source() -> bool: published_sources = _source_digests( manifest_scan.get("preservedSources", {}), "Published scan" ) - if _worker_checkpoint_heads( - scan_dir, workers, scan["id"], accepted_digests - ) != manifest_scan.get("preservedCheckpointHeads", {}): + if ( + "preservedCheckpointHeads" in manifest_scan + and _worker_checkpoint_heads(scan_dir, workers, scan["id"], accepted_digests) + != manifest_scan["preservedCheckpointHeads"] + ): return True current_sources = dict(published_sources) for path in paths: @@ -372,11 +374,15 @@ def has_saved_source() -> bool: def _recovery_source_digests( db: Any, connection: Any, scan: Any -) -> tuple[dict[str, str], bool, dict[str, str]]: +) -> tuple[dict[str, str], bool, dict[str, str] | None]: scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) frozen_sources: dict[str, str] | None = None include_parent = True raw_frozen_sources = scan["retained_source_digests_json"] + raw_checkpoint_heads = scan["retained_checkpoint_heads_json"] + checkpoint_heads = ( + json.loads(raw_checkpoint_heads) if raw_checkpoint_heads is not None else None + ) if raw_frozen_sources is not None: frozen_sources = _source_digests(json.loads(raw_frozen_sources), "Saved stopped-scan") include_parent = False @@ -394,6 +400,8 @@ def _recovery_source_digests( if scan["seal_manifest_digest"] is not None or ( manifest_scan.get("sealedAt") is not None or manifest_scan.get("artifacts") is not None ): + if checkpoint_heads is None: + checkpoint_heads = manifest_scan.get("preservedCheckpointHeads") if "preservedSources" in manifest_scan: published_sources = _source_digests( manifest_scan["preservedSources"], "Published scan" @@ -412,7 +420,6 @@ def _recovery_source_digests( workers = _saved_workers(connection, 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(): @@ -440,6 +447,14 @@ def _recovery_source_digests( ) except (ContractError, OSError, ValueError): continue + if checkpoint_heads is not None and ( + recovery_sources != frozen_sources + or _worker_checkpoint_heads(scan_dir, workers, scan["id"], accepted_digests) + != checkpoint_heads + ): + raise SystemExit( + "This stopped scan requires a newer version to select recovery checkpoints." + ) return recovery_sources, include_parent, checkpoint_heads @@ -588,10 +603,6 @@ def merge_saved_results( ) -> 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, 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: @@ -680,7 +691,7 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: continue if worker["kind"] != "discovery": continue - head = checkpoint_heads.get(output) + head = (checkpoint_heads or {}).get(output) if head is not None: paths[head] = worker["id"] current_results.add(head) @@ -702,7 +713,7 @@ def reducer_output(directory: str, attempt: int, reducer_worker: Any) -> None: ) for name in archived_attempts: archived = (attempts / name).as_posix() - archived_head = checkpoint_heads.get(archived) + archived_head = (checkpoint_heads or {}).get(archived) if archived_head is not None: paths[archived_head] = worker["id"] current_results.add(archived_head) @@ -793,7 +804,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 parent_manifest["scan"].get("preservedCheckpointHeads") == checkpoint_heads and all(warning in initial_warnings for warning in warnings) ): return None @@ -825,7 +836,8 @@ 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 + if checkpoint_heads is not None: + manifest["scan"]["preservedCheckpointHeads"] = checkpoint_heads coverage = ( copy.deepcopy(parent["coverage"]) if parent and parent["coverage"] @@ -1292,7 +1304,10 @@ 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 "{}") + raw_checkpoint_heads = scan["retained_checkpoint_heads_json"] + checkpoint_heads = ( + json.loads(raw_checkpoint_heads) if raw_checkpoint_heads is not None else None + ) scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) deep_run = connection.execute( "SELECT * FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) @@ -1348,15 +1363,11 @@ 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, @@ -1383,7 +1394,9 @@ 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", {}) + existing_heads = existing_scan.get("preservedCheckpointHeads") + if checkpoint_heads is None: + checkpoint_heads = existing_heads if frozen_source_digests is None: if not isinstance(existing_sources, dict) or not all( isinstance(relative, str) and isinstance(digest, str) @@ -1449,14 +1462,10 @@ 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 = ?, " - "retained_checkpoint_heads_json = ? " + "UPDATE scans SET retained_source_digests_json = ? " "WHERE id = ? AND retained_source_digests_json IS NULL", ( json.dumps(retained_sources, sort_keys=True), - json.dumps( - documents[0]["scan"].get("preservedCheckpointHeads", {}), sort_keys=True - ), scan_id, ), ) diff --git a/plugins/codex-security/tests/test_reader_checkpoint_replay.py b/plugins/codex-security/tests/test_reader_checkpoint_replay.py new file mode 100644 index 000000000..32cd56e6c --- /dev/null +++ b/plugins/codex-security/tests/test_reader_checkpoint_replay.py @@ -0,0 +1,189 @@ +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 + + +def save_checkpoint(scan, result, *, rejected=False): + candidate = copy.deepcopy(scan.findings[0]) + candidate["extensions"] = {"candidateId": "candidate-disposition"} + retained = copy.deepcopy(candidate) + retained["extensions"] = {"candidateId": "candidate-retained"} + retained["identity"]["anchor"] = "independent-finding" + retained["locations"][0]["startLine"] = 20 + retained["locations"][0]["endLine"] = 21 + draft = { + "scanId": scan.scan_id, + "complete": False, + "findings": [retained] if rejected else [candidate, retained], + "coverage": { + **scan.coverage, + "surfaces": [ + { + "candidateId": "candidate-disposition", + "label": "Validated candidate disposition", + "disposition": "rejected" if rejected else "reported", + "receiptRefs": [], + } + ], + }, + } + checkpoint = write_checkpoint(result.parent / "checkpoints", draft) + (result.parent / "checkpoint-head.json").write_text(json.dumps({"checkpoint": checkpoint.name})) + return draft, checkpoint + + +def stop(workbench_api, connection, scan): + return workbench_api["fail_scan"]( + connection, + Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None, message="Audit stopped."), + )["scan"] + + +def preserve(workbench_api, connection, scan): + return workbench_api["preserve_scan_results"]( + connection, + Namespace( + scan_id=scan.scan_id, claim_token=None, thread_id=None, coordinator_generation=None + ), + )["scan"] + + +@pytest.mark.parametrize("has_head", [False, True]) +def test_legacy_stop_does_not_create_frozen_checkpoint_metadata( + workbench_api, workbench_db, publication_scan, has_head +): + scan = publication_scan() + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + result = add_worker(workbench_db, scan, status="canceled") + draft, _ = save_checkpoint(scan, result) + result.write_text(json.dumps(draft)) + if not has_head: + (result.parent / "checkpoint-head.json").unlink() + + stopped = stop(workbench_api, workbench_db, scan) + assert stopped["findingCount"] == 2 + assert stopped["failureMessage"] == "Audit stopped." + assert preserve(workbench_api, workbench_db, scan)["findingCount"] == 2 + assert ( + workbench_api["recover_scan_results"](workbench_db, Namespace(scan_id=scan.scan_id))[ + "scan" + ]["findingCount"] + == 2 + ) + row = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + assert row["retained_source_digests_json"] + assert row["retained_checkpoint_heads_json"] is None + manifest = json.loads((scan.scan_dir / "scan-manifest.json").read_text()) + assert "preservedCheckpointHeads" not in manifest["scan"] + + +@pytest.fixture +def frozen_stop(workbench_api, workbench_db, publication_scan, monkeypatch): + 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_checkpoint(scan, result) + result.write_text(json.dumps(previous)) + _, selected = save_checkpoint(scan, result, rejected=True) + directory = result.parent.relative_to(scan.scan_dir).as_posix() + heads = {directory: selected.relative_to(scan.scan_dir).as_posix()} + 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 obstruct_coverage(directory, relative, payload, **kwargs): + if relative != "coverage.json" or failed_writes: + return write_bytes(directory, relative, payload, **kwargs) + failed_writes.append(json.loads((directory / "findings.json").read_text())) + path = directory / relative + before = path.read_bytes() + path.unlink() + path.mkdir() + try: + return write_bytes(directory, relative, payload, **kwargs) + finally: + path.rmdir() + path.write_bytes(before) + + with monkeypatch.context() as patch: + patch.setattr(finalize_scan_contract, "write_scan_local_bytes", obstruct_coverage) + stop(workbench_api, workbench_db, scan) + assert len(failed_writes) == 1 + assert "scanId" in failed_writes[0] + assert all( + (scan.scan_dir / name).read_bytes() == contents + for name, contents in original_outputs.items() + ) + 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 + # A later writer freezes this existing checkpoint map before output writes. + # Seed its recorded input; this release only consumes that saved authority. + with workbench_db: + workbench_db.execute( + "UPDATE scans SET retained_checkpoint_heads_json = ? WHERE id = ?", + (json.dumps(heads, sort_keys=True), scan.scan_id), + ) + return scan, result, heads + + +@pytest.mark.parametrize("head_change", ["replaced", "removed", "missing-checkpoint"]) +def test_reader_replays_frozen_rejection_after_real_output_fault( + workbench_api, workbench_db, frozen_stop, head_change +): + scan, result, heads = frozen_stop + row = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + head = result.parent / "checkpoint-head.json" + if head_change == "replaced": + save_checkpoint(scan, result) + elif head_change == "removed": + head.unlink() + else: + head.write_text(json.dumps({"checkpoint": "a" * 64 + ".json"})) + + replayed = preserve(workbench_api, workbench_db, scan) + assert replayed["findingCount"] == 1 + assert replayed["failureMessage"] == "Audit stopped." + findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] + assert findings[0]["identity"]["anchor"] == "independent-finding" + coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) + assert coverage["completeness"] == "partial" + assert any( + surface.get("candidateId") == "candidate-disposition" + and surface.get("disposition") == "rejected" + for surface in coverage["surfaces"] + ) + manifest = json.loads((scan.scan_dir / "scan-manifest.json").read_text()) + assert manifest["scan"]["preservedCheckpointHeads"] == heads + after = workbench_db.execute("SELECT * FROM scans WHERE id = ?", (scan.scan_id,)).fetchone() + assert after["retained_source_digests_json"] == row["retained_source_digests_json"] + assert after["retained_checkpoint_heads_json"] == row["retained_checkpoint_heads_json"] + assert preserve(workbench_api, workbench_db, scan)["findingCount"] == 1 + + +def test_reader_requires_writer_to_select_new_recovery_heads( + workbench_api, workbench_db, frozen_stop +): + scan, result, _ = frozen_stop + assert preserve(workbench_api, workbench_db, scan)["findingCount"] == 1 + save_checkpoint(scan, result) + before_db = list(workbench_db.iterdump()) + before_files = {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} + with pytest.raises(SystemExit, match="newer version"): + workbench_api["recover_scan_results"](workbench_db, Namespace(scan_id=scan.scan_id)) + assert list(workbench_db.iterdump()) == before_db + assert {path: path.read_bytes() for path in scan.scan_dir.rglob("*") if path.is_file()} == ( + before_files + ) From b855bf7b765fcc1294484a451e9c6087e77ccbbc Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 11:31:25 +0000 Subject: [PATCH 102/133] Separate reader fixtures from deferred state-writer tests --- .../tests/deep_scan_coverage_fixture.mjs | 4 +- .../tests/deep_scan_publication_cases.mjs | 20 +- .../tests/test_deep_scan_attempt_replay.mjs | 133 --------- .../mcp-app/tests/test_deep_scan_executor.mjs | 6 +- .../test_deep_scan_recorded_recovery.mjs | 33 --- .../test_deep_scan_recovery_settings.mjs | 19 +- .../test_deep_scan_selected_coverage.mjs | 36 --- .../tests/test_deep_scan_stdio_lifecycle.mjs | 16 +- .../mcp-app/tests/test_deep_scan_store.mjs | 2 +- .../test_checkpoint_publication_authority.py | 96 +------ .../tests/test_deep_scan_compatibility.py | 15 +- ...st_deep_scan_finalization_compatibility.py | 2 +- .../tests/test_deep_scan_persistence.py | 270 ++++-------------- .../tests/test_deep_scan_recovery_settings.py | 131 --------- .../tests/test_deep_scan_usage_owner.py | 38 ++- ...est_finalization_selection_process_loss.py | 168 ----------- .../test_publication_stop_interleavings.py | 100 ------- .../codex-security/tests/test_workbench_db.py | 2 +- .../test_workbench_setup_and_migrations.py | 6 +- .../test_workbench_standard_deep_results.py | 16 +- .../tests-ts/deep-finalization.test.ts | 3 + .../tests-ts/fixtures/selected-deep-scan.py | 13 + 22 files changed, 161 insertions(+), 968 deletions(-) delete mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs delete mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_recorded_recovery.mjs delete mode 100644 plugins/codex-security/mcp-app/tests/test_deep_scan_selected_coverage.mjs delete mode 100644 plugins/codex-security/tests/test_deep_scan_recovery_settings.py delete mode 100644 plugins/codex-security/tests/test_finalization_selection_process_loss.py 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 05cfb0b3a..3a10cbb42 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,7 +64,7 @@ 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"); + assert.equal(run.workflowVersion, "deep-security-scan/v1", "the prior reader starts the legacy workflow"); if (selectedRecovery) { ({ run } = await store.claimCoordinator({ scanId: run.scanId, threadId })); } else { @@ -177,7 +177,7 @@ export async function publishCoverageFixture(root, completeness, { } rawSources.set(resultManifestPath, await readFile(resultManifestPath, "utf8")); const committed = await store.commitDedup({ id, scanId: run.scanId, newFindings: materialFindings && index === 0 ? 1 : 0, resultManifestPath }); - lastReducerReference = committed.committedMerge.resultManifestPath; + lastReducerReference = committed.persistedWorkers.find((worker) => worker.id === id).resultManifestPath; lastReducerId = id; } if (legacyAttempts) { 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 9afb20ddf..6c192c1b7 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,6 +1,5 @@ import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; export async function testDeepScanPublication({ @@ -109,21 +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); - 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(); @@ -163,8 +148,9 @@ export async function testDeepScanPublication({ worker.kind === "dedup" && worker.status === "succeeded" )); const { coverage, ...publishedReduction } = completed[0]; + assert.equal(coverage.reviews.length, 2, "both completed audits retain source coverage in the publication"); assert.deepEqual( - { ...publishedReduction, sourceCoverage: coverage }, + publishedReduction, JSON.parse(await readFile(acceptedReducer.resultManifestPath, "utf8")), "the accepted aggregate still reaches publication when redundant cancellation writes fail", ); 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 deleted file mode 100644 index b18a93105..000000000 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_attempt_replay.mjs +++ /dev/null @@ -1,133 +0,0 @@ -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 { DeepScanCoordinator } from "./src/deep-scan/coordinator.ts";', - 'export { createDeepScanArtifacts, ensureDeepScanDirectories } from "./src/deep-scan/artifacts.ts";' - ].join("\n") } -}); -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); - -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++; - 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 = { - 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].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); - assert.equal(counts.get("merge"), 2); - assert.equal(executions, 3); - 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, - 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 }); - } -} 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 369e9da47..301e69254 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 @@ -811,6 +811,9 @@ async function testIsolatedReconstructedWorkers() { 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"); + // Prior-reader fixture: this snapshot was written by the writer release. + await mkdir(path.dirname(snapshotPath), { recursive: true }); + await writeFile(snapshotPath, JSON.stringify({ version: 1, settings: saved })); const snapshot = await readFile(snapshotPath, "utf8"); assert.equal(snapshot.includes("synthetic-"), false); const runtimeEnvironment = { ...codexOptions.env }; @@ -841,6 +844,7 @@ async function testIsolatedReconstructedWorkers() { if (scan.name === "first") delete saved.settings.serviceTier; await writeFile(scan.snapshotPath, JSON.stringify(saved)); } + const beforeRead = await readFile(scan.snapshotPath, "utf8"); const recorded = await loadOrCaptureDeepScanExecutionSettings(scan.fixture.root, () => assert.fail("reconstruction must not recapture current settings"), { ...scan.settings, createdAt: "2026-01-01T00:01:00Z" @@ -848,7 +852,7 @@ async function testIsolatedReconstructedWorkers() { 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); + assert.equal(await readFile(scan.snapshotPath, "utf8"), beforeRead, "the prior reader preserves the stored snapshot bytes"); } } for (const scan of scans) { 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 deleted file mode 100644 index 8a7693dc6..000000000 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_recorded_recovery.mjs +++ /dev/null @@ -1,33 +0,0 @@ -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 }); - } - }); -} 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 6a0612797..3e58b507a 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 @@ -35,6 +35,8 @@ try { })); assert.deepEqual(first, settings); const savedPath = join(root, "one", "artifacts", "deep_discovery", "execution-settings.json"); + await assert.rejects(readFile(savedPath), { code: "ENOENT" }); + await writeSettingsFixture(join(root, "one"), first); const saved = await readFile(savedPath, "utf8"); assert.equal(saved.includes("synthetic-do-not-persist"), false); const [recovered, concurrent] = await Promise.all([ @@ -152,7 +154,7 @@ 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 writeSettingsFixture(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); @@ -184,7 +186,7 @@ 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); + await writeSettingsFixture(tierDir, withoutTier); const repairedTier = await loadSettings(tierDir, async () => assert.fail(), { usageOwner: appliedOwner, createdAt: "2026-01-01T00:01:00Z" }); @@ -213,7 +215,7 @@ 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 writeSettingsFixture(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" }; @@ -221,12 +223,15 @@ http_headers = { Authorization = "synthetic-secret" } assert.deepEqual(repaired, { ...incomplete, model: "stored-model", reasoningEffort: "ultra", modelProvider: "openai", reasoningSummary: "none" }); const repairedPath = join(incompleteDir, "artifacts", "deep_discovery", "execution-settings.json"); + assert.deepEqual(JSON.parse(await readFile(repairedPath, "utf8")).settings, incomplete, "the reader does not persist a settings upgrade"); + // Replay a full snapshot that the later writer has already upgraded. + await writeSettingsFixture(incompleteDir, repaired); 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); + await writeSettingsFixture(unknownDir, 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"); @@ -239,3 +244,9 @@ http_headers = { Authorization = "synthetic-secret" } } finally { await rm(root, { recursive: true, force: true }); } + +async function writeSettingsFixture(scanDir, settings) { + const directory = join(scanDir, "artifacts", "deep_discovery"); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "execution-settings.json"), JSON.stringify({ version: 1, settings })); +} 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 deleted file mode 100644 index d805d6f74..000000000 --- a/plugins/codex-security/mcp-app/tests/test_deep_scan_selected_coverage.mjs +++ /dev/null @@ -1,36 +0,0 @@ -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 }); - } - }); -} 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 e5e540758..88ab18bd6 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,16 +487,10 @@ 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.workflowVersion, "deep-security-scan/v1", "the prior reader starts the legacy workflow"); 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( - "update_codex_security_scan_context", - { scanId: resumedScanId, handoffClaimToken, userContext: "Later result discussion" }, - resumedThreadId - ))); + await assert.rejects(readFile(settingsPath), { code: "ENOENT" }); 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]); @@ -538,8 +532,6 @@ async function testDeepScanStdioLifecycle() { "--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'); const restartedServer = startServer(serverBundlePath, environment); try { @@ -565,14 +557,12 @@ async function testDeepScanStdioLifecycle() { }); 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); 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); + await assert.rejects(readFile(settingsPath), { code: "ENOENT" }); const successfulDiscoveries = finished.workers.filter((worker) => ( worker.kind === "discovery" && worker.status === "succeeded" )); 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 1faa30291..4c14abc35 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-security-scan/v2"); + assert.equal(flagValue(calls[0].args, "--workflow-version"), "deep-security-scan/v1"); const claimToken = randomUUID(); let joinedArgs; diff --git a/plugins/codex-security/tests/test_checkpoint_publication_authority.py b/plugins/codex-security/tests/test_checkpoint_publication_authority.py index a079f280c..7c277d3dd 100644 --- a/plugins/codex-security/tests/test_checkpoint_publication_authority.py +++ b/plugins/codex-security/tests/test_checkpoint_publication_authority.py @@ -11,7 +11,7 @@ @pytest.mark.parametrize("archived", [False, True], ids=["current", "archived"]) -@pytest.mark.parametrize("has_head", [True, False], ids=["committed-head", "legacy"]) +@pytest.mark.parametrize("has_head", [False], ids=["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 @@ -103,100 +103,6 @@ def save_disposition(scan, directory, disposition): 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 ): diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py index 4d84e96c3..51b89b25d 100644 --- a/plugins/codex-security/tests/test_deep_scan_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_compatibility.py @@ -16,9 +16,7 @@ def snapshot(state_dir: Path) -> str: return "\n".join(connection.iterdump()) -@pytest.mark.parametrize( - "version", ["deep-security-scan/v1", "deep-scan-mcp/v1", "deep-security-scan/v2"] -) +@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() @@ -161,7 +159,12 @@ def test_reader_honors_original_context_when_present(tmp_path: Path, original: s assert observed["userContext"] == original -def test_unsupported_new_workflow_does_not_claim_registered_scan(tmp_path: Path) -> None: +@pytest.mark.parametrize( + "version,message", [("future/v99", "unsupported"), ("deep-security-scan/v2", "newer version")] +) +def test_unsupported_new_workflow_does_not_claim_registered_scan( + tmp_path: Path, version: str, message: str +) -> None: state = tmp_path / "state" target = tmp_path / "target" target.mkdir() @@ -195,9 +198,9 @@ def test_unsupported_new_workflow_does_not_claim_registered_scan(tmp_path: Path) "--thread-id", "fixture-thread", "--workflow-version", - "future/v99", + version, check=False, ) assert rejected["returncode"] != 0 - assert "unsupported" in str(rejected["stderr"]).lower() + assert message in str(rejected["stderr"]).lower() assert snapshot(state) == before 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 6e1cd3a02..28f5b2852 100644 --- a/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py +++ b/plugins/codex-security/tests/test_deep_scan_finalization_compatibility.py @@ -28,7 +28,7 @@ def test_new_workflow_default_preserves_existing_run_version( str(tmp_path / "scans"), *(["--workflow-version", legacy_version] if legacy_version else []), )["deepScan"] - expected_version = legacy_version or "deep-security-scan/v2" + expected_version = legacy_version or "deep-security-scan/v1" assert created["workflowVersion"] == expected_version resumed = run_workbench( state, diff --git a/plugins/codex-security/tests/test_deep_scan_persistence.py b/plugins/codex-security/tests/test_deep_scan_persistence.py index 0f2f0e67e..ff3ce985e 100644 --- a/plugins/codex-security/tests/test_deep_scan_persistence.py +++ b/plugins/codex-security/tests/test_deep_scan_persistence.py @@ -10,7 +10,6 @@ import pytest from test_workbench_deep_scan import ( begin_target_scan, - commit_reducer, dispatch_discovery_worker, upsert_worker, worker_paths, @@ -82,145 +81,6 @@ def test_state_snapshot_preserves_its_callers_transaction( 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: @@ -240,20 +100,28 @@ def test_native_usage_keeps_replaced_failed_canceled_attempts_and_descendants( 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" + upsert_worker(state, home, **mutation, status="canceled", attempt=3, thread_id="replacement") + # The writer release recorded these prior attempts and observed sessions. + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute("PRAGMA foreign_keys = ON") + for attempt, status, thread in ( + (1, "replaced", "old"), + (2, "failed", "old"), + (3, "canceled", "replacement"), + ): + connection.execute( + "INSERT INTO deep_scan_attempts " + "(scan_id, worker_id, attempt, status, started_at) VALUES (?, ?, ?, ?, ?)", + (run["scanId"], worker_id, attempt, status, run["createdAt"]), + ) + connection.execute( + "INSERT INTO deep_scan_attempt_sessions " + "(scan_id, worker_id, attempt, sdk_thread_id, observed_at) VALUES (?, ?, ?, ?, ?)", + (run["scanId"], worker_id, attempt, thread, run["createdAt"]), + ) + terminal = run_workbench( + state, "get-deep-scan", "--scan-id", run["scanId"], "--thread-id", "thread-deep-scan" )["deepScan"] assert [item["status"] for item in terminal["attempts"]] == ["replaced", "failed", "canceled"] environment = { @@ -307,6 +175,36 @@ def test_native_usage_keeps_replaced_failed_canceled_attempts_and_descendants( assert measured["modelUsage"] == [{"model": "gpt-5.6-sol", **_counts(55, 0, 0)}] +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 + + 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() @@ -342,69 +240,3 @@ def test_claim_replay_preserves_original_inputs_after_concurrent_discovery(tmp_p 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_deep_scan_recovery_settings.py b/plugins/codex-security/tests/test_deep_scan_recovery_settings.py deleted file mode 100644 index 37976c3e1..000000000 --- a/plugins/codex-security/tests/test_deep_scan_recovery_settings.py +++ /dev/null @@ -1,131 +0,0 @@ -"""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"] - - -@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", - 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["workflowVersion"] == workflow_version - 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_deep_scan_usage_owner.py b/plugins/codex-security/tests/test_deep_scan_usage_owner.py index 9b5cb237c..d2041342c 100644 --- a/plugins/codex-security/tests/test_deep_scan_usage_owner.py +++ b/plugins/codex-security/tests/test_deep_scan_usage_owner.py @@ -40,7 +40,28 @@ def test_original_usage_turn_survives_join_and_coordinator_recovery(tmp_path: Pa str(tmp_path / "scans"), environment=environment, )["deepScan"] - owner = begun["usageOwner"] + # A writer recorded the original turn; joining and recovery only read it. + owner = { + "threadId": "shared-parent", + "turnId": "original-turn", + "startedAt": begun["createdAt"], + "dedicated": False, + } + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET usage_owner_json = ? WHERE scan_id = ?", + (json.dumps(owner), begun["scanId"]), + ) + observed = run_workbench( + state, + "get-deep-scan", + "--scan-id", + begun["scanId"], + "--thread-id", + "shared-parent", + environment=environment, + )["deepScan"] + assert observed["usageOwner"] == owner assert owner["threadId"] == "shared-parent" assert owner["turnId"] == "original-turn" assert owner["dedicated"] is False @@ -92,6 +113,21 @@ def test_original_usage_turn_survives_join_and_coordinator_recovery(tmp_path: Pa str(tmp_path / "scans"), environment=environment, )["deepScan"] + other_owner = {**owner, "turnId": "later-turn", "startedAt": other["createdAt"]} + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET usage_owner_json = ? WHERE scan_id = ?", + (json.dumps(other_owner), other["scanId"]), + ) + other = run_workbench( + state, + "get-deep-scan", + "--scan-id", + other["scanId"], + "--thread-id", + "shared-parent", + environment=environment, + )["deepScan"] assert other["usageOwner"]["turnId"] == "later-turn" original = run_workbench( state, "get-scan", "--scan-id", begun["scanId"], environment=environment diff --git a/plugins/codex-security/tests/test_finalization_selection_process_loss.py b/plugins/codex-security/tests/test_finalization_selection_process_loss.py deleted file mode 100644 index c5ffafa61..000000000 --- a/plugins/codex-security/tests/test_finalization_selection_process_loss.py +++ /dev/null @@ -1,168 +0,0 @@ -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_publication_stop_interleavings.py b/plugins/codex-security/tests/test_publication_stop_interleavings.py index d1f4c8323..fdd8db225 100644 --- a/plugins/codex-security/tests/test_publication_stop_interleavings.py +++ b/plugins/codex-security/tests/test_publication_stop_interleavings.py @@ -228,106 +228,6 @@ def test_stop_and_publication_keep_the_winning_terminal_outcome( 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["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. - 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 diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 697bb0e36..5ad21c2c8 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() == (46,) + 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 f551a3964..c9b4e8734 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() == (46,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (45,) @pytest.mark.parametrize("previous_history", ["main", "comparison-preview"]) @@ -867,7 +867,6 @@ 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"), (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), @@ -2001,7 +2000,6 @@ 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"), (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), @@ -2089,7 +2087,6 @@ 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"), (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), @@ -2185,7 +2182,6 @@ 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"), (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), 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 a88e1680b..58e4b2fd2 100644 --- a/plugins/codex-security/tests/test_workbench_standard_deep_results.py +++ b/plugins/codex-security/tests/test_workbench_standard_deep_results.py @@ -1399,10 +1399,24 @@ def test_failure_preserves_last_committed_reducer_without_parent_draft(tmp_path: draft = json.loads(result_path.read_text()) draft["findings"] = json.loads((contract_dir / "findings.json").read_text())["findings"] result_path.write_text(json.dumps(draft)) - _, reducer_path, _ = committed_standard_reducer( + reducer_id, reducer_path, _ = committed_standard_reducer( state_dir, codex_home, scan_dir, scan_id, worker_id, result_path ) reduced = json.loads(reducer_path.read_text()) + # The later writer retained this accepted reference before result.json changed. + import hashlib + + accepted = write_checkpoint(reducer_path.parent / "checkpoints", reduced) + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + connection.execute("PRAGMA foreign_keys = ON") + connection.execute( + "INSERT INTO deep_scan_attempts " + "(scan_id, worker_id, attempt, status, started_at, completed_at, " + "accepted_result_path, accepted_result_sha256) " + "SELECT scan_id, id, attempt, status, created_at, completed_at, ?, ? " + "FROM deep_scan_workers WHERE id = ?", + (str(accepted), hashlib.sha256(accepted.read_bytes()).hexdigest(), reducer_id), + ) accepted_summary = reduced["findings"][0]["summary"] reduced["findings"][0]["summary"] = ( "The reducer retained additional independently reviewed evidence." diff --git a/sdk/typescript/tests-ts/deep-finalization.test.ts b/sdk/typescript/tests-ts/deep-finalization.test.ts index d52947088..e4e16fe78 100644 --- a/sdk/typescript/tests-ts/deep-finalization.test.ts +++ b/sdk/typescript/tests-ts/deep-finalization.test.ts @@ -359,6 +359,9 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { scanDir, database: join(stateDir, "workbench.sqlite3"), draft, + terminalReason: loseCompletionResponse + ? "capped" + : "saturated", }), encoding: "utf8", env: environment, diff --git a/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py b/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py index 5b741021c..6f9497f28 100644 --- a/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py +++ b/sdk/typescript/tests-ts/fixtures/selected-deep-scan.py @@ -64,4 +64,17 @@ ) # The committed immutable reference survives loss of the replaceable output. result.unlink() + # A prior writer selected this aggregate before the public reader resumed it. + selection = { + "version": 1, + "resultPath": accepted.relative_to(scan_dir).as_posix(), + "resultSha256": digest, + "terminalReason": payload["terminalReason"], + "omittedWorkerIds": [], + "selectedAt": timestamp, + } + connection.execute( + "UPDATE deep_scan_runs SET finalization_input_json = ?, terminal_reason = ? WHERE scan_id = ?", + (json.dumps(selection), payload["terminalReason"], scan_id), + ) print(json.dumps({"resultPath": str(result), "acceptedPath": str(accepted)})) From ca72760259cc75f43cc5690a11e6f5ce4d988b05 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 11:39:22 +0000 Subject: [PATCH 103/133] Read original settings before reader-stage coordinator adoption --- .../mcp-app/scripts/build_mcp_app.mjs | 1 + plugins/codex-security/mcp-app/server.ts | 10 +- .../src/deep-scan/recovery-settings.ts | 72 +++++-- .../mcp-app/tests/test_deep_scan_executor.mjs | 104 +++++++--- .../test_deep_scan_recovery_settings.mjs | 187 ++++++++++++++---- plugins/codex-security/mcp-app/tsconfig.json | 4 + .../scripts/deep_scan_workbench.py | 30 ++- .../tests/test_reader_settings_claim.py | 110 +++++++++++ sdk/typescript/tests-ts/build-plugin.test.ts | 72 +++++-- 9 files changed, 500 insertions(+), 90 deletions(-) create mode 100644 plugins/codex-security/tests/test_reader_settings_claim.py 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 6b15baa7a..e8e636e6b 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/server.ts b/plugins/codex-security/mcp-app/server.ts index 93204e9da..8c03e0650 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 { 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, @@ -753,8 +753,12 @@ 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, 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: { 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 daa4ad988..2f07eb8e9 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 @@ -25,6 +25,11 @@ export interface DeepScanExecutionSettings { parentSandbox?: DeepWorkerParentSandbox; } +export interface DeepScanLegacySettingsContext { + config?: JsonObject; + usageOwner?: DeepScanRunState["usageOwner"]; +} + export async function captureDeepScanExecutionSettings( original: Pick, parentSandbox: DeepWorkerParentSandbox, @@ -129,12 +134,13 @@ 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 { + 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,8 +151,30 @@ export async function loadOrCaptureDeepScanExecutionSettings( settings = executionSettings(saved.settings); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - settings = executionSettings(await capture()); - return settings; + 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_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."); } if (!original || (settings.model !== undefined && settings.reasoningEffort !== undefined && settings.modelProvider !== undefined && settings.reasoningSummary !== undefined @@ -157,7 +185,9 @@ export async function loadOrCaptureDeepScanExecutionSettings( 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, @@ -167,11 +197,10 @@ export async function loadOrCaptureDeepScanExecutionSettings( ...(settings.serviceTier === undefined && native.nativeServiceTierAbsent ? { nativeServiceTierAbsent: true as const } : {}) }); - return recovered; } export function restoredDeepScanWorkerSettings( - settings: DeepScanExecutionSettings, + settings: Partial, currentParentSandbox: DeepWorkerParentSandbox, environment: () => NodeJS.ProcessEnv = () => process.env ): { @@ -183,6 +212,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, @@ -190,33 +224,37 @@ 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, // 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: { + 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 301e69254..3ed0c6b8c 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({ @@ -16,7 +17,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 +26,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({ @@ -748,22 +749,28 @@ 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"); 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. @@ -779,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 } }; @@ -787,7 +795,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"), [ @@ -807,19 +815,55 @@ 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"); - // Prior-reader fixture: this snapshot was written by the writer release. + 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, 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, + 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); + const recordedScanDir = run.scanDir; + const snapshotPath = path.join(recordedScanDir, "artifacts", "deep_discovery", "execution-settings.json"); + await assert.rejects(readFile(snapshotPath), { code: "ENOENT" }); + // This prior release reads the later writer's existing snapshot. await mkdir(path.dirname(snapshotPath), { recursive: true }); - await writeFile(snapshotPath, JSON.stringify({ version: 1, settings: saved })); + await writeFile(snapshotPath, JSON.stringify({ version: 1, settings: saved }, null, 2) + "\n"); 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, + "recorded provider selections need no persisted catalog definitions"); + 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, 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, 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, recordedScanDir, currentParentSandbox, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, providerKeys, expectedProvider, executor: new CodexSdkWorkerExecutor(restored) }); } childProcess.spawn = (command, args, options) => { @@ -840,23 +884,27 @@ 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)); } - const beforeRead = await readFile(scan.snapshotPath, "utf8"); - const recorded = await loadOrCaptureDeepScanExecutionSettings(scan.fixture.root, () => - assert.fail("reconstruction must not recapture current settings"), { + 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.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"), beforeRead, "the prior reader preserves the stored snapshot bytes"); + assert.equal(await readFile(scan.snapshotPath, "utf8"), snapshotBeforeRead, + "restoring original worker selections must not rewrite saved settings"); } } 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"); @@ -867,7 +915,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"); @@ -880,6 +928,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])}`; @@ -892,6 +942,15 @@ 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]) { + 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"); + } assertReadOnlyWorkerPolicy(child.argv); assertWorkerSubagentPolicy(child.argv, scan.name === "first" ? 0 : 2); assert.equal(workerPermissionProfileOverride(child.argv).includes('"/repo/.env"="deny"'), true); @@ -1815,7 +1874,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 3e58b507a..a32622d21 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"; @@ -14,7 +17,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-")); @@ -28,26 +31,41 @@ try { 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 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 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"); - await assert.rejects(readFile(savedPath), { code: "ENOENT" }); - await writeSettingsFixture(join(root, "one"), first); 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" @@ -68,6 +86,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" @@ -121,6 +177,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" }), @@ -154,10 +234,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 writeSettingsFixture(freshDir, 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); @@ -186,8 +266,8 @@ http_headers = { Authorization = "synthetic-secret" } const tierDir = join(root, "missing-tier"); const { serviceTier: omittedTier, ...withoutTier } = applied; assert.equal(omittedTier, "default"); - await writeSettingsFixture(tierDir, 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"); @@ -215,38 +295,75 @@ 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 writeSettingsFixture(incompleteDir, 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 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"); - assert.deepEqual(JSON.parse(await readFile(repairedPath, "utf8")).settings, incomplete, "the reader does not persist a settings upgrade"); - // Replay a full snapshot that the later writer has already upgraded. - await writeSettingsFixture(incompleteDir, repaired); - 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, async () => assert.fail(), 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 writeSettingsFixture(unknownDir, 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 }); } - -async function writeSettingsFixture(scanDir, settings) { - const directory = join(scanDir, "artifacts", "deep_discovery"); - await mkdir(directory, { recursive: true }); - await writeFile(join(directory, "execution-settings.json"), JSON.stringify({ version: 1, settings })); -} diff --git a/plugins/codex-security/mcp-app/tsconfig.json b/plugins/codex-security/mcp-app/tsconfig.json index ef0922f6b..5f455476e 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/dist/index"] + }, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index 213f5bde5..ec1112dc8 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -671,6 +671,26 @@ def require_legacy_deep_scan_creation(connection: sqlite3.Connection) -> None: raise SystemExit("This Deep Scan database requires a newer version to start a scan.") +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, @@ -1157,10 +1177,16 @@ 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" + scan_dir = Path(scan["scan_dir"]) + if ( + deep_scan_finalization_input(run) is None + and (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/tests/test_reader_settings_claim.py b/plugins/codex-security/tests/test_reader_settings_claim.py new file mode 100644 index 000000000..d693568b5 --- /dev/null +++ b/plugins/codex-security/tests/test_reader_settings_claim.py @@ -0,0 +1,110 @@ +"""Recorded settings are checked before an expired coordinator changes state.""" + +from __future__ import annotations + +import datetime +import json +import sqlite3 +import uuid +from pathlib import Path + +import pytest +from workbench_test_support import run_workbench + + +def database_snapshot(state: Path) -> str: + with sqlite3.connect(state / "workbench.sqlite3") as connection: + return "\n".join(connection.iterdump()) + + +@pytest.mark.parametrize("version", ["deep-security-scan/v1", "deep-scan-mcp/v1"]) +@pytest.mark.parametrize("saved", ["unsupported", "missing-home", "valid", "absent"]) +@pytest.mark.parametrize("live", [False, True], ids=["expired-owner", "live-observer"]) +def test_reader_checks_recorded_settings_before_adoption( + tmp_path: Path, version: str, saved: str, live: bool +) -> None: + state, target = tmp_path / "state", tmp_path / "target" + target.mkdir() + run = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "original-thread", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + "--workflow-version", + version, + )["deepScan"] + scan_dir = Path(run["scanDir"]) + worker_dir = scan_dir / "artifacts" / "deep_discovery" / "worker" + worker_dir.mkdir(parents=True) + prompt = worker_dir / "prompt.md" + prompt.write_text("Original discovery input") + run_workbench( + state, + "upsert-deep-scan-worker", + "--scan-id", + run["scanId"], + "--worker-id", + str(uuid.uuid4()), + "--kind", + "discovery", + "--status", + "running", + "--prompt-path", + str(prompt), + "--artifact-dir", + str(worker_dir), + "--attempt", + "1", + ) + path = worker_dir.parent / "execution-settings.json" + if saved != "absent": + settings = {"codexPath": "/fixture/codex", "codexHome": "/fixture/original-home"} + if saved == "missing-home": + del settings["codexHome"] + path.write_text( + json.dumps({"version": 99 if saved == "unsupported" else 1, "settings": settings}) + ) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE deep_scan_runs SET coordinator_generation = 2, updated_at = ?", + ( + datetime.datetime.now(datetime.timezone.utc).isoformat() + if live + else "2000-01-01T00:00:00Z", + ), + ) + before = database_snapshot(state) + files = { + item.relative_to(scan_dir): item.read_bytes() + for item in scan_dir.rglob("*") + if item.is_file() + } + rejects = not live and saved in {"unsupported", "missing-home"} + result = run_workbench( + state, + "claim-deep-scan-coordinator", + "--scan-id", + run["scanId"], + "--thread-id", + "original-thread", + check=not rejects, + ) + if rejects: + assert result["returncode"] != 0 + assert "settings" in result["stderr"].lower() + assert database_snapshot(state) == before + else: + observed = result + assert observed["coordinatorDisposition"] == ("observing" if live else "adopted") + assert observed["deepScan"]["workflowVersion"] == version + if live: + assert database_snapshot(state) == before + assert { + item.relative_to(scan_dir): item.read_bytes() + for item in scan_dir.rglob("*") + if item.is_file() + } == files diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index 1e70ee07f..4fe7b964a 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 35aabf4a902a29915a04221d447fefe5388dcca0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 11:51:08 +0000 Subject: [PATCH 104/133] Preserve initial reader settings without capturing recovery history --- plugins/codex-security/mcp-app/server.ts | 17 ++++++++------- .../tests/test_deep_scan_stdio_lifecycle.mjs | 17 +++++++++++++-- .../tests/test_reader_release_settings.mjs | 21 +++++++++++-------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 8c03e0650..0840fffc0 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 { loadDeepScanExecutionSettings, restoredDeepScanWorkerSettings, type DeepScanLegacySettingsContext } 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, @@ -753,12 +753,15 @@ export function createCodexSecurityServer(): McpServer { store: deepScanStore, prepareExecutor: async (run) => new CodexSdkWorkerExecutor({ ...restoredDeepScanWorkerSettings( - 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 }; - }), + begun.shouldStart + ? await captureDeepScanExecutionSettings(run, parentSandbox, process.env, + { threadId, startedAt: run.createdAt }) + : 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: { 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 88ab18bd6..6a059f77e 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,16 @@ async function testDeepScanStdioLifecycle() { "the MCP server must remain responsive after canceling one scan" ); - let resumedThreadId = "deep-scan-stdio-resumed-thread"; + const originalThreadId = "deep-scan-stdio-resumed-thread"; + await mkdir(path.join(codexHome, "sessions"), { recursive: true }); + await writeFile(path.join(codexHome, "sessions", "original-owner.jsonl"), [ + { type: "session_meta", timestamp: "2026-01-01T00:00:00Z", + payload: { id: originalThreadId, cli_version: "0.154.0", model_provider: "openai" } }, + { type: "event_msg", timestamp: "2026-01-01T00:00:01Z", + payload: { type: "thread_settings_applied", thread_id: originalThreadId, + thread_settings: { reasoning_summary: "none", model_provider_id: "openai" } } } + ].map(JSON.stringify).join("\n") + "\n"); + let resumedThreadId = originalThreadId; const opened = await server.request(24, "tools/call", toolCall( "open_codex_security_workspace", { targetPath, scope: ".", mode: "deep" }, @@ -491,6 +500,7 @@ async function testDeepScanStdioLifecycle() { assert.equal(partial.userContext, "Original discovery focus"); const settingsPath = path.join(resumedScan.scanDir, "artifacts", "deep_discovery", "execution-settings.json"); await assert.rejects(readFile(settingsPath), { code: "ENOENT" }); + const originalWorkerPids = new Set((await readJsonLines(startLogPath)).slice(restartStartIndex).map((execution) => execution.pid)); 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]); @@ -580,7 +590,10 @@ 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 summary = execution.argv.find((argument) => argument.startsWith("model_reasoning_summary=")); + // Baseline native handoff replaces its owner binding. Without a saved + // recipe or snapshot, the old summary is unavailable after that handoff. + assert.equal(summary, originalWorkerPids.has(execution.pid) ? 'model_reasoning_summary="none"' : undefined); const context = discoveryPromptContext(execution.stdin); if (context.workerLabel) assert.equal(context.userContext, "Original discovery focus"); } diff --git a/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs b/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs index 72a71f8a8..1afb42b89 100644 --- a/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs @@ -13,7 +13,7 @@ const bundle = await build({ format: "esm", write: false, }); -const { loadOrCaptureDeepScanExecutionSettings } = await import( +const { loadDeepScanExecutionSettings } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); @@ -22,10 +22,9 @@ for (const state of ["absent", "saved", "unsupported"]) { const root = await mkdtemp(join(tmpdir(), "reader-settings-")); const path = join(root, "artifacts", "deep_discovery", "execution-settings.json"); try { - const settings = { codexPath: join(root, "codex"), codexHome: root, model: "original-model", parentSandbox: { filesystemDenies: [] } }; - let captures = 0; - const capture = async () => { captures++; return settings; }; - const original = { model: "original-model", reasoningEffort: "high", createdAt: "2026-01-01T00:00:00Z", usageOwner: null }; + let contextReads = 0; + const readLegacyContext = async () => { contextReads++; return { config: {} }; }; + const original = { workflowVersion: "deep-security-scan/v1", model: "original-model", reasoningEffort: "high", createdAt: "2026-01-01T00:00:00Z", usageOwner: null }; let bytes; if (state !== "absent") { await mkdir(join(root, "artifacts", "deep_discovery"), { recursive: true }); @@ -33,13 +32,17 @@ for (const state of ["absent", "saved", "unsupported"]) { await writeFile(path, bytes); } if (state === "unsupported") { - await assert.rejects(loadOrCaptureDeepScanExecutionSettings(root, capture, original), /unsupported/); + await assert.rejects(loadDeepScanExecutionSettings(root, original, readLegacyContext), /unsupported/); } else { - const loaded = await loadOrCaptureDeepScanExecutionSettings(root, capture, original); + const loaded = await loadDeepScanExecutionSettings(root, original, readLegacyContext); assert.equal(loaded.model, "original-model"); - if (state === "saved") assert.equal(loaded.reasoningEffort, "high"); + assert.equal(loaded.reasoningEffort, "high"); + if (state === "absent") { + assert.equal(loaded.codexPath, undefined); + assert.equal(loaded.codexHome, undefined); + } } - assert.equal(captures, state === "absent" ? 1 : 0); + assert.equal(contextReads, state === "absent" ? 1 : 0); if (state === "absent") await assert.rejects(stat(path), { code: "ENOENT" }); else assert.equal(await readFile(path, "utf8"), bytes); } finally { From 587552417548e530606e0a96ab5aa5e138a2a761 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:15:08 +0000 Subject: [PATCH 105/133] Clarify recorded settings reader behavior --- .../codex-security/mcp-app/src/deep-scan/recovery-settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2f07eb8e9..5b40b5153 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 @@ -134,7 +134,7 @@ async function originalParentSettings( } } -/** New runs save settings in their creation transaction, before any coordinator claim. */ +/** Read recorded execution settings or original legacy facts. */ export async function loadDeepScanExecutionSettings( scanDir: string, original?: Pick, From e501431f17d704106f2b33afb45b3e96aaa6cf63 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 09:31:57 +0000 Subject: [PATCH 106/133] 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 3b110f68e..66097fe24 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 000000000..a313bc00c --- /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 8d2d2a442..f85477f8a 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 0e31f97ad..e3356ec4b 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 000000000..854ee85ea --- /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 91eb9edfe8d298352c6de8d65142bd45ac123bea Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:00:56 +0000 Subject: [PATCH 107/133] 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 6c9209972..0553d5ac5 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 2bb4c7d0994e5ba0d24bc53e456e7820405655b0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:58:51 +0000 Subject: [PATCH 108/133] 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 66097fe24..33d89ac26 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 34564b71c0989ced27ee94832e54ca3c0424cbc6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 10:29:33 +0000 Subject: [PATCH 109/133] 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 e4e16fe78..4b1c58856 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"); } @@ -369,7 +400,7 @@ for (const { outcome, budgetCompletionFault, cancellationFault } of cases) { ), ); // Exercise the dedicated function bridge, without extending CLI arguments. - execFileSync( + const selectionOutput = execFileSync( "python3", [ "-c", @@ -386,11 +417,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", @@ -421,9 +456,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() }; @@ -599,9 +632,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], @@ -624,9 +679,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 90baed1c21b404fefa3791139d3b361f3cc011a7 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:58:46 +0000 Subject: [PATCH 110/133] Read saved worker usage from its original Codex home --- .../scripts/workbench_scan_usage.py | 83 +++++++-- .../tests/test_workbench_scan_usage.py | 173 ++++++++++++++++++ .../tests/workbench_test_support.py | 2 +- 3 files changed, 239 insertions(+), 19 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 2abbf45b3..e0543f49e 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 63d80348c..590a488b4 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -6,6 +6,7 @@ import sys import tempfile import uuid +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path @@ -657,6 +658,178 @@ 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" + deep = run_workbench( + root / "state", + "begin-deep-scan", + "--thread-id", + f"owner-{index}", + "--target-path", + str(target), + "--scan-root", + str(root / "scans"), + environment=environment, + )["deepScan"] + scan_id, scan_dir = deep["scanId"], Path(deep["scanDir"]) + snapshot = scan_dir / "artifacts/deep_discovery/execution-settings.json" + assert not snapshot.exists() + # These original facts were persisted by a newer writer release. + snapshot.parent.mkdir(parents=True, exist_ok=True) + snapshot.write_text( + json.dumps( + { + "version": 1, + "settings": { + "codexHome": str(selected_home), + "codexPath": sys.executable, + }, + } + ) + ) + owner_json = json.dumps( + { + "threadId": f"owner-{index}", + "turnId": "original", + "startedAt": deep["createdAt"], + "dedicated": False, + } + ) + with sqlite3.connect(root / "state" / "workbench.sqlite3") as connection: + assert connection.execute( + "SELECT usage_owner_json FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) + ).fetchone() == (None,) + connection.execute( + "UPDATE deep_scan_runs SET usage_owner_json = ? WHERE scan_id = ?", + (owner_json, scan_id), + ) + 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 + with sqlite3.connect(fixture.state_dir / "workbench.sqlite3") as connection: + assert connection.execute( + "SELECT usage_owner_json FROM deep_scan_runs WHERE scan_id = ?", (scan_id,) + ).fetchone() == (owner_json,) + assert connection.execute("SELECT COUNT(*) FROM deep_scan_attempts").fetchone() == (0,) + assert connection.execute( + "SELECT COUNT(*) FROM deep_scan_attempt_sessions" + ).fetchone() == (0,) + 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 dae12b77e..4a9017c1f 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 ea543292b36da078bdb620579edd764e41993fa1 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:28:37 +0000 Subject: [PATCH 111/133] 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 28c13a9ab..c9696f92d 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 077eb4983..b07d23899 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 79595795024528760ead71b497aea242e7f08f2a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 13:12:32 +0000 Subject: [PATCH 112/133] Cancel owned Deep scans across resume and selection boundaries --- sdk/typescript/src/api.ts | 46 +++----- .../tests-ts/deep-finalization.test.ts | 111 ++++++++++++------ 2 files changed, 92 insertions(+), 65 deletions(-) diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 33d89ac26..7bd3c104c 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1960,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); @@ -1983,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; @@ -2497,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) { - 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, [ - "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 4b1c58856..663b4ab64 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 ( @@ -426,6 +453,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", @@ -498,6 +531,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, { @@ -640,14 +674,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 dea9ba45e549bb430d5b159d5e85424cfde9043a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 13:04:27 +0000 Subject: [PATCH 113/133] 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 e0543f49e..52667684b 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 590a488b4..21043202c 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -658,7 +658,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: @@ -783,7 +783,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 (?, ?)", @@ -792,6 +792,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 47c43e4c02b59da2b1529026d9314a39e302ecf0 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 12:52:04 +0000 Subject: [PATCH 114/133] 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 c9696f92d..405455733 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 b07d23899..5789ecf62 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 35cf9ee738a702ec0be21b62a16388d41a415101 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 13:11:10 +0000 Subject: [PATCH 115/133] 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 405455733..4db9da82b 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 5789ecf62..04eafcce5 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 05505704f465a7d9015cfc2b02a8a356b471d0d6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 13:48:48 +0000 Subject: [PATCH 116/133] Allow saved budget finalization to resume in the reader release --- .../scripts/workbench_scan_history.py | 10 ++- .../tests/test_reader_budget_resume.py | 90 +++++++++++++++++++ sdk/typescript/tests-ts/scan-resume.test.ts | 77 ++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 plugins/codex-security/tests/test_reader_budget_resume.py diff --git a/plugins/codex-security/scripts/workbench_scan_history.py b/plugins/codex-security/scripts/workbench_scan_history.py index fc145bc04..e814adda9 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_reader_budget_resume.py b/plugins/codex-security/tests/test_reader_budget_resume.py new file mode 100644 index 000000000..7340e6262 --- /dev/null +++ b/plugins/codex-security/tests/test_reader_budget_resume.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +from argparse import Namespace +from pathlib import Path + +import pytest +from test_deep_scan_successful_publication import publication_scan as publication_scan +from test_stopped_result_version_boundary import snapshot + + +@pytest.mark.parametrize( + "state", + [ + "selected-budget", + "legacy-finished", + "parent-canceled", + "stopping", + "failed", + "canceled", + "unselected", + ], +) +def test_reader_resume_consumes_saved_selection_without_writes( + workbench_api, workbench_db, publication_scan, tmp_path, state +): + scan = publication_scan() + thread = "a23e657b-c14c-4da7-bd20-baa9e7579390" + workbench_api["set_scan_thread"]( + workbench_db, Namespace(scan_id=scan.scan_id, thread_id=thread) + ) + selection = json.dumps( + { + "version": 1, + "resultPath": None, + "resultSha256": None, + "terminalReason": "capped", + "omittedWorkerIds": [], + "selectedAt": scan.timestamp, + } + ) + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET status = ?, cancel_requested = ?, " + "workflow_version = ?, finalization_input_json = ?", + ( + "running" + if state == "stopping" + else state + if state in {"failed", "canceled"} + else "succeeded", + int(state != "legacy-finished"), + "deep-security-scan/v1" if state == "legacy-finished" else "deep-security-scan/v2", + selection if state == "selected-budget" else None, + ), + ) + if state == "parent-canceled": + workbench_db.execute("UPDATE scans SET canceled_at = ?", (scan.timestamp,)) + state_dir = tmp_path / "state" + state_dir.mkdir(mode=0o700) + with sqlite3.connect(state_dir / "workbench.sqlite3") as disk: + workbench_db.backup(disk) + before = snapshot(workbench_db, scan.scan_dir) + script = Path(workbench_api["__file__"]) + result = subprocess.run( + [sys.executable, "-I", "-B", str(script), "get-cli-scan-resume", "--scan-id", scan.scan_id], + env={ + **os.environ, + "CODEX_SECURITY_STATE_DIR": str(state_dir), + "CODEX_HOME": str(tmp_path / "home"), + }, + capture_output=True, + text=True, + ) + with sqlite3.connect(state_dir / "workbench.sqlite3") as disk: + after = snapshot(disk, scan.scan_dir) + assert disk.execute("SELECT COUNT(*) FROM deep_scan_attempts").fetchone() == (0,) + assert disk.execute("SELECT COUNT(*) FROM deep_scan_attempt_sessions").fetchone() == (0,) + assert after == before + if state in {"selected-budget", "legacy-finished"}: + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["scanId"] == scan.scan_id + assert json.loads(result.stdout)["threadId"] == thread + else: + assert result.returncode != 0 + assert "cannot resume" in result.stderr diff --git a/sdk/typescript/tests-ts/scan-resume.test.ts b/sdk/typescript/tests-ts/scan-resume.test.ts index d2cf81023..5cc0628c6 100644 --- a/sdk/typescript/tests-ts/scan-resume.test.ts +++ b/sdk/typescript/tests-ts/scan-resume.test.ts @@ -620,6 +620,83 @@ test.each([ }, ); +test("reader resumes a saved budget selection without model work", async () => { + const f = await interruptedScan(); + await finishDiscovery(f); + const selection = JSON.stringify({ + version: 1, + resultPath: null, + resultSha256: null, + terminalReason: "capped", + omittedWorkerIds: [], + selectedAt: "2000-01-01T00:00:00Z", + }); + // These committed facts came from the later writer before process loss. + const prepared = Bun.spawnSync( + [ + f.python, + "-I", + "-B", + "-c", + "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute(\"UPDATE deep_scan_runs SET workflow_version='deep-security-scan/v2', cancel_requested=1, finalization_input_json=? WHERE scan_id=?\", (sys.argv[3],sys.argv[2])); c.commit()", + join(f.environment.CODEX_SECURITY_STATE_DIR, "workbench.sqlite3"), + f.scanId, + selection, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + expect(prepared.exitCode, new TextDecoder().decode(prepared.stderr)).toBe(0); + const stdout = capture(); + const stderr = capture(); + const code = await main( + ["scans", "resume", f.scanId, "--json"], + stdout.stream, + stderr.stream, + { + ...dependencies({ environment: f.environment, currentDirectory: f.root }), + runWorkbench: f.command, + createSecurity: resumeClient(f, () => ({ + startThread() { + throw new Error("Saved publication must not start a new session"); + }, + resumeThread(threadId) { + expect(threadId).toBe(f.threadId); + return { + id: threadId, + async runStreamed() { + throw new Error( + "Saved publication must not run another model turn", + ); + }, + }; + }, + })), + }, + ); + expect(code, stderr.text()).toBe(2); + expect(stdout.text(), stderr.text()).not.toBe(""); + const result = JSON.parse(stdout.text()); + expect(result.manifest.scan.id).toBe(f.scanId); + expect(result.manifest.scan.sealedAt).toBeString(); + expect(result.coverage.completeness).toBe("partial"); + const conserved = Bun.spawnSync( + [ + f.python, + "-I", + "-B", + "-c", + "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); assert c.execute('SELECT finalization_input_json FROM deep_scan_runs WHERE scan_id=?',(sys.argv[2],)).fetchone()==(sys.argv[3],); assert c.execute('SELECT COUNT(*) FROM deep_scan_attempts').fetchone()==(0,); assert c.execute('SELECT COUNT(*) FROM deep_scan_attempt_sessions').fetchone()==(0,)", + join(f.environment.CODEX_SECURITY_STATE_DIR, "workbench.sqlite3"), + f.scanId, + selection, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + expect(conserved.exitCode, new TextDecoder().decode(conserved.stderr)).toBe( + 0, + ); +}); + test.each([ "single", "bulk", From 1275f4b688b815a154d0ef8019d1d9d23b8f9cef Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 14:25:21 +0000 Subject: [PATCH 117/133] 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 fe2370682..c6781bfb5 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 4db9da82b..860e705bb 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 04eafcce5..cfc1160aa 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 3d5357b93c37cd5614d0bfe8464cbb99986923cb Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 15:01:10 +0000 Subject: [PATCH 118/133] 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 7bd3c104c..ed0777bf6 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 663b4ab64..26190ceb1 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 571c4758a93d43d31dcb37290c2b172e695fc22a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 15:01:10 +0000 Subject: [PATCH 119/133] 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 52667684b..5a4da11c7 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 21043202c..57c98eccb 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -658,11 +658,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"), @@ -771,8 +789,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), ], ) @@ -800,10 +834,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"] @@ -834,7 +892,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 ab1375163d359f109e893d02100060485b585ea2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 15:01:10 +0000 Subject: [PATCH 120/133] Replay sealed budget output in the reader release --- .../codex-security/scripts/workbench_db.py | 5 +- .../scripts/workbench_saved_results.py | 15 ++ .../tests/test_reader_sealed_budget_resume.py | 136 ++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 plugins/codex-security/tests/test_reader_sealed_budget_resume.py diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 2ded9cd58..5a598170c 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1310,7 +1310,10 @@ 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.") + saved_results.validate_sealed_budget_draft( + _WORKBENCH_DB_CONTEXT, scan, scan_dir, manifest + ) + return else: contract = scan_contract(scan) target_contract = contract["target"] diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index cd8999e27..b430e47a9 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -80,6 +80,21 @@ class WorkbenchDbContext: workspace_state: Callable[..., dict[str, Any]] +def validate_sealed_budget_draft( + db: WorkbenchDbContext, scan: sqlite3.Row, scan_dir: Path, manifest: dict[str, Any] +) -> None: + # 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 + + def _encoded(value: Any) -> bytes: return json.dumps( value, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":") diff --git a/plugins/codex-security/tests/test_reader_sealed_budget_resume.py b/plugins/codex-security/tests/test_reader_sealed_budget_resume.py new file mode 100644 index 000000000..d23e55989 --- /dev/null +++ b/plugins/codex-security/tests/test_reader_sealed_budget_resume.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys + +import pytest +import workbench_test_support +from test_workbench_db import BUDGET_COST, BUDGET_WARNING, budget_scan_fixture + + +@pytest.mark.parametrize( + "state", + [ + "retry", + "changed-findings", + "wrong-scan", + "running-discovery", + "canceled", + "other-owner", + "complete", + ], +) +def test_reader_replays_sealed_budget_without_new_writer_state( + workbench_api, monkeypatch, tmp_path, state +): + 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)} + budget_args = [ + "complete-budget-exhausted-scan", + "--scan-id", + scan_id, + "--cost-json", + json.dumps(BUDGET_COST), + "--message", + BUDGET_WARNING, + ] + cut_program = """ +import os, runpy, sys +script, *args = sys.argv[1:] +api = runpy.run_path(script, run_name="sealed_budget_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, *budget_args], + env=environment, + text=True, + capture_output=True, + ) + assert cut.returncode == 86, cut.stderr + manifest_path = scan_dir / "scan-manifest.json" + manifest = json.loads(manifest_path.read_text()) + assert manifest["scan"]["sealedAt"] + assert manifest["scan"]["artifacts"] + database = state_dir / "workbench.sqlite3" + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT status, seal_manifest_digest FROM scans").fetchone() == ( + "running", + None, + ) + assert connection.execute( + "SELECT status, workflow_version, finalization_input_json FROM deep_scan_runs" + ).fetchone() == ("succeeded", "deep-security-scan/v1", None) + + def command(*args): + return subprocess.run( + [sys.executable, "-I", "-B", script, *args], + env=environment, + text=True, + capture_output=True, + ) + + if state == "changed-findings": + path = scan_dir / "findings.json" + findings = json.loads(path.read_text()) + findings["findings"].append({"title": "Changed after sealing"}) + path.write_text(json.dumps(findings)) + elif state == "wrong-scan": + manifest["scan"]["id"] = "69078890-d24c-4416-a6fa-c286825bef88" + manifest_path.write_text(json.dumps(manifest)) + elif state == "running-discovery": + with sqlite3.connect(database) as connection: + connection.execute("UPDATE deep_scan_runs SET status = 'running'") + elif state == "other-owner": + with sqlite3.connect(database) as connection: + connection.execute( + "UPDATE scans SET handoff_claim_token = 'a3292ae4-9b47-430f-8ed6-73ff73db575c'" + ) + elif state == "canceled": + result = command("cancel-scan", "--scan-id", scan_id) + assert result.returncode == 0, result.stderr + elif state == "complete": + result = command( + "complete-scan", "--scan-id", scan_id, "--cost-json", json.dumps(BUDGET_COST) + ) + assert result.returncode == 0, result.stderr + + def snapshot(): + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT COUNT(*) FROM deep_scan_attempts").fetchone() == (0,) + assert connection.execute( + "SELECT COUNT(*) FROM deep_scan_attempt_sessions" + ).fetchone() == (0,) + return list(connection.iterdump()), { + path.relative_to(scan_dir): path.read_bytes() + for path in scan_dir.rglob("*") + if path.is_file() + } + + before = snapshot() + result = command(*budget_args) + after = snapshot() + if state == "retry": + assert result.returncode == 0, result.stderr + assert after[1] == before[1] + with sqlite3.connect(database) as connection: + status, digest = connection.execute( + "SELECT status, seal_manifest_digest FROM scans" + ).fetchone() + assert status == "complete" + assert digest + else: + assert result.returncode != 0 + assert after == before From 8480a14e93384ce34fcfc50311c5e49c8aeef0a2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 15:49:34 +0000 Subject: [PATCH 121/133] Enforce measured cost bounds while preserving unknown totals --- .../codex-security/scripts/workbench_db.py | 7 +- .../scripts/workbench_validation.py | 18 +- .../tests/test_reader_budget_cost.py | 88 +++++++++ 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, 458 insertions(+), 31 deletions(-) create mode 100644 plugins/codex-security/tests/test_reader_budget_cost.py diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 5a598170c..eee09a103 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -137,6 +137,7 @@ from workbench_validation import ( bounded_output_text, optional_text, + parse_budget_scan_cost, parse_scan_cost, path_within_scope, reject_non_finite_json, @@ -1155,9 +1156,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) - if cost_json is None: - raise SystemExit("Budget-exhausted scan completion requires the measured scan cost.") + cost_json, measured = parse_budget_scan_cost(args.cost_json) with scan_completion_lock(scan_id): scan = require_scan(connection, scan_id) if scan["status"] != "running" or scan["mode"] != "deep" or scan["recipe_json"] is None: @@ -1165,8 +1164,6 @@ def complete_budget_exhausted_scan( 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.") - cost = json.loads(cost_json) - measured = cost.get("cost", cost) limit = recipe.get("maxCostUsd") if ( not isinstance(limit, (int, float)) diff --git a/plugins/codex-security/scripts/workbench_validation.py b/plugins/codex-security/scripts/workbench_validation.py index f4f68f5f4..661eb6bad 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"]) @@ -192,6 +195,17 @@ def parse_scan_cost(value: str | None) -> str | None: return json.dumps(cost, separators=(",", ":"), allow_nan=False) +def parse_budget_scan_cost(value: str | None) -> tuple[str | None, Any]: + cost_json = parse_scan_cost(value, allow_lower_bound=True) + if cost_json is None: + raise SystemExit("Budget-exhausted scan completion requires the measured scan cost.") + cost = json.loads(cost_json) + if set(cost) == {"lowerBound"}: + # A priced subtotal proves the stop but is not the saved total estimate. + return None, cost["lowerBound"] + return cost_json, cost.get("cost", cost) + + def bounded_output_text(value: Any, maximum_bytes: int) -> str: encoded = str(value).encode("utf-8")[:maximum_bytes] return encoded.decode("utf-8", errors="ignore") diff --git a/plugins/codex-security/tests/test_reader_budget_cost.py b/plugins/codex-security/tests/test_reader_budget_cost.py new file mode 100644 index 000000000..09c2d2465 --- /dev/null +++ b/plugins/codex-security/tests/test_reader_budget_cost.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys + +import pytest +import workbench_test_support +from test_workbench_db import BUDGET_COST, BUDGET_WARNING, budget_scan_fixture + + +@pytest.mark.parametrize("cost_kind", ["full", "lower-bound", "invalid", "unexceeded", "ordinary"]) +def test_reader_budget_cost_preserves_unknown_totals( + workbench_api, monkeypatch, tmp_path, cost_kind +): + 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) + database = state_dir / "workbench.sqlite3" + cost = ( + BUDGET_COST + if cost_kind == "full" + else { + "lowerBound": None + if cost_kind == "invalid" + else {**BUDGET_COST, "estimatedUsd": 0.005} + if cost_kind == "unexceeded" + else BUDGET_COST + } + ) + + def snapshot(): + with sqlite3.connect(database) as connection: + assert connection.execute("SELECT COUNT(*) FROM deep_scan_attempts").fetchone() == (0,) + assert connection.execute( + "SELECT COUNT(*) FROM deep_scan_attempt_sessions" + ).fetchone() == (0,) + 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() + command = [ + sys.executable, + "-I", + "-B", + script, + "complete-scan" if cost_kind == "ordinary" else "complete-budget-exhausted-scan", + "--scan-id", + scan_id, + "--cost-json", + json.dumps(cost), + *([] if cost_kind == "ordinary" else ["--message", BUDGET_WARNING]), + ] + result = subprocess.run( + command, + env={**os.environ, "CODEX_SECURITY_STATE_DIR": str(state_dir)}, + text=True, + capture_output=True, + ) + after = snapshot() + if cost_kind in {"invalid", "unexceeded", "ordinary"}: + assert result.returncode != 0 + assert after == before + return + assert result.returncode == 0, result.stderr + public = json.loads(result.stdout)["scan"] + with sqlite3.connect(database) as connection: + status, saved = connection.execute("SELECT status, cost_json FROM scans").fetchone() + assert connection.execute( + "SELECT status, workflow_version, finalization_input_json FROM deep_scan_runs" + ).fetchone() == ("succeeded", "deep-security-scan/v1", None) + assert status == "complete" + if cost_kind == "lower-bound": + assert "cost" not in public + saved = json.loads(saved) + assert "cost" not in saved and "estimatedUsd" not in saved + assert saved["usage"]["coverage"] == "unavailable" + else: + assert public["cost"] == BUDGET_COST + assert json.loads(saved) == BUDGET_COST + manifest = json.loads((scan_dir / "scan-manifest.json").read_text()) + assert manifest["scan"]["sealedAt"] diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index ed0777bf6..e838a8980 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 08d057f01..899ffc59e 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 e3356ec4b..1e7d35f56 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 26190ceb1..b9cd6055f 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 = () => @@ -627,6 +667,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) { @@ -672,7 +715,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 fdadd932f735d8ac45029f449f4b1cc8cb51da5e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 15:50:42 +0000 Subject: [PATCH 122/133] Recover recorded worker usage and clear resolved lookup warnings --- .../scripts/workbench_scan_usage.py | 61 ++++++++++++++++++- .../tests/test_workbench_scan_usage.py | 56 +++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_scan_usage.py b/plugins/codex-security/scripts/workbench_scan_usage.py index 5a4da11c7..b00787117 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,10 +172,21 @@ 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: - 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( @@ -586,6 +599,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 57c98eccb..4671818b3 100644 --- a/plugins/codex-security/tests/test_workbench_scan_usage.py +++ b/plugins/codex-security/tests/test_workbench_scan_usage.py @@ -668,6 +668,10 @@ def test_completion_counts_deep_sdk_workers_and_descendants(tmp_path: Path) -> N "recorded-prefix", "current-unreadable", "current-mismatched", + "external-sqlite", + "external-shared-home", + "external-missing-copy", + "external-missing-child", "unavailable", ], ) @@ -701,6 +705,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" deep = run_workbench( root / "state", "begin-deep-scan", @@ -864,6 +870,42 @@ def complete(index: int) -> dict[str, Any]: recorded_threads, [(f"discovery-{index}", child_id)], ) + 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" + 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)], + ) + 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 with sqlite3.connect(fixture.state_dir / "workbench.sqlite3") as connection: @@ -886,6 +928,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 faa31afea19e02ab5e2d1c07fe216ad91f0c3104 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 16:23:52 +0000 Subject: [PATCH 123/133] Reject unsupported workflows before budget completion --- .../codex-security/scripts/workbench_db.py | 4 +- .../tests/test_reader_budget_cost.py | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index eee09a103..f239000bd 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1173,9 +1173,11 @@ 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 run is not None: + deep_scan.require_supported_deep_scan(run) if ( run is None or run["status"] != "succeeded" diff --git a/plugins/codex-security/tests/test_reader_budget_cost.py b/plugins/codex-security/tests/test_reader_budget_cost.py index 09c2d2465..fd474f1e8 100644 --- a/plugins/codex-security/tests/test_reader_budget_cost.py +++ b/plugins/codex-security/tests/test_reader_budget_cost.py @@ -86,3 +86,54 @@ def snapshot(): assert json.loads(saved) == BUDGET_COST manifest = json.loads((scan_dir / "scan-manifest.json").read_text()) assert manifest["scan"]["sealedAt"] + + +@pytest.mark.parametrize("protocol", ["workflow", "selection"]) +def test_reader_budget_rejects_unknown_protocol_before_mutation( + workbench_api, monkeypatch, tmp_path, 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) + database = state_dir / "workbench.sqlite3" + with sqlite3.connect(database) as connection: + if protocol == "workflow": + connection.execute("UPDATE deep_scan_runs SET workflow_version = 'future/v99'") + else: + 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, + "complete-budget-exhausted-scan", + "--scan-id", + scan_id, + "--cost-json", + json.dumps({"lowerBound": BUDGET_COST}), + "--message", + BUDGET_WARNING, + ], + env={**os.environ, "CODEX_SECURITY_STATE_DIR": str(state_dir)}, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "unsupported" in result.stderr.lower() + assert snapshot() == before From 25b02615a61a7be5b5cd134d521d854590a9dc27 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 16:36:32 +0000 Subject: [PATCH 124/133] Validate protocol before parent completion --- .../scripts/deep_scan_workbench.py | 4 +- .../tests/test_reader_budget_cost.py | 51 ---------- .../codex-security/tests/test_workbench_db.py | 93 +++++++++++++++++++ 3 files changed, 96 insertions(+), 52 deletions(-) diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index ec1112dc8..49a365cbe 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -303,9 +303,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/tests/test_reader_budget_cost.py b/plugins/codex-security/tests/test_reader_budget_cost.py index fd474f1e8..09c2d2465 100644 --- a/plugins/codex-security/tests/test_reader_budget_cost.py +++ b/plugins/codex-security/tests/test_reader_budget_cost.py @@ -86,54 +86,3 @@ def snapshot(): assert json.loads(saved) == BUDGET_COST manifest = json.loads((scan_dir / "scan-manifest.json").read_text()) assert manifest["scan"]["sealedAt"] - - -@pytest.mark.parametrize("protocol", ["workflow", "selection"]) -def test_reader_budget_rejects_unknown_protocol_before_mutation( - workbench_api, monkeypatch, tmp_path, 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) - database = state_dir / "workbench.sqlite3" - with sqlite3.connect(database) as connection: - if protocol == "workflow": - connection.execute("UPDATE deep_scan_runs SET workflow_version = 'future/v99'") - else: - 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, - "complete-budget-exhausted-scan", - "--scan-id", - scan_id, - "--cost-json", - json.dumps({"lowerBound": BUDGET_COST}), - "--message", - BUDGET_WARNING, - ], - env={**os.environ, "CODEX_SECURITY_STATE_DIR": str(state_dir)}, - capture_output=True, - text=True, - ) - assert result.returncode != 0 - assert "unsupported" in result.stderr.lower() - assert snapshot() == before diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 5ad21c2c8..e74a0b482 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 b45b980b02933b03093739ea68016de8618345a4 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 16:58:45 +0000 Subject: [PATCH 125/133] 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 49a365cbe..e0ac20d39 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -297,17 +297,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 f239000bd..5d81c2b1f 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1161,6 +1161,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.") @@ -1172,12 +1175,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) if ( run is None or run["status"] != "succeeded" @@ -1446,6 +1444,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 e74a0b482..c0710cc5b 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", [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 dc8edae1b436e082945dd1817054f52500d0382c Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 16:43:53 +0000 Subject: [PATCH 126/133] docs: describe recovery from existing Deep aggregates --- sdk/typescript/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 67ee33cfd..ad229f3f4 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -799,10 +799,10 @@ 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. +When a Deep scan has a saved accepted aggregate, recovering an interrupted +publication 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; From e05e19cbafec5818bd8a31fdecc414102475afb9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 17:58:53 +0000 Subject: [PATCH 127/133] test: resolve reader settings dependencies from MCP install --- .../mcp-app/tests/test_reader_release_settings.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs b/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs index 1afb42b89..27b7d0abb 100644 --- a/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs @@ -8,6 +8,7 @@ import { build } from "esbuild"; const bundle = await build({ bundle: true, + nodePaths: [fileURLToPath(new URL("../node_modules", import.meta.url))], entryPoints: [fileURLToPath(new URL("../src/deep-scan/recovery-settings.ts", import.meta.url))], platform: "node", format: "esm", From 8f60665e8d39da5829f98c64d85fa2549703c6a9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 18:50:11 +0000 Subject: [PATCH 128/133] test: collect original worker processes after shutdown --- .../mcp-app/tests/test_deep_scan_stdio_lifecycle.mjs | 4 +++- 1 file changed, 3 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 6a059f77e..ea2acde6e 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 @@ -500,9 +500,11 @@ async function testDeepScanStdioLifecycle() { assert.equal(partial.userContext, "Original discovery focus"); const settingsPath = path.join(resumedScan.scanDir, "artifacts", "deep_discovery", "execution-settings.json"); await assert.rejects(readFile(settingsPath), { code: "ENOENT" }); - const originalWorkerPids = new Set((await readJsonLines(startLogPath)).slice(restartStartIndex).map((execution) => execution.pid)); await server.stop(); assert.throws(() => process.kill(server.pid, 0), "the original MCP server must have exited"); + // A worker can be marked running before its process appends the start log. + // Capture the original process set after shutdown has settled those launches. + const originalWorkerPids = new Set((await readJsonLines(startLogPath)).slice(restartStartIndex).map((execution) => execution.pid)); const paused = await runWorkbench(environment, ["get-scan", "--scan-id", resumedScanId]); assert.deepEqual([paused.scan.progress.status, paused.scan.progress.phase], ["running", "discovery"]); assert.deepEqual(paused.scan.progress.independentReviews, { From 001d3f75a87c71985546bfc73029d82932859519 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 19:02:31 +0000 Subject: [PATCH 129/133] 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 9793f9d43..fc542cdf6 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 4fe7b964a..020a60788 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 d3f8cd8cf549b7cd17f87b788c5833bbd18e7312 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 12 Sep 2026 19:12:26 +0000 Subject: [PATCH 130/133] 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 020a60788..db52ed062 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 f65be3e3daf211dceeb1cf00233dc6261c6d9667 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 15 Sep 2026 08:24:56 +0000 Subject: [PATCH 131/133] fix(deep-scan): preserve compatible saved-result recovery Read committed execution settings and validate saved publication bindings before recovery. Preserve historical unsealed budget coverage through the existing finalizer, and reconcile the reader with current main. --- .github/workflows/native-musl.yml | 2 +- .github/workflows/native-unix.yml | 2 +- .github/workflows/native-windows.yml | 2 +- .github/workflows/node-ci.yml | 18 +- .github/workflows/node-release.yml | 2 + .github/workflows/test-quality.yml | 9 +- package.json | 2 +- .../mcp-app/scripts/build_mcp_app.mjs | 7 +- plugins/codex-security/mcp-app/server.ts | 27 +- .../mcp-app/src/deep-scan/coordinator.ts | 2 +- .../mcp-app/src/deep-scan/finalization.ts | 12 +- .../src/deep-scan/recovery-settings.ts | 21 +- .../mcp-app/src/deep-scan/store.ts | 8 +- .../mcp-app/src/deep-scan/types.ts | 2 + .../test_deep_scan_artifact_validation.mjs | 2 +- .../mcp-app/tests/test_deep_scan_executor.mjs | 49 +- .../tests/test_deep_scan_finalization.mjs | 42 + .../test_deep_scan_recovery_settings.mjs | 20 +- .../tests/test_reader_release_settings.mjs | 18 +- .../scripts/deep_scan_workbench.py | 30 +- .../scripts/report_projection.py | 6 +- .../scripts/windows_scan_local_files.py | 4 +- .../codex-security/scripts/workbench_db.py | 42 +- .../scripts/workbench_saved_results.py | 266 ++++- .../scripts/workbench_schema.py | 7 + .../skills/fix-finding/SKILL.md | 2 +- .../skills/fix-finding/agents/openai.yaml | 2 +- .../skills/triage-finding/evals/package.json | 2 +- .../triage-finding/evals/pnpm-lock.yaml | 14 +- .../tests/test_deep_scan_compatibility.py | 203 +++- .../test_deep_scan_successful_publication.py | 3 +- .../test_reader_publication_compatibility.py | 241 +++++ .../tests/test_reader_release_persistence.py | 7 +- .../tests/test_reader_settings_claim.py | 78 +- .../tests/test_reader_unsealed_budget.py | 231 ++++ .../tests/test_report_projection.py | 21 + .../codex-security/tests/test_workbench_db.py | 8 +- .../tests/test_workbench_scan_history.py | 19 +- .../test_workbench_setup_and_migrations.py | 8 +- sdk/typescript/README.md | 41 +- sdk/typescript/TESTING.md | 3 + sdk/typescript/package.json | 6 +- sdk/typescript/pnpm-lock.yaml | 994 +++++++++--------- sdk/typescript/pnpm-workspace.yaml | 6 + sdk/typescript/src/api.ts | 16 +- sdk/typescript/src/cli.ts | 32 +- sdk/typescript/src/cost-model.ts | 127 ++- sdk/typescript/src/errors.ts | 4 +- sdk/typescript/src/knowledge-base.ts | 24 +- sdk/typescript/src/scan-dashboard.ts | 80 +- sdk/typescript/src/security-policy-cli.ts | 4 +- sdk/typescript/tests-ts/api-policy.test.ts | 6 + sdk/typescript/tests-ts/api.test.ts | 12 +- sdk/typescript/tests-ts/build-plugin.test.ts | 27 +- .../tests-ts/cli-patch-results.test.ts | 26 +- sdk/typescript/tests-ts/cli-policy.test.ts | 13 + sdk/typescript/tests-ts/cli.test.ts | 23 +- sdk/typescript/tests-ts/cost-context.test.ts | 170 +++ sdk/typescript/tests-ts/cost.test.ts | 9 +- sdk/typescript/tests-ts/errors.test.ts | 2 + .../tests-ts/knowledge-base.test.ts | 30 +- sdk/typescript/tests-ts/mcp-launcher.test.ts | 2 +- sdk/typescript/tests-ts/patch-tui.test.ts | 3 +- sdk/typescript/tests-ts/result.test.ts | 14 +- .../tests-ts/scan-dashboard.test.ts | 64 +- sdk/typescript/tests-ts/scan-resume.test.ts | 2 +- 66 files changed, 2434 insertions(+), 747 deletions(-) create mode 100644 plugins/codex-security/tests/test_reader_publication_compatibility.py create mode 100644 plugins/codex-security/tests/test_reader_unsealed_budget.py create mode 100644 sdk/typescript/tests-ts/cost-context.test.ts diff --git a/.github/workflows/native-musl.yml b/.github/workflows/native-musl.yml index d588ceb60..61c1ffe51 100644 --- a/.github/workflows/native-musl.yml +++ b/.github/workflows/native-musl.yml @@ -39,7 +39,7 @@ jobs: with: persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true diff --git a/.github/workflows/native-unix.yml b/.github/workflows/native-unix.yml index e1bfd5928..931eb8f92 100644 --- a/.github/workflows/native-unix.yml +++ b/.github/workflows/native-unix.yml @@ -45,7 +45,7 @@ jobs: with: persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true diff --git a/.github/workflows/native-windows.yml b/.github/workflows/native-windows.yml index f8c1bced6..a58dd0345 100644 --- a/.github/workflows/native-windows.yml +++ b/.github/workflows/native-windows.yml @@ -31,7 +31,7 @@ jobs: with: persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json - name: Set up Node.js 22 diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 094e69eb4..532a8a56c 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -122,7 +122,7 @@ jobs: - name: Set up pnpm if: steps.scope.outputs.check-markdown == 'true' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true @@ -174,7 +174,7 @@ jobs: with: persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true @@ -234,7 +234,7 @@ jobs: - name: Prepare native runtime uses: ./.github/actions/download-native - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true @@ -286,7 +286,7 @@ jobs: - name: Prepare native runtime uses: ./.github/actions/download-native - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true @@ -386,7 +386,7 @@ jobs: with: persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true @@ -437,7 +437,7 @@ jobs: with: node-version: "22.13.0" - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true @@ -447,7 +447,7 @@ jobs: - name: Compile CI scripts run: pnpm --dir sdk/typescript run build:ci - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: pip @@ -520,7 +520,7 @@ jobs: with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Prepare native runtime @@ -530,7 +530,7 @@ jobs: with: node-version: "22.13.0" - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json - name: Set up Bun diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 5ef6bb6ab..6ec0e69ac 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -13,6 +13,8 @@ concurrency: jobs: native: if: github.repository == 'openai/codex-security' + permissions: + contents: read uses: ./.github/workflows/native-artifacts.yml verify: diff --git a/.github/workflows/test-quality.yml b/.github/workflows/test-quality.yml index de1a0b648..42590aca5 100644 --- a/.github/workflows/test-quality.yml +++ b/.github/workflows/test-quality.yml @@ -78,7 +78,7 @@ jobs: with: node-version: "22.13.0" - name: Set up pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true @@ -127,7 +127,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22.13.0" - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true @@ -163,8 +163,9 @@ jobs: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "22.13.0" - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + # Stryker 10's Babel 8 requires Node 22.18+ or 24.11+. + node-version: "24.15.0" + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: package_json_file: package.json cache: true diff --git a/package.json b/package.json index 36459fae9..c9ab53b66 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,4 @@ { "private": true, - "packageManager": "pnpm@11.19.0+sha512.7881f3ed590d472c4a955e2b88b2121791116066dcc88cbca3849ec9b60f1bbaa6d2ccb221fa91da4e1c65bef2bcbe379365aea7ac539c7bf86dedc3a1b22dce" + "packageManager": "pnpm@11.25.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956" } 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 e8e636e6b..2b2e67842 100644 --- a/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs +++ b/plugins/codex-security/mcp-app/scripts/build_mcp_app.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node +import { existsSync, realpathSync } from "node:fs"; import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; import { execFileSync } from "node:child_process"; import { build } from "esbuild"; @@ -69,8 +70,8 @@ export async function buildMcpApp({ output }) { const invokedPath = process.argv[1]; if ( - invokedPath !== undefined - && pathToFileURL(resolve(invokedPath)).href === import.meta.url + invokedPath !== undefined && existsSync(invokedPath) + && realpathSync(invokedPath) === realpathSync(fileURLToPath(import.meta.url)) ) { const args = process.argv.slice(2); if (args.length !== 2 || args[0] !== "--output") { diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index 0840fffc0..f1a034eb6 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -1670,11 +1670,12 @@ async function runWorkbench( args: string[], input?: string | Buffer, selectFinalization = false, + withExecutionSettings = false, ): Promise { let pythonCommand: string | undefined; try { pythonCommand = await resolvePythonCommand(); - return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization); + return await executeWorkbenchWithStateSelection(pythonCommand, args, input, selectFinalization, withExecutionSettings); } catch (error) { const launchError = pythonCommand ? missingPythonHelperMessage(error, pythonCommand) @@ -1694,35 +1695,36 @@ async function executeWorkbenchWithStateSelection( args: string[], input?: string | Buffer, selectFinalization = false, + withExecutionSettings = 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, withExecutionSettings); } if (CONFIGURED_WORKBENCH_STATE_DIR) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); } if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, withExecutionSettings); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); } return await withWorkbenchStateSelectionLock(async () => { if (fallbackWorkbenchStateDir) { - return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, await fallbackWorkbenchStateDir, input, selectFinalization, withExecutionSettings); } if (persistentWorkbenchStateSucceeded) { - return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + return await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization, withExecutionSettings); } try { - const result = await executeWorkbench(pythonCommand, args, undefined, input, selectFinalization); + 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); + return await executeWorkbench(pythonCommand, args, fallbackStateDir, input, selectFinalization, withExecutionSettings); } }); } @@ -1747,6 +1749,7 @@ async function executeWorkbench( stateDir?: string, input?: string | Buffer, selectFinalization = false, + withExecutionSettings = false, ): Promise { const userContextIndex = args.indexOf("--user-context"); const userContext = userContextIndex === -1 ? undefined : args[userContextIndex + 1]; @@ -1755,8 +1758,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" + : 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]; const execution = execFileAsync(pythonCommand, pythonArgs, { cwd: PLUGIN_ROOT, 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 7dd264617..34210a97d 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/coordinator.ts @@ -933,7 +933,7 @@ export class DeepScanCoordinator { throw new Error("Deep Scan ended without a successfully reduced Standard scan."); } return { - reason: stopReason ?? "capped", + reason: stopReason, omittedWorkerIds: unique(omittedWorkerIds), canceledWorkerIds: unique(canceledWorkerIds), accepted, 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 8e2fb6f4b..06ad7896f 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/finalization.ts @@ -130,6 +130,14 @@ export async function resumeSelectedDeepScan(input: { const store = new WorkbenchDeepScanStore(input.runWorkbench); const run = await store.get(input.scanId, input.threadId); selectedInput(run); + const prepare = [ + "prepare-scan-completion", "--scan-id", input.scanId, + ...(input.handoffClaimToken ? ["--claim-token", input.handoffClaimToken] : []), + ]; + if (run.status === "succeeded") { + await input.runWorkbench(prepare); + return; + } try { await publishSelectedDeepScan({ run, @@ -161,11 +169,13 @@ export async function resumeSelectedDeepScan(input: { }), }); } catch (error) { - // The original coordinator may publish while the SDK recovers its parent turn. + // A succeeded child does not prove that its parent publication is valid. + // Validate the publication and any existing seal; the caller owns completion. const committed = await store .get(input.scanId, input.threadId) .catch(() => null); if (committed?.status !== "succeeded") throw error; + await input.runWorkbench(prepare); } } 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 5b40b5153..222bee459 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 @@ -30,6 +30,11 @@ export interface DeepScanLegacySettingsContext { usageOwner?: DeepScanRunState["usageOwner"]; } +export interface DeepScanExecutionSettingsSnapshot { + version: number; + settings: DeepScanExecutionSettings; +} + export async function captureDeepScanExecutionSettings( original: Pick, parentSandbox: DeepWorkerParentSandbox, @@ -136,23 +141,23 @@ async function originalParentSettings( /** Read recorded execution settings or original legacy facts. */ export async function loadDeepScanExecutionSettings( - scanDir: string, - original?: Pick, + _scanDir: string, + original?: Pick, readLegacyContext?: () => Promise, environment: NodeJS.ProcessEnv = process.env ): Promise> { - const path = join(scanDir, "artifacts", "deep_discovery", "execution-settings.json"); + // Only the workbench creation transaction records the launch selection. Scan + // artifacts are model-writable and cannot select a preflight executable/home. + const saved = original?.executionSettings; let settings: DeepScanExecutionSettings; - try { - const saved = JSON.parse(await fs.readFile(path, "utf8")); + if (saved) { if (saved.version !== 1) { throw new Error("This Deep Scan uses an unsupported execution settings version."); } settings = executionSettings(saved.settings); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } else { if (original?.workflowVersion === "deep-security-scan/v1" || original?.workflowVersion === "deep-scan-mcp/v1") { - // Legacy runs predate this file. Their saved recipe and recorded owner + // Legacy runs predate the binding. 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?.(); 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 b6a8da658..9acc99a0c 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,7 @@ export type WorkbenchRunner = ( args: string[], input?: string, selectFinalization?: boolean, + withExecutionSettings?: boolean, ) => Promise; const WORKFLOW_VERSION = "deep-security-scan/v1"; @@ -177,7 +178,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") { @@ -438,12 +439,13 @@ export class WorkbenchDeepScanStore implements DeepScanStore { retryTransientFailure = false, input?: string, selectFinalization = 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); + : await this.runWorkbench(args, input, selectFinalization, withExecutionSettings); } catch (error) { const scanId = argumentValue(args, "--scan-id"); if (scanId && isStaleCoordinatorGenerationError(error)) { @@ -620,6 +622,8 @@ export function parseDeepScan(result: JsonObject): DeepScanRunState { workflowVersion: optionalString(value.workflowVersion), finalizationInput: parseFinalizationInput(value.finalizationInput), usageOwner: parseUsageOwner(value.usageOwner), + executionSettings: value.executionSettings == null ? undefined + : objectValue(value.executionSettings, "deepScan.executionSettings") as unknown as DeepScanRunState["executionSettings"], 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 505b3722d..2d1cc8f85 100644 --- a/plugins/codex-security/mcp-app/src/deep-scan/types.ts +++ b/plugins/codex-security/mcp-app/src/deep-scan/types.ts @@ -1,5 +1,6 @@ import type { DeepReducerContext } from "../artifact-io.js"; import type { ScanExecutionAttribution } from "../../../../../sdk/typescript/src/scan-sessions.js"; +import type { DeepScanExecutionSettingsSnapshot } from "./recovery-settings.js"; export type DeepScanTerminalReason = "saturated" | "capped"; @@ -53,6 +54,7 @@ export interface DeepScanRunState { workflowVersion?: string; finalizationInput?: DeepScanFinalizationInput; usageOwner?: ScanExecutionAttribution["owner"] | null; + executionSettings?: DeepScanExecutionSettingsSnapshot | null; status: DeepScanRunStatus; phase?: "setup" | "discovery" | "reducing" | "terminal"; coordinatorGeneration?: number; 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 74ad7e755..ed3235dde 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 @@ -202,7 +202,7 @@ async function testReducerValidation(root) { "worker-001", draft([firstFinding]) ); - const second = await createWorker( + await createWorker( artifacts, "discovery-0002", "worker-002", 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 3ed0c6b8c..170a6e9fc 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 @@ -744,6 +744,7 @@ async function testOpenAiCredentialsReachWorker() { } async function testIsolatedReconstructedWorkers() { + const launchFailures = []; const previousMarker = process.env.FAKE_CODEX_MARKER; const originalSpawn = childProcess.spawn; const scans = []; @@ -842,17 +843,24 @@ async function testIsolatedReconstructedWorkers() { const recordedScanDir = run.scanDir; const snapshotPath = path.join(recordedScanDir, "artifacts", "deep_discovery", "execution-settings.json"); await assert.rejects(readFile(snapshotPath), { code: "ENOENT" }); - // This prior release reads the later writer's existing snapshot. + assert.equal(run.workflowVersion, "deep-security-scan/v1"); + // Import the eventual writer's row and projection; this release must not create them. + const envelope = { version: 1, settings: saved }; + const imported = spawnSync(process.env.PYTHON?.trim() || "python3", ["-c", + "import json, sqlite3, sys; c=sqlite3.connect(sys.argv[1]); c.execute(\"UPDATE deep_scan_runs SET execution_settings_json = ? WHERE scan_id = ?\", (sys.argv[3], sys.argv[2])); c.commit()", + path.join(fixture.root, "state", "workbench.sqlite3"), run.scanId, JSON.stringify(envelope) + ], { encoding: "utf8" }); + assert.equal(imported.status, 0, imported.stderr); await mkdir(path.dirname(snapshotPath), { recursive: true }); - await writeFile(snapshotPath, JSON.stringify({ version: 1, settings: saved }, null, 2) + "\n"); + await writeFile(snapshotPath, JSON.stringify(envelope) + "\n"); 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, "recorded provider selections need no persisted catalog definitions"); - assert.deepEqual(await loadDeepScanExecutionSettings(recordedScanDir), saved); const claim = await store.claimCoordinator({ scanId: run.scanId, threadId: beginInput.threadId }); assert.equal(claim.acquired, true); + assert.deepEqual(await loadDeepScanExecutionSettings(recordedScanDir, claim.run), saved); const observer = await new WorkbenchDeepScanStore(runWorkbench).begin({ ...beginInput, model: "observer-model", reasoningEffort: "low" }); @@ -863,25 +871,30 @@ 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, providerKeys, expectedProvider, + scans.push({ name, fixture, run, readRun: async () => (await new WorkbenchDeepScanStore(runWorkbench).claimCoordinator({ scanId: run.scanId, threadId: beginInput.threadId })).run, recordedScanDir, currentParentSandbox, config, configPath, promptPath, settings, runtimeEnvironment, snapshotPath, snapshot, providerKeys, expectedProvider, executor: new CodexSdkWorkerExecutor(restored) }); } 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(); - for (const phase of ["fresh", "resume", "reconstructed", "incomplete"]) { - if (phase === "reconstructed" || phase === "incomplete") { + for (const phase of ["fresh", "resume", "reconstructed-fresh", "reconstructed", "incomplete"]) { + 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") await rm(scan.configPath); + 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((other) => other !== scan).settings.codexOptions.env.CODEX_HOME; + await writeFile(scan.snapshotPath, JSON.stringify(rewritten)); + } if (phase === "incomplete") { const saved = JSON.parse(scan.snapshot); for (const key of ["model", "reasoningEffort", "reasoningSummary"]) delete saved.settings[key]; @@ -890,10 +903,12 @@ async function testIsolatedReconstructedWorkers() { if (scan.name === "first") delete saved.settings.modelProvider; if (scan.name === "first") delete saved.settings.serviceTier; await writeFile(scan.snapshotPath, JSON.stringify(saved)); + // Emulate an older trusted record with missing optional selections. + scan.run.executionSettings = saved; } const snapshotBeforeRead = await readFile(scan.snapshotPath, "utf8"); const recorded = await loadDeepScanExecutionSettings(scan.recordedScanDir, { - ...scan.settings, createdAt: "2026-01-01T00:01:00Z" + ...scan.run, ...scan.settings, createdAt: "2026-01-01T00:01:00Z" }); const restored = restoredDeepScanWorkerSettings(recorded, scan.currentParentSandbox, () => scan.runtimeEnvironment); restored.codexOptions.baseUrl = scan.settings.codexOptions.baseUrl; @@ -910,8 +925,8 @@ async function testIsolatedReconstructedWorkers() { 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) => { - const resumeThreadId = phase === "fresh" ? undefined : `fixture-${scan.name}-resumed`; + const launches = await Promise.allSettled(scans.map(async (scan) => { + const resumeThreadId = ["fresh", "reconstructed-fresh"].includes(phase) ? 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, @@ -957,6 +972,11 @@ async function testIsolatedReconstructedWorkers() { assert.equal(child.argv.includes("resume"), resumeThreadId !== undefined); assert.equal(child.stdin.includes("continuation"), resumeThreadId !== undefined); })); + for (const [index, launch] of launches.entries()) { + if (launch.status === "rejected") { + launchFailures.push(`${scans[index].name}/${phase}/${kind}: ${launch.reason.message}`); + } + } } if (phase === "fresh") { for (const scan of scans) { @@ -964,6 +984,7 @@ async function testIsolatedReconstructedWorkers() { } } } + assert.deepEqual(launchFailures, [], "every actual preflight and worker must retain the original launch selection"); } finally { childProcess.spawn = originalSpawn; syncBuiltinESMExports(); 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 8d725ef84..2a96eb5ea 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 @@ -107,3 +107,45 @@ test("cancellation prevents selected publication and preserves its input", async }), (error) => error === controller.signal.reason); assert.equal(selection.terminalReason, "capped"); }); + +for (const parentStatus of ["running", "complete", "invalid-seal"]) { + test(`recovery verifies the ${parentStatus} parent after a succeeded child`, async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), "selected-parent-"))); + const { resumeSelectedDeepScan } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}`, + ); + const resultPath = "selected.json"; + await writeFile(path.join(root, resultPath), "{}\n"); + const calls = []; + const runWorkbench = async (args) => { + const [command] = args; + calls.push(command); + if (command === "get-deep-scan") return { deepScan: { + scanId, scanDir: root, targetPath: root, scope: ".", + workflowVersion: "deep-security-scan/v2", status: "succeeded", phase: "terminal", + coordinatorGeneration: 2, dispatchedCount: 2, noNewStreak: 0, + config: { workers: 1, subagents: 0, stopAfterNoNew: 1, maxDiscoveryRuns: 2 }, + finalizationInput: { version: 1, resultPath, resultSha256: "0".repeat(64), + terminalReason: "capped", omittedWorkerIds: [], selectedAt: "2026-01-01T00:00:00Z" }, + } }; + if (command === "get-scan") return { scan: { + scanId, scanDir: root, targetPath: root, + progress: { status: parentStatus === "running" ? "running" : "complete" }, + } }; + assert.deepEqual(args, ["prepare-scan-completion", "--scan-id", scanId, "--claim-token", "current-claim"]); + if (parentStatus === "running") throw new Error("Selected input changed after acceptance"); + if (parentStatus === "invalid-seal") throw new Error("Recorded seal does not match"); + return {}; + }; + try { + const recover = () => resumeSelectedDeepScan({ scanId, threadId: "original-parent", + pluginRoot: root, runWorkbench, handoffClaimToken: "current-claim", signal: new AbortController().signal }); + if (parentStatus === "complete") await recover(); + else await assert.rejects(recover(), parentStatus === "running" ? /changed after acceptance/ : /Recorded seal/); + assert.equal(calls.includes("prepare-scan-completion"), true); + assert.equal(await readFile(path.join(root, resultPath), "utf8"), "{}\n"); + } finally { + await rm(root, { recursive: true, force: 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 a32622d21..72d0220b7 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 @@ -17,9 +17,13 @@ const bundle = await build({ platform: "node", write: false }); -const { captureDeepScanExecutionSettings: captureSettings, restoredDeepScanWorkerSettings: restoreSettings, loadDeepScanExecutionSettings: loadSettings } = await import( +const { captureDeepScanExecutionSettings: captureSettings, restoredDeepScanWorkerSettings: restoreSettings, loadDeepScanExecutionSettings: loadRecordedSettings } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].contents).toString("base64")}` ); +const snapshots = new Map(); +const loadSettings = (directory, original, ...rest) => loadRecordedSettings(directory, { + ...original, executionSettings: snapshots.get(directory) +}, ...rest); const root = await mkdtemp(join(tmpdir(), "deep-settings-")); try { const settings = { @@ -51,7 +55,9 @@ try { 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"); + const snapshot = { version: 1, settings: structuredClone(value) }; + snapshots.set(directory, snapshot); + await writeFile(path, JSON.stringify(snapshot, null, 2) + "\n"); }; await assert.rejects(loadSettings(join(root, "missing")), /no recorded original execution settings/); await writeSnapshot(join(root, "one"), settings); @@ -362,8 +368,18 @@ access_key_id = "synthetic-secret" assert.equal(unknown.nativeServiceTierAbsent, undefined); const unsupported = JSON.stringify({ version: 99, settings }); await writeFile(savedPath, unsupported); + assert.deepEqual(await loadSettings(join(root, "one")), settings, + "artifact versions and settings cannot replace trusted run state"); + snapshots.set(join(root, "one"), { version: 99, settings }); await assert.rejects(loadSettings(join(root, "one")), /unsupported/); assert.equal(await readFile(savedPath, "utf8"), unsupported); + await assert.rejects(loadRecordedSettings(join(root, "one"), { + workflowVersion: "deep-security-scan/v2" + }), /no recorded original/, "an existing artifact cannot establish missing launch provenance"); + snapshots.set(join(root, "one"), { version: 1, settings }); + await rm(savedPath); + assert.deepEqual(await loadSettings(join(root, "one")), settings, + "removing the artifact does not remove the trusted launch selection"); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs b/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs index 27b7d0abb..e258565ef 100644 --- a/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs +++ b/plugins/codex-security/mcp-app/tests/test_reader_release_settings.mjs @@ -32,18 +32,12 @@ for (const state of ["absent", "saved", "unsupported"]) { bytes = JSON.stringify({ version: state === "unsupported" ? 99 : 1, settings: { codexPath: join(root, "codex"), codexHome: root, parentSandbox: { filesystemDenies: [] } } }); await writeFile(path, bytes); } - if (state === "unsupported") { - await assert.rejects(loadDeepScanExecutionSettings(root, original, readLegacyContext), /unsupported/); - } else { - const loaded = await loadDeepScanExecutionSettings(root, original, readLegacyContext); - assert.equal(loaded.model, "original-model"); - assert.equal(loaded.reasoningEffort, "high"); - if (state === "absent") { - assert.equal(loaded.codexPath, undefined); - assert.equal(loaded.codexHome, undefined); - } - } - assert.equal(contextReads, state === "absent" ? 1 : 0); + const loaded = await loadDeepScanExecutionSettings(root, original, readLegacyContext); + assert.equal(loaded.model, "original-model"); + assert.equal(loaded.reasoningEffort, "high"); + assert.equal(loaded.codexPath, undefined); + assert.equal(loaded.codexHome, undefined); + assert.equal(contextReads, 1, "legacy recovery ignores planted execution artifacts"); if (state === "absent") await assert.rejects(stat(path), { code: "ENOENT" }); else assert.equal(await readFile(path, "utf8"), bytes); } finally { diff --git a/plugins/codex-security/scripts/deep_scan_workbench.py b/plugins/codex-security/scripts/deep_scan_workbench.py index e0ac20d39..65fd39bfa 100644 --- a/plugins/codex-security/scripts/deep_scan_workbench.py +++ b/plugins/codex-security/scripts/deep_scan_workbench.py @@ -679,6 +679,17 @@ def require_legacy_deep_scan_creation(connection: sqlite3.Connection) -> None: raise SystemExit("This Deep Scan database requires a newer version to start a scan.") +def recorded_deep_scan_execution_settings(run: sqlite3.Row) -> dict[str, Any] | None: + saved = run["execution_settings_json"] if "execution_settings_json" in run.keys() else None + return json.loads(saved) if saved else None + + +def include_execution_settings(connection: sqlite3.Connection, result: dict[str, Any]) -> None: + if "deepScan" in result: + run = require_deep_scan_run(connection, result["deepScan"]["scanId"]) + result["deepScan"]["executionSettings"] = recorded_deep_scan_execution_settings(run) + + 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(): @@ -687,6 +698,10 @@ def read_deep_scan_execution_settings(scan_dir: Path) -> dict[str, Any]: "its executable and Codex home cannot be recovered." ) saved = _read_scan_local_json(scan_dir, relative_path, "Deep Scan execution settings") + return validate_deep_scan_execution_settings(saved) + + +def validate_deep_scan_execution_settings(saved: dict[str, Any]) -> dict[str, Any]: if saved.get("version") != 1: raise SystemExit("This Deep Scan uses an unsupported execution settings version.") settings = saved.get("settings") @@ -1187,12 +1202,15 @@ def claim_deep_scan_coordinator_locked( adopted = run["coordinator_generation"] > 1 or run["phase"] != "setup" disposition = "adopted" if adopted else "claimed" - scan_dir = Path(scan["scan_dir"]) - if ( - deep_scan_finalization_input(run) is None - and (scan_dir / "artifacts/deep_discovery/execution-settings.json").exists() - ): - read_deep_scan_execution_settings(scan_dir) + if deep_scan_finalization_input(run) is None: + saved = recorded_deep_scan_execution_settings(run) + if saved is not None: + validate_deep_scan_execution_settings(saved) + elif run["workflow_version"] == "deep-security-scan/v2": + raise SystemExit( + "This Deep Scan has no recorded original execution settings; " + "its executable and Codex home cannot be recovered." + ) if disposition == "adopted": recover_expired_coordinator(connection, run, timestamp) connection.execute( diff --git a/plugins/codex-security/scripts/report_projection.py b/plugins/codex-security/scripts/report_projection.py index cc404e5f8..a4e363857 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/scripts/windows_scan_local_files.py b/plugins/codex-security/scripts/windows_scan_local_files.py index 0c5581740..c67c86eb6 100644 --- a/plugins/codex-security/scripts/windows_scan_local_files.py +++ b/plugins/codex-security/scripts/windows_scan_local_files.py @@ -465,7 +465,7 @@ def open_read_fd(scan_dir: Path, relative_path: str, context: str) -> int: flags=_FILE_FLAG_OPEN_REPARSE_POINT, ) assert handle is not None and handle.value is not None - try: + with handle: _verify_regular_file(handle.value, path) raw_handle = handle.detach() try: @@ -474,8 +474,6 @@ def open_read_fd(scan_dir: Path, relative_path: str, context: str) -> int: except BaseException: _close_handle(raw_handle) raise - finally: - handle.close() except WindowsScanLocalFileError as exc: raise WindowsScanLocalFileError( exc.errno, diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 5d81c2b1f..cc58bf6a5 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -56,7 +56,6 @@ 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 @@ -1186,19 +1185,13 @@ def complete_budget_exhausted_scan( "Budget-exhausted scan completion requires successfully completed Deep Scan " "discovery." ) - scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) - candidates = ( - [] - if 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: warning = ( f"Deep Scan reached its cost limit after an estimated " f"${measured['estimatedUsd']:.6g}; completed discovery was preserved." ) - budget_exhausted_draft(scan, scan_dir, candidates, warning) + saved_results.prepare_budget_draft(_WORKBENCH_DB_CONTEXT, connection, scan, warning) warnings = json.loads(scan["completion_warnings_json"]) if warning not in warnings: connection.execute( @@ -1286,7 +1279,7 @@ def budget_exhausted_draft( scan_dir: Path, candidates: list[dict[str, Any]], warning: str, -) -> None: +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | 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) @@ -1310,7 +1303,7 @@ def budget_exhausted_draft( saved_results.validate_sealed_budget_draft( _WORKBENCH_DB_CONTEXT, scan, scan_dir, manifest ) - return + return None else: contract = scan_contract(scan) target_contract = contract["target"] @@ -1418,19 +1411,7 @@ def budget_exhausted_draft( } ) 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 + return manifest, findings, coverage def complete_scan_locked( @@ -1526,8 +1507,7 @@ def add_warning() -> None: scan_dir, expected_coverage_mode=expected_coverage_mode(scan), completion_binding=completion_binding, - # Save the finished Deep result as submitted. Worker drafts and - # recovery repairs belong to the stopped-scan path. + # Preserve the finished Deep output. completion_warnings=warnings if scan["mode"] != "deep" else None, draft_documents=saved_results.merge_saved_results( scan_dir, @@ -1544,6 +1524,9 @@ def add_warning() -> None: if scan["mode"] != "deep" and current_manifest_path is not None and not already_sealed else None, ) + saved_results.require_selected_publication( + _WORKBENCH_DB_CONTEXT, connection, scan, prepared + ) add_warning() wrote = True manifest, findings, _ = _write_prepared_scan_finalization(prepared) @@ -2489,7 +2472,7 @@ def require_reviewed_patch_applied( checkout = checkout_root copy_directory_excluding(target, checkout, excluded) else: - checkout = copy_git_worktree_files(target, checkout_root, excluded) + copy_git_worktree_files(target, checkout_root, excluded) arguments = ["apply", "--reverse", "--whitespace=nowarn"] if unversioned: arguments.append("--no-index") @@ -3382,6 +3365,8 @@ def read_json_object(path: Path) -> dict[str, Any]: _WORKBENCH_DB_CONTEXT = saved_results.WorkbenchDbContext( ARTIFACTS=ARTIFACTS, artifact_path=artifact_path, + budget_exhausted_candidates=budget_exhausted_candidates, + budget_exhausted_draft=budget_exhausted_draft, deep_scan=deep_scan, expected_coverage_mode=expected_coverage_mode, handoff=handoff, @@ -3404,8 +3389,7 @@ def read_json_object(path: Path) -> dict[str, Any]: ) -def main(*, select_finalization: bool = False) -> None: - # Workbench callers send UTF-8 even when Windows uses a legacy code page. +def main(*, select_finalization: bool = False, with_execution_settings: bool = False) -> None: sys.stdin.reconfigure(encoding="utf-8") args = parse_args(__doc__) deep_scan.configure( @@ -3655,6 +3639,8 @@ def main(*, select_finalization: bool = False) -> None: result = list_stored_findings(connection, limit=args.limit, offset=args.offset) else: raise SystemExit(f"Unknown command: {args.command}") + if with_execution_settings: + deep_scan.include_execution_settings(connection, result) print(json.dumps(result, allow_nan=False, sort_keys=True)) diff --git a/plugins/codex-security/scripts/workbench_saved_results.py b/plugins/codex-security/scripts/workbench_saved_results.py index b430e47a9..2d6fe4d7c 100644 --- a/plugins/codex-security/scripts/workbench_saved_results.py +++ b/plugins/codex-security/scripts/workbench_saved_results.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from finalize_scan_contract import ( ContractError, + RecoverableContractError, _finding_strength, _populate_unsealed_artifact_envelope, _populate_unsealed_manifest_envelope, @@ -59,6 +60,8 @@ class WorkbenchDbContext: ARTIFACTS: dict[str, str] artifact_path: Callable[..., Path | None] + budget_exhausted_candidates: Callable[..., list[dict[str, Any]]] + budget_exhausted_draft: Callable[..., Any] deep_scan: ModuleType expected_coverage_mode: Callable[..., str] handoff: ModuleType @@ -1474,7 +1477,6 @@ def record_publication(manifest: dict[str, Any], findings: dict[str, Any]) -> No for relative, digest in retained_sources.items() ): raise ContractError("Stopped scan source digests could not be frozen.") - frozen_source_digests = retained_sources with connection: connection.execute( "UPDATE scans SET retained_source_digests_json = ? " @@ -1613,6 +1615,260 @@ 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: + # 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]]) + + +def _require_selected_result(scan: Any, selection: dict[str, Any]) -> None: + relative = selection["resultPath"] + if relative is not None: + _read_saved_result( + Path(scan["scan_dir"]), + relative, + scan["id"], + kind="dedup", + accepted_source_digests={ + str(Path(scan["scan_dir"]) / relative): selection["resultSha256"] + }, + ) + + +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) + 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) + with connection: + connection.execute( + "UPDATE deep_scan_runs SET finalization_input_json = ? WHERE scan_id = ?", + (json.dumps(selection), scan["id"]), + ) + + +def retain_unmerged_budget_coverage( + scan: Any, scan_dir: Path, coverage: dict[str, Any], worker: Any +) -> None: + """Keep each unmerged review's obligations; its findings remain evidence only.""" + accepted_digests = _source_digests( + {worker["accepted_result_path"]: worker["accepted_result_sha256"]}, "Accepted budget" + ) + relative = Path(worker["accepted_result_path"]).relative_to(scan_dir).as_posix() + draft, _ = _read_saved_result( + scan_dir, + relative, + scan["id"], + accepted_source_digests=accepted_digests, + ) + head = _worker_checkpoint_head( + scan_dir, Path(worker["artifact_dir"]).relative_to(scan_dir).as_posix(), scan["id"] + ) + if head is not None and _read_saved_result(scan_dir, head, scan["id"])[0] != draft: + raise ContractError("The accepted discovery differs from its current checkpoint head.") + 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", [])) + } + + 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: + matches = [] + 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() + ) + ): + previous = copy.deepcopy(existing) + previous["provenance"] = {**item["provenance"], **existing_provenance} + if previous != item: + raise ContractError( + "Legacy budget coverage changed from its accepted review." + ) + 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) + + 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 {}), + } + item["id"] = f"{prefix}-{field}-{index + 1}" + if field == "surfaces": + item["id"] = f"{prefix}-surface-{index + 1}" + 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"]] + 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.", + }, + ) + + +def prepare_budget_draft(db: Any, connection: Any, scan: Any, warning: str) -> None: + scan_dir = db.require_canonical_scan_directory(Path(scan["scan_dir"])) + run = db.deep_scan.require_deep_scan_run(connection, scan["id"]) + selection = db.deep_scan.deep_scan_finalization_input(run) + has_publication = selection is not None and "publicationSha256" in selection + candidates = ( + [] + if run["manifest_path"] == str(scan_dir / "scan-manifest.json") + else db.budget_exhausted_candidates(scan, scan_dir) + ) + try: + if has_publication: + documents = tuple( + _read_scan_local_json(scan_dir, name, name) + for name in ("scan-manifest.json", "findings.json", "coverage.json") + ) + prepared = _prepare_scan_finalization( + scan_dir, + expected_coverage_mode=db.expected_coverage_mode(scan), + completion_binding=db.workbench_completion_binding(scan, db.now(), documents[0]), + draft_documents=documents, + ) + manifest = copy.deepcopy(documents[0]) + manifest["scan"].setdefault("id", scan["id"]) + # Budget drafts can omit host-owned scope fields until finalization. + manifest["scan"]["scope"] = { + **prepared[2]["scan"]["scope"], + **manifest["scan"]["scope"], + } + db.verify_manifest_binding(scan, manifest) + require_selected_publication(db, connection, scan, prepared) + documents = db.budget_exhausted_draft(scan, scan_dir, candidates, warning) + if documents is None: + return + if selection is not None and not has_publication: + _require_selected_result(scan, selection) + unmerged = 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() + for worker in unmerged: + retain_unmerged_budget_coverage(scan, scan_dir, documents[2], worker) + prepared = _prepare_scan_finalization( + scan_dir, + expected_coverage_mode=db.expected_coverage_mode(scan), + completion_binding=db.workbench_completion_binding(scan, db.now(), documents[0]), + draft_documents=documents, + ) + manifest = copy.deepcopy(documents[0]) + manifest["scan"].setdefault("id", scan["id"]) + manifest["scan"]["scope"] = { + **prepared[2]["scan"]["scope"], + **manifest["scan"]["scope"], + } + db.verify_manifest_binding(scan, manifest) + for name, payload in ( + ("findings.json", documents[1]), + ("coverage.json", documents[2]), + ("scan-manifest.json", documents[0]), + ): + 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 + if has_publication: + documents = tuple( + _read_scan_local_json(scan_dir, name, name) + for name in ("scan-manifest.json", "findings.json", "coverage.json") + ) + record_selected_publication(db, connection, scan, documents) + except ContractError as exc: + raise SystemExit(str(exc)) from exc + + +def require_selected_publication(db: Any, connection: Any, scan: Any, prepared: Any) -> None: + if scan["mode"] != "deep": + return + run = db.deep_scan.require_deep_scan_run(connection, scan["id"]) + selection = db.deep_scan.deep_scan_finalization_input(run) + if selection is None or "publicationSha256" not in selection: + return + try: + _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." + ) + + def _require_current_deep_publication( db: Any, connection: Any, scan_id: str, draft: dict[str, Any] ) -> None: @@ -1629,6 +1885,14 @@ def _require_current_deep_publication( if publication is None: raise SystemExit("Deep Scan publication requires its committed selection.") scan = db.require_scan(connection, scan_id) + 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"]), + ) + require_selected_publication(db, connection, scan, prepared) selected_result = ( str(Path(scan["scan_dir"]) / selection["resultPath"]) if selection["resultPath"] is not None diff --git a/plugins/codex-security/scripts/workbench_schema.py b/plugins/codex-security/scripts/workbench_schema.py index 5d3f7079d..ef1d20d6c 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 usage_owner_json TEXT; """, ), + ( + 51, + "bind original deep scan execution settings", + """ + ALTER TABLE deep_scan_runs ADD COLUMN execution_settings_json TEXT; + """, + ), ) diff --git a/plugins/codex-security/skills/fix-finding/SKILL.md b/plugins/codex-security/skills/fix-finding/SKILL.md index 1d54d6834..007c1785c 100644 --- a/plugins/codex-security/skills/fix-finding/SKILL.md +++ b/plugins/codex-security/skills/fix-finding/SKILL.md @@ -1,6 +1,6 @@ --- name: fix-finding -description: Use when the user explicitly asks to fix and verify a validated or plausible security finding. Do not use as the primary trigger for full PR, commit, branch, patch, or repository scans. +description: Use only when the user explicitly asks to fix and verify a validated or plausible security vulnerability. Do not use for ordinary bug fixes, correctness or design review findings, general validation, or full PR, commit, branch, patch, or repository scans. --- # Fix Finding diff --git a/plugins/codex-security/skills/fix-finding/agents/openai.yaml b/plugins/codex-security/skills/fix-finding/agents/openai.yaml index e69ed78dd..cf7ed71c9 100644 --- a/plugins/codex-security/skills/fix-finding/agents/openai.yaml +++ b/plugins/codex-security/skills/fix-finding/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Fix Finding" + display_name: "Fix Security Finding" short_description: "Fix and verify a given security finding" default_prompt: "Use $fix-finding to fix a given security finding and verify the issue no longer reproduces." diff --git a/plugins/codex-security/skills/triage-finding/evals/package.json b/plugins/codex-security/skills/triage-finding/evals/package.json index 0fa5fa0b0..23df858bc 100644 --- a/plugins/codex-security/skills/triage-finding/evals/package.json +++ b/plugins/codex-security/skills/triage-finding/evals/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@openai/codex-sdk": "0.154.0", - "@opencode-ai/sdk": "1.18.28", + "@opencode-ai/sdk": "1.18.29", "promptfoo": "0.122.2" } } diff --git a/plugins/codex-security/skills/triage-finding/evals/pnpm-lock.yaml b/plugins/codex-security/skills/triage-finding/evals/pnpm-lock.yaml index 538be0e8a..1e8085f44 100644 --- a/plugins/codex-security/skills/triage-finding/evals/pnpm-lock.yaml +++ b/plugins/codex-security/skills/triage-finding/evals/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: '@openai/codex': 0.154.0 '@openai/codex-sdk': 0.154.0 - '@opencode-ai/sdk': 1.18.28 + '@opencode-ai/sdk': 1.18.29 importers: @@ -17,8 +17,8 @@ importers: specifier: 0.154.0 version: 0.154.0 '@opencode-ai/sdk': - specifier: 1.18.28 - version: 1.18.28 + specifier: 1.18.29 + version: 1.18.29 promptfoo: specifier: 0.122.2 version: 0.122.2(@aws-sdk/credential-provider-node@3.972.82)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(@types/json-schema@7.0.15)(@types/node@25.9.1)(better-sqlite3@12.10.0)(graphql@17.0.2)(pg@8.23.0)(playwright-core@1.62.1)(socks@2.8.10) @@ -1434,8 +1434,8 @@ packages: cpu: [x64] os: [win32] - '@opencode-ai/sdk@1.18.28': - resolution: {integrity: sha512-laFCPzeeYVVjr4WiXdA9t7YGguhEsSJDcteiL2H2zH0KDfGOgHXlYoZNRS9QKkucExpN7MDW9RzrsZoJbEXRzw==} + '@opencode-ai/sdk@1.18.29': + resolution: {integrity: sha512-4CS+FoLPkymTlcga8jxivGDDb2AbWMIIl3b8+myoe2wtv/1ANYCErslgz1xy5hTVHymWE6CtVNKzRuPU0ED57A==} '@opentelemetry/api-logs@0.221.0': resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==} @@ -5761,7 +5761,7 @@ snapshots: '@openai/codex@0.154.0-win32-x64': optional: true - '@opencode-ai/sdk@1.18.28': + '@opencode-ai/sdk@1.18.29': dependencies: cross-spawn: 7.0.6 @@ -8153,7 +8153,7 @@ snapshots: '@openai/agents': 0.11.8(@aws-sdk/credential-provider-node@3.972.82)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.5.4) '@openai/codex-sdk': 0.154.0 '@openai/codex-security': 0.1.27(@types/node@25.9.1)(graphql@17.0.2) - '@opencode-ai/sdk': 1.18.28 + '@opencode-ai/sdk': 1.18.29 '@playwright/browser-chromium': 1.62.1 '@rollup/rollup-linux-x64-gnu': 4.63.1 '@slack/web-api': 8.1.1 diff --git a/plugins/codex-security/tests/test_deep_scan_compatibility.py b/plugins/codex-security/tests/test_deep_scan_compatibility.py index 51b89b25d..1fa0f882b 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,204 @@ 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), + ("deep-security-scan/v2", 1), + ], +) +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", + "deep-security-scan/v1" if workflow == "deep-security-scan/v2" else workflow, + )["deepScan"] + with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute("UPDATE deep_scan_runs SET workflow_version = ?", (workflow,)) + 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) + with sqlite3.connect(state / "workbench.sqlite3") as connection: + columns = {row[1] for row in connection.execute("PRAGMA table_info(deep_scan_runs)")} + if "execution_settings_json" not in columns: + connection.execute( + "ALTER TABLE deep_scan_runs ADD COLUMN execution_settings_json TEXT" + ) + if settings_version != 1: + connection.execute( + "UPDATE deep_scan_runs SET execution_settings_json = ?", (saved.decode(),) + ) + 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 ( + "newer version to resume execution" if workflow.endswith("/v2") else "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"] + artifact = Path(run["scanDir"]) / "artifacts/deep_discovery/execution-settings.json" + artifact.parent.mkdir(parents=True, exist_ok=True) + planted = json.dumps( + { + "version": 99, + "settings": {"codexPath": "/untrusted/codex", "codexHome": "/untrusted/home"}, + } + ) + artifact.write_text(planted) + 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 artifact.read_text() == planted, "legacy recovery ignores model-writable settings" + + +@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 workflow_version = 'deep-security-scan/v2', " + "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"]) def test_supported_workflows_keep_their_identity(tmp_path: Path, version: str) -> None: target = tmp_path / "target" 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 b9c9c9e14..405787c32 100644 --- a/plugins/codex-security/tests/test_deep_scan_successful_publication.py +++ b/plugins/codex-security/tests/test_deep_scan_successful_publication.py @@ -164,7 +164,8 @@ def assert_published_aggregate(scan): findings = json.loads((scan.scan_dir / "findings.json").read_text())["findings"] for finding in findings: for field in ("findingId", "occurrenceId", "fingerprints"): - assert finding.pop(field) + value = finding.pop(field) + assert value assert findings == scan.findings coverage = json.loads((scan.scan_dir / "coverage.json").read_text()) for field in ("documentType", "schemaVersion", "scanId"): diff --git a/plugins/codex-security/tests/test_reader_publication_compatibility.py b/plugins/codex-security/tests/test_reader_publication_compatibility.py new file mode 100644 index 000000000..f14b94caa --- /dev/null +++ b/plugins/codex-security/tests/test_reader_publication_compatibility.py @@ -0,0 +1,241 @@ +"""A reader preserves publication identity recorded by a newer writer.""" + +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): + 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]) + for key in ("completedAt", "sealedAt"): + manifest["scan"].pop(key, None) + encoded = json.dumps( + [manifest, prepared[3], prepared[4]], 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("damage", [None, "findings", "coverage", "selected"]) +def test_reader_completion_validates_saved_publication( + workbench_api, workbench_db, publication_scan, damage +): + scan = publication_scan() + selected = selected_publication(workbench_api, workbench_db, scan) + 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 + + +@pytest.mark.parametrize("damage", ["draft", "selected"]) +def test_reader_republication_checks_identity_before_writing_checkpoints( + workbench_api, workbench_db, publication_scan, damage +): + scan = publication_scan() + result = selected_publication(workbench_api, workbench_db, scan) + 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()) + with pytest.raises( + workbench_api["ContractError"], match="publication|changed after acceptance" + ): + workbench_api["write_scan_draft"](workbench_db, staged) + assert {p: p.read_bytes() for p in scan.scan_dir.rglob("*.json")} == before + assert "\n".join(workbench_db.iterdump()) == state + + +@pytest.mark.parametrize( + "damage", + [ + None, + "findings", + "coverage", + "selected", + "target", + "displayName", + "id", + "includePaths", + "excludePaths", + "null-digest", + "bad-digest", + "claim", + "protocol", + ], +) +@pytest.mark.parametrize("omitted_scope", [False, True]) +def test_reader_budget_validates_publication_before_changing_the_projection( + workbench_api, workbench_db, publication_scan, damage, omitted_scope +): + from test_workbench_db import BUDGET_COST, BUDGET_WARNING + + scan = publication_scan() + scan.coverage["completeness"] = "partial" + scan.coverage["deferred"] = [{"id": "remaining", "reason": "A review remains unresolved."}] + (scan.scan_dir / "coverage.json").write_text(json.dumps(scan.coverage)) + if omitted_scope: + path = scan.scan_dir / "scan-manifest.json" + manifest = json.loads(path.read_bytes()) + manifest["scan"]["scope"].pop("includePaths") + manifest["scan"]["scope"].pop("excludePaths") + path.write_text(json.dumps(manifest)) + selected = selected_publication(workbench_api, workbench_db, scan) + recipe = json.loads(workbench_api["require_scan"](workbench_db, scan.scan_id)["recipe_json"]) + recipe["maxCostUsd"] = 0.005 + with workbench_db: + workbench_db.execute( + "UPDATE scans SET recipe_json = ? WHERE id = ?", (json.dumps(recipe), scan.scan_id) + ) + if damage == "selected": + selected.write_bytes(selected.read_bytes() + b"\n") + elif damage in { + "findings", + "coverage", + "target", + "displayName", + "id", + "includePaths", + "excludePaths", + }: + name = damage if damage in {"findings", "coverage"} else "scan-manifest" + path = scan.scan_dir / f"{name}.json" + document = json.loads(path.read_bytes()) + if damage == "findings": + document["findings"][0]["remediation"] = "Substituted repair." + elif damage == "coverage": + document["deferred"][0]["reason"] = "Substituted unresolved review." + elif damage in {"includePaths", "excludePaths"}: + document["scan"]["scope"][damage] = ["another-path"] + elif damage == "id": + document["scan"]["id"] = "95a98220-0653-47cb-b6b8-b5f125a5b4e7" + else: + document["scan"]["target"]["targetId" if damage == "target" else damage] = ( + "another-target" + ) + path.write_text(json.dumps(document)) + elif damage in {"null-digest", "bad-digest"}: + selection = json.loads( + workbench_db.execute("SELECT finalization_input_json FROM deep_scan_runs").fetchone()[0] + ) + selection["publicationSha256"] = None if damage == "null-digest" else "0" * 64 + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET finalization_input_json = ?", (json.dumps(selection),) + ) + elif damage == "claim": + with workbench_db: + workbench_db.execute( + "UPDATE scans SET handoff_claim_token = '407e80b8-a8a0-412a-8ec7-d4954afba06a'" + ) + elif damage == "protocol": + with workbench_db: + workbench_db.execute("UPDATE deep_scan_runs SET workflow_version = 'future/v9'") + + def snapshot(): + return list(workbench_db.iterdump()), { + p.relative_to(scan.scan_dir): p.read_bytes() + for p in scan.scan_dir.rglob("*") + if p.is_file() + } + + before = snapshot() + run_before = dict(workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone()) + args = Namespace( + scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=BUDGET_WARNING + ) + if damage is not None: + with pytest.raises(SystemExit): + workbench_api["complete_budget_exhausted_scan"](workbench_db, args) + assert snapshot() == before + return + + result = workbench_api["complete_budget_exhausted_scan"](workbench_db, args)["scan"] + assert result["progress"]["status"] == "complete" + run_after = dict(workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone()) + selection_before = json.loads(run_before.pop("finalization_input_json")) + selection_after = json.loads(run_after.pop("finalization_input_json")) + assert run_after == run_before + assert selection_after.pop("publicationSha256") != selection_before.pop("publicationSha256") + assert selection_after == selection_before + coverage = json.loads((scan.scan_dir / "coverage.json").read_bytes()) + assert coverage["completeness"] == "partial" + assert scan.coverage["deferred"][0] in coverage["deferred"] + assert any(item["id"] == "scan-cost-limit" for item in coverage["deferred"]) + assert [f["remediation"] for f in result["findings"]] == [ + f["remediation"] for f in scan.findings + ] + sealed = snapshot() + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert snapshot() == sealed + with pytest.raises(SystemExit, match="Only a running CLI Deep Scan"): + workbench_api["complete_budget_exhausted_scan"](workbench_db, args) + assert snapshot() == sealed diff --git a/plugins/codex-security/tests/test_reader_release_persistence.py b/plugins/codex-security/tests/test_reader_release_persistence.py index 757e6047a..f6f0dac40 100644 --- a/plugins/codex-security/tests/test_reader_release_persistence.py +++ b/plugins/codex-security/tests/test_reader_release_persistence.py @@ -31,7 +31,12 @@ def introduced_metadata(state: Path, scan_dir: Path) -> dict: run = connection.execute("SELECT * FROM deep_scan_runs").fetchone() context = { name: run[name] - for name in ("discovery_user_context", "usage_owner_json", "finalization_input_json") + for name in ( + "discovery_user_context", + "usage_owner_json", + "finalization_input_json", + "execution_settings_json", + ) if name in run.keys() } inputs = list(connection.execute("SELECT * FROM deep_scan_dedup_inputs")) diff --git a/plugins/codex-security/tests/test_reader_settings_claim.py b/plugins/codex-security/tests/test_reader_settings_claim.py index d693568b5..0541f6048 100644 --- a/plugins/codex-security/tests/test_reader_settings_claim.py +++ b/plugins/codex-security/tests/test_reader_settings_claim.py @@ -17,7 +17,9 @@ def database_snapshot(state: 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"] +) @pytest.mark.parametrize("saved", ["unsupported", "missing-home", "valid", "absent"]) @pytest.mark.parametrize("live", [False, True], ids=["expired-owner", "live-observer"]) def test_reader_checks_recorded_settings_before_adoption( @@ -35,7 +37,7 @@ def test_reader_checks_recorded_settings_before_adoption( "--scan-root", str(tmp_path / "scans"), "--workflow-version", - version, + "deep-security-scan/v1" if version == "deep-security-scan/v2" else version, )["deepScan"] scan_dir = Path(run["scanDir"]) worker_dir = scan_dir / "artifacts" / "deep_discovery" / "worker" @@ -69,6 +71,11 @@ def test_reader_checks_recorded_settings_before_adoption( json.dumps({"version": 99 if saved == "unsupported" else 1, "settings": settings}) ) with sqlite3.connect(state / "workbench.sqlite3") as connection: + connection.execute("UPDATE deep_scan_runs SET workflow_version = ?", (version,)) + if version == "deep-security-scan/v2" and saved != "absent": + connection.execute( + "UPDATE deep_scan_runs SET execution_settings_json = ?", (path.read_text(),) + ) connection.execute( "UPDATE deep_scan_runs SET coordinator_generation = 2, updated_at = ?", ( @@ -83,7 +90,7 @@ def test_reader_checks_recorded_settings_before_adoption( for item in scan_dir.rglob("*") if item.is_file() } - rejects = not live and saved in {"unsupported", "missing-home"} + rejects = version == "deep-security-scan/v2" result = run_workbench( state, "claim-deep-scan-coordinator", @@ -95,7 +102,7 @@ def test_reader_checks_recorded_settings_before_adoption( ) if rejects: assert result["returncode"] != 0 - assert "settings" in result["stderr"].lower() + assert "newer version to resume execution" in result["stderr"] assert database_snapshot(state) == before else: observed = result @@ -108,3 +115,66 @@ def test_reader_checks_recorded_settings_before_adoption( for item in scan_dir.rglob("*") if item.is_file() } == files + + +@pytest.mark.parametrize("workflow", ["deep-security-scan/v1", "deep-security-scan/v2"]) +@pytest.mark.parametrize("bound", [False, True]) +def test_reader_private_settings_projection_preserves_public_output( + tmp_path: Path, workflow: str, bound: bool +) -> None: + import os + import subprocess + import sys + + from workbench_test_support import SCRIPT + + state, target = tmp_path / "state", tmp_path / "target" + target.mkdir() + run = run_workbench( + state, + "begin-deep-scan", + "--thread-id", + "reader-owner", + "--target-path", + str(target), + "--scan-root", + str(tmp_path / "scans"), + )["deepScan"] + settings = { + "version": 1, + "settings": {"codexPath": "/fixture/codex", "codexHome": "/fixture/original-home"}, + } + with sqlite3.connect(state / "workbench.sqlite3") as connection: + assert connection.execute( + "SELECT execution_settings_json FROM deep_scan_runs" + ).fetchone() == (None,), "the reader does not write creation settings" + connection.execute( + "UPDATE deep_scan_runs SET workflow_version = ?, execution_settings_json = ?", + (workflow, json.dumps(settings) if bound else None), + ) + before = database_snapshot(state) + args = ["get-deep-scan", "--scan-id", run["scanId"], "--thread-id", "reader-owner"] + public = run_workbench(state, *args) + assert "executionSettings" not in public["deepScan"] + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import runpy, sys; script=sys.argv.pop(1); " + "runpy.run_path(script)['main'](with_execution_settings=True)" + ), + str(SCRIPT), + *args, + ], + env={**os.environ, "CODEX_SECURITY_STATE_DIR": str(state)}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + private = json.loads(result.stdout) + assert private["deepScan"].pop("executionSettings") == (settings if bound else None) + assert private == public + assert database_snapshot(state) == before + assert not (Path(run["scanDir"]) / "artifacts/deep_discovery/execution-settings.json").exists() diff --git a/plugins/codex-security/tests/test_reader_unsealed_budget.py b/plugins/codex-security/tests/test_reader_unsealed_budget.py new file mode 100644 index 000000000..c6bdf5eb0 --- /dev/null +++ b/plugins/codex-security/tests/test_reader_unsealed_budget.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import copy +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_workbench_db import BUDGET_COST + + +@pytest.fixture +def legacy_budget(workbench_db, publication_scan): + def create(*, named=True, duplicate=True): + scan = publication_scan() + worker_id = add_worker(workbench_db, scan).parent.name + result = ( + scan.scan_dir / "artifacts/deep_discovery/workers/discovery-0001/output/result.json" + ) + result.parent.mkdir(parents=True) + receipt = result.parent / "artifacts/review.md" + receipt.parent.mkdir() + receipt.write_text("Synthetic accepted review evidence.\n") + surfaces = [ + { + "label": "Filesystem boundary", + "disposition": "needs_follow_up", + "notes": "The race remains untested.", + "reason": "The caller policy is unknown.", + "receiptRefs": ["artifacts/review.md"], + "provenance": {"source": "independent-review"}, + }, + { + "label": "Configuration boundary", + "disposition": "rejected", + "reason": "Only trusted configuration reaches this path.", + "receiptRefs": ["artifacts/review.md"], + "provenance": {"source": "independent-review"}, + }, + ] + if named: + for index, surface in enumerate(surfaces): + surface["id"] = f"source-{index}" + source = { + "scanId": scan.scan_id, + "complete": True, + "findings": scan.findings, + "coverage": {**scan.coverage, "completeness": "partial", "surfaces": surfaces}, + } + contents = json.dumps(source).encode() + digest = hashlib.sha256(contents).hexdigest() + accepted = result.parent / "checkpoints" / f"{digest}.json" + accepted.parent.mkdir() + accepted.write_bytes(contents) + result.write_text("Later unaccepted output must not replace the accepted checkpoint.") + provenance = {"workerId": worker_id, "attempt": 1} + prefix = f"{worker_id}-attempt-1" + # This is the persisted old-writer shape at its draft commit: one copy + # lacks descriptive provenance; an interrupted projection adds another. + projected = [] + for index, surface in enumerate(surfaces): + item = copy.deepcopy(surface) + item["id"] = f"{prefix}-surface-{index + 1}" + item["receiptRefs"] = [receipt.relative_to(scan.scan_dir).as_posix()] + item["provenance"] = { + **provenance, + **({"sourceId": surface["id"]} if named else {}), + } + projected.append(item) + coverage = { + **scan.coverage, + "completeness": "partial", + "surfaces": projected, + "reviews": [{**provenance, "completeness": "partial"}], + "deferred": [ + { + "id": f"{prefix}-unmerged", + "provenance": provenance, + "reason": "This accepted discovery was not merged before the scan reached its cost limit.", + }, + { + "id": "scan-cost-limit", + "reason": "Validation was deferred because the scan reached its cost limit.", + }, + ], + } + if duplicate: + for item in copy.deepcopy(projected): + item["provenance"]["source"] = "independent-review" + projected.append(item) + (scan.scan_dir / "coverage.json").write_text(json.dumps(coverage)) + (scan.scan_dir / "findings.json").write_text(json.dumps({"findings": []})) + selection = { + "version": 1, + "resultPath": None, + "resultSha256": None, + "terminalReason": "capped", + "omittedWorkerIds": [worker_id], + "selectedAt": scan.timestamp, + } + 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_workers SET merge_state = 'buffered', artifact_dir = ?, " + "result_manifest_path = ? WHERE id = ?", + (str(result.parent), str(result), 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), + ) + workbench_db.execute( + "UPDATE deep_scan_runs SET workflow_version = 'deep-security-scan/v2', " + "terminal_reason = 'capped', finalization_input_json = ?", + (json.dumps(selection),), + ) + scan.accepted, scan.contents, scan.source = accepted, contents, source + scan.receipt, scan.selection, scan.projected = receipt, selection, projected + return scan + + return create + + +def snapshot(connection, scan): + return list(connection.iterdump()), { + str(path.relative_to(scan.scan_dir)): path.read_bytes() + for path in scan.scan_dir.rglob("*") + if path.is_file() + } + + +@pytest.mark.parametrize("named", [False, True]) +@pytest.mark.parametrize("duplicate", [False, True]) +def test_reader_completes_old_unsealed_budget_and_replays( + workbench_api, workbench_db, legacy_budget, named, duplicate +): + scan = legacy_budget(named=named, duplicate=duplicate) + run = dict(workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone()) + attempts = list(workbench_db.execute("SELECT * FROM deep_scan_attempts")) + workbench_api["complete_budget_exhausted_scan"]( + workbench_db, + Namespace(scan_id=scan.scan_id, cost_json=json.dumps(BUDGET_COST), message=None), + ) + assert workbench_db.execute("SELECT status FROM scans").fetchone()[0] == "complete" + assert dict(workbench_db.execute("SELECT * FROM deep_scan_runs").fetchone()) == run + assert list(workbench_db.execute("SELECT * FROM deep_scan_attempts")) == attempts + coverage = json.loads((scan.scan_dir / "coverage.json").read_bytes()) + assert coverage["completeness"] == "partial" + assert len(coverage["surfaces"]) == 2 + for actual, source in zip( + coverage["surfaces"], scan.source["coverage"]["surfaces"], strict=True + ): + assert actual["disposition"] == source["disposition"] + assert actual["reason"] == source["reason"] + assert actual["provenance"]["source"] == "independent-review" + assert actual["receiptRefs"] == [scan.receipt.relative_to(scan.scan_dir).as_posix()] + assert source["reason"] in (scan.scan_dir / "report.md").read_text() + assert json.loads((scan.scan_dir / "findings.json").read_bytes())["findings"] == [] + assert scan.accepted.read_bytes() == scan.contents + before = snapshot(workbench_db, scan) + workbench_api["complete_scan"]( + workbench_db, Namespace(scan_id=scan.scan_id, claim_token=None, cost_json=None) + ) + assert snapshot(workbench_db, scan) == before + + +@pytest.mark.parametrize( + "damage", + [ + "accepted-bytes", + "accepted-null-digest", + "newer-rejection", + "changed-obligation", + "unknown-duplicate", + "null-digest", + ], +) +def test_reader_rejects_damaged_old_budget_before_writes( + workbench_api, workbench_db, legacy_budget, damage +): + scan = legacy_budget() + if damage == "accepted-bytes": + scan.accepted.write_bytes(scan.contents + b" ") + elif damage == "accepted-null-digest": + with workbench_db: + workbench_db.execute("UPDATE deep_scan_attempts SET accepted_result_sha256 = NULL") + elif damage == "newer-rejection": + newer = copy.deepcopy(scan.source) + newer["coverage"]["surfaces"][0]["disposition"] = "rejected" + contents = json.dumps(newer).encode() + name = hashlib.sha256(contents).hexdigest() + ".json" + (scan.accepted.parent / name).write_bytes(contents) + (scan.accepted.parent.parent / "checkpoint-head.json").write_text( + json.dumps({"checkpoint": name}) + ) + elif damage == "null-digest": + with workbench_db: + workbench_db.execute( + "UPDATE deep_scan_runs SET finalization_input_json = ?", + (json.dumps({**scan.selection, "publicationSha256": None}),), + ) + else: + path = scan.scan_dir / "coverage.json" + coverage = json.loads(path.read_bytes()) + if damage == "changed-obligation": + coverage["surfaces"][0]["reason"] = "A different obligation remains unresolved." + else: + coverage["surfaces"].extend( + [{"id": "other", "label": "Other", "disposition": "needs_follow_up"}] * 2 + ) + path.write_text(json.dumps(coverage)) + before = snapshot(workbench_db, scan) + statements = [] + workbench_db.set_trace_callback(statements.append) + 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), + ) + workbench_db.set_trace_callback(None) + assert snapshot(workbench_db, scan) == before + assert not any( + statement.lstrip().split()[0].upper() in {"UPDATE", "DELETE", "INSERT", "REPLACE"} + for statement in statements + ) diff --git a/plugins/codex-security/tests/test_report_projection.py b/plugins/codex-security/tests/test_report_projection.py index 47495d7d5..73c61cbd2 100644 --- a/plugins/codex-security/tests/test_report_projection.py +++ b/plugins/codex-security/tests/test_report_projection.py @@ -905,3 +905,24 @@ def test_projection_includes_surface_evidence_receipts() -> None: markdown = PROJECTION.build_report_markdown(manifest, findings, coverage) assert "Reviewed parser entrypoints. Evidence: artifacts/receipts/parser.jsonl" in markdown + + +@pytest.mark.parametrize("notes", [None, "The filesystem race remains untested."]) +def test_coverage_report_preserves_the_recorded_disposition_reason(notes) -> None: + manifest, findings, coverage = canonical_documents() + surface = { + "id": "filesystem-boundary", + "label": "Filesystem boundary", + "disposition": "needs_follow_up", + "reason": "The caller's filesystem policy is unknown.", + "receiptRefs": ["artifacts/filesystem-review.md"], + } + if notes is not None: + surface["notes"] = notes + coverage["completeness"] = "partial" + coverage["surfaces"] = [surface] + markdown = PROJECTION.build_report_markdown(manifest, findings, coverage) + assert surface["reason"] in markdown + if notes is not None: + assert notes in markdown + assert surface["receiptRefs"][0] in markdown diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index c0710cc5b..272f6cd7e 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -1239,7 +1239,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,) @@ -4257,10 +4257,7 @@ def test_completed_finding_projects_writeup_and_poc_artifact_paths(tmp_path: Pat (fixtures / "payload.txt").write_text("../outside\n") outside = tmp_path / "outside.txt" outside.write_text("must not be projected\n") - try: - (poc / "outside-link.txt").symlink_to(outside) - except OSError: - pass + (poc / "outside-link.txt").symlink_to(outside) completed = run_workbench(state_dir, "complete-scan", "--scan-id", scan_id) assert completed["scan"]["findings"][0]["artifactPaths"] == [ @@ -4373,7 +4370,6 @@ def test_workbench_preserves_dirty_git_scan_after_worktree_changes(tmp_path: Pat scan_id = str(started["results"]["scanId"]) contract = started["results"]["contract"]["target"] assert contract["allowedKinds"] == ["git_worktree"] - snapshot_digest = str(contract["requiredSnapshotDigest"]) write_completed_contract( Path(str(started["results"]["scanDir"])), scan_id, diff --git a/plugins/codex-security/tests/test_workbench_scan_history.py b/plugins/codex-security/tests/test_workbench_scan_history.py index d69d57c3d..2ffcd7659 100644 --- a/plugins/codex-security/tests/test_workbench_scan_history.py +++ b/plugins/codex-security/tests/test_workbench_scan_history.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any +import pytest from test_workbench_db import HEAD_CHANGED_WARNING from test_workbench_deep_scan import begin_target_scan from test_workbench_prompt_only_scan import start_headless_standard_scan, start_prompt_only_scan @@ -442,7 +443,8 @@ def test_cli_scan_preserves_original_revision_when_head_moves(tmp_path: Path) -> assert history["scans"][0]["warnings"] == completed["scan"]["warnings"] -def test_cli_scan_history_persists_per_scan_cost(tmp_path: Path) -> None: +@pytest.mark.parametrize("context_reporting", ["bounded", "unknown_upper", "legacy"]) +def test_cli_scan_history_persists_per_scan_cost(tmp_path: Path, context_reporting: str) -> None: state_dir = tmp_path / "state" repository = tmp_path / "repository" repository.mkdir() @@ -456,12 +458,25 @@ def test_cli_scan_history_persists_per_scan_cost(tmp_path: Path) -> None: "cacheWriteInputTokensReported": False, "pricing": { "source": "https://developers.openai.com/api/docs/pricing", - "asOf": "2026-09-09", + "asOf": "2026-09-09" if context_reporting == "legacy" else "2026-09-14", "serviceTier": "standard", "context": "short", "usdPerMillionTokens": {"input": 4, "cacheRead": 0.4, "cacheWrite": 5, "output": 20}, }, } + if context_reporting != "legacy": + cost["estimatedUsdRange"] = { + "min": 0.00488, + "max": 0.01156 if context_reporting == "bounded" else None, + "context": "unknown", + } + if context_reporting == "bounded": + cost["pricing"]["longContextUsdPerMillionTokens"] = { + "input": 8, + "cacheRead": 0.8, + "cacheWrite": 10, + "output": 30, + } scan = create_cli_scan(state_dir, tmp_path / "results", repository, cost=cost) listed = run_workbench(state_dir, "list-scans", "--repository", str(repository)) 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 c9b4e8734..94dd74fbc 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: (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), (48, "bind original deep scan parent usage turn"), + (51, "bind original deep scan execution settings"), ] 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() == (48,) + assert connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone() == (51,) 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: (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), (48, "bind original deep scan parent usage turn"), + (51, "bind original deep scan execution settings"), ] 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) (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), (48, "bind original deep scan parent usage turn"), + (51, "bind original deep scan execution settings"), ] 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 (46, "persist selected deep scan finalization input"), (47, "freeze stopped scan checkpoint selections"), (48, "bind original deep scan parent usage turn"), + (51, "bind original deep scan execution settings"), ] assert "continuation_thread_id" in { row[1] for row in connection.execute("PRAGMA table_info(scans)") diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index ad229f3f4..c3a8b9a39 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -572,9 +572,10 @@ Working-tree snapshots include files from untracked nested Git repositories. Initialized submodules must be clean and checked out at the commit recorded by the parent repository. -Repeat `--knowledge-base PATH` for Markdown, text, PDF, or Word (`.docx`) files. -Directories are searched recursively. Bulk scans share these documents with -every repository. +Repeat `--knowledge-base PATH` for UTF-8 text files with any extension (including +JSON and SARIF), PDF, or Word (`.docx`) files. Directories are searched recursively, +skipping other binary files. Explicitly supplied unsupported binary files are rejected. +Bulk scans share these documents with every repository. Use an empty output directory outside the scanned directory and enclosing Git worktree. On macOS/Linux, existing directories must be private to you @@ -939,13 +940,19 @@ The final summary preserves missing-data information from a matching session log If the Codex runtime converts an omitted count to zero before recording it, the CLI cannot distinguish that zero from reported usage. -JSON results, scan history, and bulk-scan receipts record the model, tokens, -estimated cost, and `cost.pricing`: the price source, verification date, processing -tier, context category, and rates in USD per million tokens. Estimates use -[standard, short-context API prices](https://developers.openai.com/api/docs/pricing), -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. +Cost displays show a range using +[standard API prices](https://developers.openai.com/api/docs/pricing), because +runtime usage does not identify which requests received long-context pricing. +The minimum assumes short-context pricing; the maximum assumes long-context +pricing. These are token-cost estimates for the observed usage, excluding other +processing tiers, fees, surcharges, and account-specific pricing. + +JSON results, scan history, and bulk-scan receipts preserve +`cost.estimatedUsdRange`: `min`, `max`, and `context: "unknown"`. A `null` maximum +means an upper estimate is unavailable, including models without verified +long-context rates. `cost.pricing` records the price source, verification date, +processing tier, short-context rates, and verified long-context rates when known. +Models without known short-context prices still have no cost estimate. Deep Scan accounting includes failed, replaced, and canceled worker attempts and their descendants once. Shared conversation usage is limited to the original @@ -958,9 +965,15 @@ 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`. -In that case, the estimate prices unclassified input at the ordinary input rate; -it may undercount cache-write charges. Older saved records lack this distinction -and the saved pricing basis. +In that case, the range minimum prices unclassified input as ordinary input, +and the maximum allows it to be cache writes. Token counts remain unchanged. +Older saved records remain readable and display a labeled legacy estimate; +they are not repriced using current rates. + +For compatibility, `cost.estimatedUsd` retains the short-context baseline used +by existing spending limits. `cost.pricing.context: "short"` describes that +baseline, not observed request contexts. Use `estimatedUsdRange` for cost +reporting. This change does not change when spending limits stop scans. `--max-cost USD` stops the scan and its workers when estimated cost exceeds the limit, though in-flight requests can finish above it. If deep-scan @@ -1291,7 +1304,7 @@ Omitting `--rubric` inherits each finding's existing severity without a model ca `--rubric PATH` supplies the classification policy. Repeat `--knowledge-base PATH` to provide supporting architecture, deployment, or business context. Both accept -the same Markdown, text, PDF, DOCX, and directory inputs as scan knowledge bases. +the same UTF-8 text, PDF, DOCX, and directory inputs as scan knowledge bases. Rubric classification uses the full supplied report and context in a separate read-only Codex turn per finding, without source inspection, tools, or new validation. `--model` and `--effort` select the classification model and reasoning diff --git a/sdk/typescript/TESTING.md b/sdk/typescript/TESTING.md index b538f948b..92d3d5a37 100644 --- a/sdk/typescript/TESTING.md +++ b/sdk/typescript/TESTING.md @@ -141,6 +141,9 @@ runtime are not necessarily part of Bun's import graph. ## Mutation testing +Mutation tests require Node 22.18+ or 24.11+ because Stryker 10 uses Babel 8. +CI runs this tooling on Node 24.15.0. The SDK itself still supports Node 22.13+. + ```sh pnpm run test:mutation pnpm exec stryker run --mutate src/worker-progress.ts diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 2fc39cdf0..fcbfa4153 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -79,7 +79,7 @@ "extract-zip": "2.0.1", "fflate": "0.8.3", "incur": "0.5.1", - "ink": "6.8.0", + "ink": "7.1.1", "js-tiktoken": "1.0.21", "papaparse": "5.7.0", "pdfjs-dist": "6.3.289", @@ -91,7 +91,7 @@ }, "devDependencies": { "@openai/apps-sdk-ui": "0.2.2", - "@stryker-mutator/core": "9.6.1", + "@stryker-mutator/core": "10.0.0", "@tailwindcss/postcss": "4.3.3", "@types/bun": "1.4.1", "@types/node": "26.4.1", @@ -102,7 +102,7 @@ "esbuild": "0.28.2", "fast-check": "4.9.0", "ink-testing-library": "4.0.0", - "json-schema-to-typescript": "15.0.4", + "json-schema-to-typescript": "16.0.0", "minimatch": "10.2.6", "postcss": "8.5.28", "prettier": "3.9.6", diff --git a/sdk/typescript/pnpm-lock.yaml b/sdk/typescript/pnpm-lock.yaml index b2f560c0d..ad012bd14 100644 --- a/sdk/typescript/pnpm-lock.yaml +++ b/sdk/typescript/pnpm-lock.yaml @@ -4,6 +4,8 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +packageExtensionsChecksum: sha256-RjyyqsKuq70Yk3NURy5CCORbwKDkJ7H2UOXJwERBpy8= + importers: .: @@ -36,8 +38,8 @@ importers: specifier: 0.5.1 version: 0.5.1 ink: - specifier: 6.8.0 - version: 6.8.0(@types/react@19.2.18)(react@19.2.8) + specifier: 7.1.1 + version: 7.1.1(@types/react@19.2.18)(react@19.2.8) js-tiktoken: specifier: 1.0.21 version: 1.0.21 @@ -67,8 +69,8 @@ importers: specifier: 0.2.2 version: 0.2.2(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) '@stryker-mutator/core': - specifier: 9.6.1 - version: 9.6.1(@types/node@26.4.1) + specifier: 10.0.0 + version: 10.0.0(@types/node@26.4.1) '@tailwindcss/postcss': specifier: 4.3.3 version: 4.3.3 @@ -100,8 +102,8 @@ importers: specifier: 4.0.0 version: 4.0.0(@types/react@19.2.18) json-schema-to-typescript: - specifier: 15.0.4 - version: 15.0.4 + specifier: 16.0.0 + version: 16.0.0 minimatch: specifier: 10.2.6 version: 10.2.6 @@ -126,8 +128,8 @@ importers: packages: - '@alcalzone/ansi-tokenize@0.2.5': - resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} '@alloc/quick-lru@5.2.0': @@ -138,162 +140,194 @@ packages: resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.0': + resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} + '@babel/compat-data@8.0.0': + resolution: {integrity: sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} + '@babel/core@8.0.1': + resolution: {integrity: sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/generator@7.29.8': - resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} - engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-annotate-as-pure@7.29.7': - resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} - engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@8.0.0': + resolution: {integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@8.0.0': + resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-create-class-features-plugin@7.29.7': - resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} - engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@8.0.1': + resolution: {integrity: sha512-++t3ZktzlLmASAxIlxeXQK9Z2YwUafYGYcvGBFevqOqt16HozVHStUoQvWD09fzAZOb/uJGpUTBuGK41AJAuOA==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^8.0.0 - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.0': + resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-member-expression-to-functions@7.29.7': - resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} - engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@8.0.0': + resolution: {integrity: sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@8.0.0': + resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@8.0.1': + resolution: {integrity: sha512-UgAhl1kqiW5ciE0yCXqqvnb4H2n3IELJ7lIIQRezwDPilPEZX5i+Rvbja9MFTkwUn2biEiSMeV31aUzR4Lwakw==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^8.0.0 - '@babel/helper-optimise-call-expression@7.29.7': - resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} - engines: {node: '>=6.9.0'} + '@babel/helper-optimise-call-expression@8.0.0': + resolution: {integrity: sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@8.0.1': + resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 - '@babel/helper-replace-supers@7.29.7': - resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} - engines: {node: '>=6.9.0'} + '@babel/helper-replace-supers@8.0.1': + resolution: {integrity: sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': ^8.0.0 - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': - resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} - engines: {node: '>=6.9.0'} + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + resolution: {integrity: sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} - engines: {node: '>=6.9.0'} + '@babel/helpers@8.0.0': + resolution: {integrity: sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/parser@7.29.8': - resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} - engines: {node: '>=6.0.0'} + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + engines: {node: ^22.18.0 || >=24.11.0} hasBin: true - '@babel/plugin-proposal-decorators@7.29.7': - resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} - engines: {node: '>=6.9.0'} + '@babel/plugin-proposal-decorators@8.0.2': + resolution: {integrity: sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-syntax-decorators@7.29.7': - resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} - engines: {node: '>=6.9.0'} + '@babel/plugin-syntax-decorators@8.0.1': + resolution: {integrity: sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-syntax-jsx@7.29.7': - resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} - engines: {node: '>=6.9.0'} + '@babel/plugin-syntax-jsx@8.0.1': + resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-syntax-typescript@7.29.7': - resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} - engines: {node: '>=6.9.0'} + '@babel/plugin-syntax-typescript@8.0.3': + resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-transform-destructuring@7.29.7': - resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} - engines: {node: '>=6.9.0'} + '@babel/plugin-transform-destructuring@8.0.1': + resolution: {integrity: sha512-RtR8uLDl0QcCmqMNIkM8gmDeYZ3rS0ZH+sa+I6sfc09yFoqfp9AEPgBstq9KyfVb0lFCVSRFfJXCI70FIl5ccw==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-transform-explicit-resource-management@7.29.7': - resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} - engines: {node: '>=6.9.0'} + '@babel/plugin-transform-explicit-resource-management@8.0.1': + resolution: {integrity: sha512-VzDIYwBlLCpV6mJfloRdJm8HmYnMqs7O+bGha8yfg2kP7jAdxeCw6yZBVBeaKKQUThtSU52iy+3lB7DhYsbOBA==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-transform-modules-commonjs@7.29.7': - resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} - engines: {node: '>=6.9.0'} + '@babel/plugin-transform-modules-commonjs@8.0.1': + resolution: {integrity: sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-transform-typescript@7.29.7': - resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} - engines: {node: '>=6.9.0'} + '@babel/plugin-transform-react-display-name@8.0.1': + resolution: {integrity: sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} - engines: {node: '>=6.9.0'} + '@babel/plugin-transform-react-jsx-development@8.0.1': + resolution: {integrity: sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx@8.0.1': + resolution: {integrity: sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-pure-annotations@8.0.1': + resolution: {integrity: sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-typescript@8.0.1': + resolution: {integrity: sha512-0Svqp3413Eg0GElldykF/T7SNsxQO5YVGD70fZyAdZTnX8WRgcopmbiU7GTa5xY5ZnJcEpNbfns8/GjX+/1yeA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/preset-react@8.0.1': + resolution: {integrity: sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/preset-typescript@8.0.1': + resolution: {integrity: sha512-qrPhQIN1NLrPmzgazF9XKQqXrOcp/WJly+K+6ReFonn24FZqRJO7clxOJo6Ni75L+2vAqI3cHVU2OJLBxoPp5A==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/traverse@7.29.8': - resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} - engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.4': + resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} - engines: {node: '>=6.9.0'} + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + engines: {node: ^22.18.0 || >=24.11.0} '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} @@ -621,6 +655,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -1492,21 +1529,21 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@stryker-mutator/api@9.6.1': - resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/api@10.0.0': + resolution: {integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==} + engines: {node: '>=22.0.0'} - '@stryker-mutator/core@9.6.1': - resolution: {integrity: sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/core@10.0.0': + resolution: {integrity: sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw==} + engines: {node: '>=22.0.0'} hasBin: true - '@stryker-mutator/instrumenter@9.6.1': - resolution: {integrity: sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/instrumenter@10.0.0': + resolution: {integrity: sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ==} + engines: {node: '>=22.0.0'} - '@stryker-mutator/util@9.6.1': - resolution: {integrity: sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==} + '@stryker-mutator/util@10.0.0': + resolution: {integrity: sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1615,17 +1652,23 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} - '@types/lodash@4.17.24': - resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1785,22 +1828,19 @@ packages: '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - angular-html-parser@10.4.0: - resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} + angular-html-parser@10.11.0: + resolution: {integrity: sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==} engines: {node: '>= 14'} ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} ansi-styles@6.2.3: @@ -1828,8 +1868,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.13: - resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1840,8 +1880,8 @@ packages: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} - browserslist@4.28.8: - resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1859,8 +1899,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001809: - resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1884,17 +1924,17 @@ packages: chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} cli-cursor@4.0.0: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} @@ -1973,12 +2013,16 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.404: - resolution: {integrity: sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==} + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2006,8 +2050,8 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - es-toolkit@1.50.0: - resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} @@ -2183,6 +2227,9 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + incur@0.5.1: resolution: {integrity: sha512-gEwPg0Rwf+Oi8pwySLumYgWk//Xiv2wRJhwsOwVskt7ZHrfwFTR7oQmANnyDqudRYhqK3I5/dp489kPehPjIww==} engines: {node: '>=22'} @@ -2204,12 +2251,12 @@ packages: '@types/react': optional: true - ink@6.8.0: - resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} - engines: {node: '>=20'} + ink@7.1.1: + resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} + engines: {node: '>=22'} peerDependencies: - '@types/react': '>=19.0.0' - react: '>=19.0.0' + '@types/react': '>=19.2.0' + react: '>=19.2.0' react-devtools-core: '>=6.1.2' peerDependenciesMeta: '@types/react': @@ -2229,18 +2276,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - is-fullwidth-code-point@5.1.0: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -2274,11 +2313,15 @@ packages: js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} hasBin: true jsesc@3.1.0: @@ -2286,11 +2329,11 @@ packages: engines: {node: '>=6'} hasBin: true - json-rpc-2.0@1.7.1: - resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==} + json-rpc-2.0@1.8.0: + resolution: {integrity: sha512-4nw+XlJbk5XokA7BtqHGu+0PEiUPfAkRzXXd1Cgjy1c3F2UI/3Gy7yr76wyJEv3X1F1SRGGF8xPAPPhelBiGmA==} - json-schema-to-typescript@15.0.4: - resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + json-schema-to-typescript@16.0.0: + resolution: {integrity: sha512-Ah6kK4q6SjlMzWBH1eNOQkdRh5Qhr5T/RCFwfjQqWZA7UDW+N47A8wfyoiN0L884s4L4bAEI54IjIuN3/u0B/A==} engines: {node: '>=16.0.0'} hasBin: true @@ -2401,8 +2444,9 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} luxon@3.7.1: resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==} @@ -2583,14 +2627,14 @@ packages: resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} engines: {node: '>=18'} - mutation-testing-elements@3.7.3: - resolution: {integrity: sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==} + mutation-testing-elements@3.8.4: + resolution: {integrity: sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==} - mutation-testing-metrics@3.7.3: - resolution: {integrity: sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==} + mutation-testing-metrics@3.8.4: + resolution: {integrity: sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==} - mutation-testing-report-schema@3.7.3: - resolution: {integrity: sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==} + mutation-testing-report-schema@3.8.4: + resolution: {integrity: sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==} mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} @@ -2601,8 +2645,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-releases@2.0.53: - resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} npm-run-path@6.0.0: @@ -2613,6 +2657,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2655,8 +2703,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} postcss@8.5.28: @@ -2668,8 +2716,8 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} prismjs@1.30.0: @@ -2814,15 +2862,6 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2859,9 +2898,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} smol-toml@1.8.0: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} @@ -2882,10 +2921,6 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - string-width@8.2.2: resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} @@ -2946,8 +2981,8 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - type-fest@5.8.0: - resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + type-fest@5.9.0: + resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} engines: {node: '>=20'} typed-inject@5.0.0: @@ -2958,6 +2993,11 @@ packages: resolution: {integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==} engines: {node: '>= 16.0.0'} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -3000,8 +3040,8 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - update-browserslist-db@1.3.1: - resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3041,8 +3081,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - weapon-regex@1.3.6: - resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==} + weapon-regex@2.0.5: + resolution: {integrity: sha512-BJZkSdtcae9Wb8+hKNtAqR+Q61EMOAuQnpCCHKwT50K7fIWg7/2/RnbKR27YB+sMcYxYLUVj+Asm5Y2XhD8mZg==} web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -3056,9 +3096,9 @@ packages: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} + wrap-ansi@10.0.1: + resolution: {integrity: sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==} + engines: {node: '>=20'} wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3078,9 +3118,6 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -3104,7 +3141,7 @@ packages: snapshots: - '@alcalzone/ansi-tokenize@0.2.5': + '@alcalzone/ansi-tokenize@0.3.0': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 @@ -3115,225 +3152,233 @@ snapshots: dependencies: '@jsdevtools/ono': 7.1.3 '@types/json-schema': 7.0.15 - js-yaml: 4.3.1 + js-yaml: 4.3.2 - '@babel/code-frame@7.29.7': + '@babel/code-frame@8.0.0': dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@babel/helper-validator-identifier': 8.0.4 + js-tokens: 10.0.0 - '@babel/compat-data@7.29.7': {} + '@babel/compat-data@8.0.0': {} - '@babel/core@7.29.7': + '@babel/core@8.0.1': dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - '@jridgewell/remapping': 2.3.5 + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-compilation-targets': 8.0.0 + '@babel/helpers': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@types/gensync': 1.0.5 convert-source-map: 2.0.0 - debug: 4.4.3 + empathic: 2.0.1 gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + obug: 2.1.4 + semver: 7.8.5 - '@babel/generator@7.29.8': + '@babel/generator@8.0.0': dependencies: - '@babel/parser': 7.29.8 - '@babel/types': 7.29.8 + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.29.7': + '@babel/helper-annotate-as-pure@8.0.0': dependencies: - '@babel/types': 7.29.8 + '@babel/types': 8.0.4 - '@babel/helper-compilation-targets@7.29.7': + '@babel/helper-compilation-targets@8.0.0': dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.8 - lru-cache: 5.1.1 - semver: 6.3.1 + '@babel/compat-data': 8.0.0 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.28.9 + lru-cache: 11.5.2 + semver: 7.8.5 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.8 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-member-expression-to-functions': 8.0.0 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/helper-replace-supers': 8.0.1(@babel/core@8.0.1) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/traverse': 8.0.4 + semver: 7.8.5 - '@babel/helper-globals@7.29.7': {} + '@babel/helper-globals@8.0.0': {} - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@8.0.0': dependencies: - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@8.0.0': dependencies: - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + '@babel/traverse': 8.0.4 - '@babel/helper-optimise-call-expression@7.29.7': + '@babel/helper-optimise-call-expression@8.0.0': dependencies: - '@babel/types': 7.29.8 + '@babel/types': 8.0.4 - '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + '@babel/helper-replace-supers@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.8 - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-member-expression-to-functions': 8.0.0 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/traverse': 8.0.4 - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': dependencies: - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 - '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0': {} - '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.4': {} - '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-validator-option@8.0.0': {} - '@babel/helpers@7.29.7': + '@babel/helpers@8.0.0': dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 - '@babel/parser@7.29.8': + '@babel/parser@8.0.4': dependencies: - '@babel/types': 7.29.8 + '@babel/types': 8.0.4 - '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-create-class-features-plugin': 8.0.1(@babel/core@8.0.1) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-destructuring@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8 - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-explicit-resource-management@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-destructuring': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.29.7 - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-module-transforms': 8.0.1(@babel/core@8.0.1) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-display-name@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-development@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.1) + + '@babel/plugin-transform-react-jsx@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-syntax-jsx': 8.0.1(@babel/core@8.0.1) + '@babel/types': 8.0.4 + + '@babel/plugin-transform-react-pure-annotations@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + + '@babel/plugin-transform-typescript@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-create-class-features-plugin': 8.0.1(@babel/core@8.0.1) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/plugin-syntax-typescript': 8.0.3(@babel/core@8.0.1) + + '@babel/preset-react@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-react-display-name': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-react-jsx-development': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-react-pure-annotations': 8.0.1(@babel/core@8.0.1) + + '@babel/preset-typescript@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-modules-commonjs': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-typescript': 8.0.1(@babel/core@8.0.1) '@babel/runtime@7.29.7': {} - '@babel/template@7.29.7': + '@babel/template@8.0.0': dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/types': 7.29.8 + '@babel/code-frame': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 - '@babel/traverse@7.29.8': + '@babel/traverse@8.0.4': dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-globals': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 + obug: 2.1.4 - '@babel/types@7.29.8': + '@babel/types@8.0.4': dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 '@cfworker/json-schema@4.1.1': {} @@ -3557,7 +3602,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -3569,10 +3614,12 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jsdevtools/ono@7.1.3': {} @@ -4484,32 +4531,32 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@stryker-mutator/api@9.6.1': + '@stryker-mutator/api@10.0.0': dependencies: - mutation-testing-metrics: 3.7.3 - mutation-testing-report-schema: 3.7.3 + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 tslib: 2.8.1 typed-inject: 5.0.0 - '@stryker-mutator/core@9.6.1(@types/node@26.4.1)': + '@stryker-mutator/core@10.0.0(@types/node@26.4.1)': dependencies: '@inquirer/prompts': 8.7.1(@types/node@26.4.1) - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/instrumenter': 9.6.1 - '@stryker-mutator/util': 9.6.1 - ajv: 8.18.0 + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/instrumenter': 10.0.0 + '@stryker-mutator/util': 10.0.0 + ajv: 8.20.0 chalk: 5.6.2 commander: 14.0.3 diff-match-patch: 1.0.5 emoji-regex: 10.6.0 execa: 9.6.1 - json-rpc-2.0: 1.7.1 + json-rpc-2.0: 1.8.0 lodash.groupby: 4.6.0 minimatch: 10.2.6 mutation-server-protocol: 0.4.1 - mutation-testing-elements: 3.7.3 - mutation-testing-metrics: 3.7.3 - mutation-testing-report-schema: 3.7.3 + mutation-testing-elements: 3.8.4 + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 npm-run-path: 6.0.0 progress: 2.0.3 rxjs: 7.8.2 @@ -4519,28 +4566,28 @@ snapshots: tslib: 2.8.1 typed-inject: 5.0.0 typed-rest-client: 2.3.1 + typescript: 6.0.3 transitivePeerDependencies: - '@types/node' - - supports-color - '@stryker-mutator/instrumenter@9.6.1': - dependencies: - '@babel/core': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/parser': 7.29.8 - '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/util': 9.6.1 - angular-html-parser: 10.4.0 - semver: 7.7.4 + '@stryker-mutator/instrumenter@10.0.0': + dependencies: + '@babel/core': 8.0.1 + '@babel/generator': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@8.0.1) + '@babel/plugin-transform-explicit-resource-management': 8.0.1(@babel/core@8.0.1) + '@babel/preset-react': 8.0.1(@babel/core@8.0.1) + '@babel/preset-typescript': 8.0.1(@babel/core@8.0.1) + '@babel/traverse': 8.0.4 + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/util': 10.0.0 + angular-html-parser: 10.11.0 + semver: 7.8.5 tslib: 2.8.1 - weapon-regex: 1.3.6 - transitivePeerDependencies: - - supports-color + weapon-regex: 2.0.5 - '@stryker-mutator/util@9.6.1': {} + '@stryker-mutator/util@10.0.0': {} '@tailwindcss/node@4.3.3': dependencies: @@ -4627,15 +4674,19 @@ snapshots: '@types/estree@1.0.9': {} + '@types/gensync@1.0.5': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/katex@0.16.8': {} - '@types/lodash@4.17.24': {} + '@types/lodash@4.17.25': {} '@types/mdast@4.0.4': dependencies: @@ -4734,13 +4785,6 @@ snapshots: '@ungap/structured-clone@1.3.3': {} - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.7 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -4748,13 +4792,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - angular-html-parser@10.4.0: {} + angular-html-parser@10.11.0: {} ansi-escapes@7.3.0: dependencies: environment: 1.1.0 - ansi-regex@6.2.2: {} + ansi-regex@6.3.0: {} ansi-styles@6.2.3: {} @@ -4772,7 +4816,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.13: {} + baseline-browser-mapping@2.11.21: {} before-after-hook@4.0.0: {} @@ -4780,13 +4824,13 @@ snapshots: dependencies: balanced-match: 4.0.4 - browserslist@4.28.8: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.11.13 - caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.404 - node-releases: 2.0.53 - update-browserslist-db: 1.3.1(browserslist@4.28.8) + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) buffer-crc32@0.2.13: {} @@ -4804,7 +4848,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001809: {} + caniuse-lite@1.0.30001810: {} ccount@2.0.1: {} @@ -4820,15 +4864,15 @@ snapshots: chardet@2.2.0: {} - cli-boxes@3.0.0: {} + cli-boxes@4.0.1: {} cli-cursor@4.0.0: dependencies: restore-cursor: 4.0.0 - cli-truncate@5.2.0: + cli-truncate@6.1.1: dependencies: - slice-ansi: 8.0.0 + slice-ansi: 9.0.0 string-width: 8.2.2 cli-width@4.1.0: {} @@ -4890,10 +4934,12 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.404: {} + electron-to-chromium@1.5.422: {} emoji-regex@10.6.0: {} + empathic@2.0.1: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -4915,7 +4961,7 @@ snapshots: dependencies: es-errors: 1.3.0 - es-toolkit@1.50.0: {} + es-toolkit@1.52.0: {} esbuild@0.28.2: optionalDependencies: @@ -4964,7 +5010,7 @@ snapshots: is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 - pretty-ms: 9.3.0 + pretty-ms: 9.3.1 signal-exit: 4.1.0 strip-final-newline: 4.0.0 yoctocolors: 2.2.0 @@ -5007,9 +5053,9 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 fflate@0.8.3: {} @@ -5158,6 +5204,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + import-meta-resolve@4.2.0: {} + incur@0.5.1: dependencies: '@cfworker/json-schema': 4.1.1 @@ -5176,18 +5224,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - ink@6.8.0(@types/react@19.2.18)(react@19.2.8): + ink@7.1.1(@types/react@19.2.18)(react@19.2.8): dependencies: - '@alcalzone/ansi-tokenize': 0.2.5 + '@alcalzone/ansi-tokenize': 0.3.0 ansi-escapes: 7.3.0 ansi-styles: 6.2.3 auto-bind: 5.0.1 chalk: 5.6.2 - cli-boxes: 3.0.0 + cli-boxes: 4.0.1 cli-cursor: 4.0.0 - cli-truncate: 5.2.0 + cli-truncate: 6.1.1 code-excerpt: 4.0.0 - es-toolkit: 1.50.0 + es-toolkit: 1.52.0 indent-string: 5.0.0 is-in-ci: 2.0.0 patch-console: 2.0.0 @@ -5195,13 +5243,13 @@ snapshots: react-reconciler: 0.33.0(react@19.2.8) scheduler: 0.27.0 signal-exit: 3.0.7 - slice-ansi: 8.0.0 + slice-ansi: 9.0.0 stack-utils: 2.0.6 string-width: 8.2.2 terminal-size: 4.0.1 - type-fest: 5.8.0 + type-fest: 5.9.0 widest-line: 6.0.0 - wrap-ansi: 9.0.2 + wrap-ansi: 10.0.1 ws: 8.21.3 yoga-layout: 3.2.1 optionalDependencies: @@ -5221,16 +5269,10 @@ snapshots: is-decimal@2.0.1: {} - is-extglob@2.1.1: {} - is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.6.0 - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - is-hexadecimal@2.0.1: {} is-in-ci@2.0.0: {} @@ -5251,23 +5293,26 @@ snapshots: dependencies: base64-js: 1.5.1 - js-tokens@4.0.0: {} + js-tokens@10.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 - js-yaml@4.3.1: + js-yaml@5.4.1: dependencies: argparse: 2.0.1 jsesc@3.1.0: {} - json-rpc-2.0@1.7.1: {} + json-rpc-2.0@1.8.0: {} - json-schema-to-typescript@15.0.4: + json-schema-to-typescript@16.0.0: dependencies: '@apidevtools/json-schema-ref-parser': 11.9.3 '@types/json-schema': 7.0.15 - '@types/lodash': 4.17.24 - is-glob: 4.0.3 - js-yaml: 4.3.1 + '@types/lodash': 4.17.25 + js-yaml: 5.4.1 lodash: 4.18.1 minimist: 1.2.8 prettier: 3.9.6 @@ -5347,9 +5392,7 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 + lru-cache@11.5.2: {} luxon@3.7.1: {} @@ -5772,19 +5815,19 @@ snapshots: dependencies: zod: 4.5.4 - mutation-testing-elements@3.7.3: {} + mutation-testing-elements@3.8.4: {} - mutation-testing-metrics@3.7.3: + mutation-testing-metrics@3.8.4: dependencies: - mutation-testing-report-schema: 3.7.3 + mutation-testing-report-schema: 3.8.4 - mutation-testing-report-schema@3.7.3: {} + mutation-testing-report-schema@3.8.4: {} mute-stream@3.0.0: {} nanoid@3.3.18: {} - node-releases@2.0.53: {} + node-releases@2.0.54: {} npm-run-path@6.0.0: dependencies: @@ -5793,6 +5836,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.4: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5833,7 +5878,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} postcss@8.5.28: dependencies: @@ -5843,7 +5888,7 @@ snapshots: prettier@3.9.6: {} - pretty-ms@9.3.0: + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -6090,10 +6135,6 @@ snapshots: scheduler@0.27.0: {} - semver@6.3.1: {} - - semver@7.7.4: {} - semver@7.8.5: {} shebang-command@2.0.0: @@ -6134,7 +6175,7 @@ snapshots: signal-exit@4.1.0: {} - slice-ansi@8.0.0: + slice-ansi@9.0.0: dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 @@ -6151,12 +6192,6 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 - strip-ansi: 7.2.0 - string-width@8.2.2: dependencies: get-east-asian-width: 1.6.0 @@ -6169,7 +6204,7 @@ snapshots: strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-final-newline@4.0.0: {} @@ -6191,8 +6226,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tokenx@1.6.0: {} @@ -6206,7 +6241,7 @@ snapshots: tunnel@0.0.6: {} - type-fest@5.8.0: + type-fest@5.9.0: dependencies: tagged-tag: 1.0.0 @@ -6220,6 +6255,8 @@ snapshots: tunnel: 0.0.6 underscore: 1.13.8 + typescript@6.0.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -6294,9 +6331,9 @@ snapshots: universal-user-agent@7.0.3: {} - update-browserslist-db@1.3.1(browserslist@4.28.8): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 @@ -6335,7 +6372,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - weapon-regex@1.3.6: {} + weapon-regex@2.0.5: {} web-namespaces@2.0.1: {} @@ -6347,11 +6384,10 @@ snapshots: dependencies: string-width: 8.2.2 - wrap-ansi@9.0.2: + wrap-ansi@10.0.1: dependencies: ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.2.0 + string-width: 8.2.2 wrappy@1.0.2: {} @@ -6359,8 +6395,6 @@ snapshots: xmlchars@2.2.0: {} - yallist@3.1.1: {} - yaml@2.9.0: {} yauzl@2.10.0: diff --git a/sdk/typescript/pnpm-workspace.yaml b/sdk/typescript/pnpm-workspace.yaml index 858ac9cb4..799259e31 100644 --- a/sdk/typescript/pnpm-workspace.yaml +++ b/sdk/typescript/pnpm-workspace.yaml @@ -4,5 +4,11 @@ minimumReleaseAgeExclude: - "@openai/*" trustLockfile: true +# Stryker rewrites tsconfig files through the compiler API removed in TypeScript 7. +packageExtensions: + "@stryker-mutator/core@10.0.0": + dependencies: + typescript: "6.0.3" + allowBuilds: esbuild: false diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index e838a8980..3fc5e5948 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -4352,8 +4352,10 @@ function addScanCosts( current: Readonly, ): ScanCost { if (previous === null) return { ...current }; + const { estimatedUsdRange: currentRange, ...currentCost } = current; + const previousRange = previous.estimatedUsdRange; return { - model: current.model, + ...currentCost, inputTokens: previous.inputTokens + current.inputTokens, cachedInputTokens: previous.cachedInputTokens + current.cachedInputTokens, cacheWriteInputTokens: @@ -4377,6 +4379,18 @@ function addScanCosts( current.cacheWriteInputTokensReported === false ? { cacheWriteInputTokensReported: false } : {}), + ...(previousRange === undefined || currentRange === undefined + ? {} + : { + estimatedUsdRange: { + context: "unknown" as const, + min: previousRange.min + currentRange.min, + max: + previousRange.max === null || currentRange.max === null + ? null + : previousRange.max + currentRange.max, + }, + }), }; } diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 9c00f32af..e5bccc3bf 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -115,7 +115,11 @@ import { type JsonValue, } from "./config.js"; import { formatUsd, type ScanCost } from "./cost.js"; -import { formatScanCostTokens, formatTokenUsage } from "./cost-model.js"; +import { + formatScanCost, + formatScanCostTokens, + formatTokenUsage, +} from "./cost-model.js"; import { isOutsidePath, readRegularInputFile, @@ -8112,8 +8116,7 @@ async function executeScan( } if (runningCost !== null) { details.push(`Tokens: ${formatScanCostTokens(runningCost)}`); - if (showCost) - details.push(`Cost: ${formatUsd(runningCost.estimatedUsd)}`); + if (showCost) details.push(`Cost: ${formatScanCost(runningCost)}`); } return details.length === 0 ? stage : `${stage} | ${details.join(" | ")}`; }; @@ -8159,6 +8162,7 @@ async function executeScan( diagnostic("cost.updated", { model: cost.model, estimated_usd: showCost ? cost.estimatedUsd : undefined, + cost_estimate: showCost ? formatScanCost(cost) : undefined, input_tokens: cost.inputTokens, cached_input_tokens: cost.cachedInputTokens, cache_write_input_tokens: cost.cacheWriteInputTokens, @@ -8173,11 +8177,11 @@ async function executeScan( progress?.stopTimer(); if (maxCostUsd === undefined) { progress?.stage( - `Tokens: ${formatScanCostTokens(cost)}.${showCost ? ` Estimated cost: ${formatUsd(cost.estimatedUsd)} USD.` : ""}`, + `Tokens: ${formatScanCostTokens(cost)}.${showCost ? ` Estimated cost: ${formatScanCost(cost)}.` : ""}`, ); } else { progress?.stage( - `Estimated cost: ${formatUsd(cost.estimatedUsd)} of ${formatUsd(maxCostUsd)} limit`, + `Estimated cost: ${formatScanCost(cost)}; short-context budget baseline: ${formatUsd(cost.estimatedUsd)} of ${formatUsd(maxCostUsd)} limit`, ); } if (maxCostUsd === undefined || cost.estimatedUsd <= maxCostUsd) { @@ -8348,10 +8352,10 @@ async function executeScan( if (status.kind === "dispatch") { dashboard.setStage(scanPhase(status.phase)); } - if (message !== null) dashboard.note(message); + dashboard.note(message); return; } - if (message === null || progress === null) return; + if (progress === null) return; progress.stopTimer(); progress.stage(message); progress.startTimer(runningMessage()); @@ -8441,6 +8445,10 @@ async function executeScan( partial_output: scanDir !== null, max_cost_usd: costLimitFailure?.maxCostUsd, estimated_usd: costLimitFailure?.cost.estimatedUsd, + cost_estimate: + costLimitFailure === undefined + ? undefined + : formatScanCost(costLimitFailure.cost), }); errorOutput.write(`${message}\n`); if (failure instanceof ScanInterruptedError) { @@ -8538,6 +8546,10 @@ async function executeScan( findings: findings.length, scan_id: result.manifest.scan.id, estimated_usd: showCost ? result.cost?.estimatedUsd : undefined, + cost_estimate: + showCost && result.cost !== null + ? formatScanCost(result.cost) + : undefined, exit_code: exitCode, }); progress?.stopTimer(); @@ -8972,7 +8984,7 @@ function printScanSummary( const costSummary = result.cost === null ? "unavailable (model pricing or usage missing)" - : `${formatUsd(result.cost.estimatedUsd)} (standard, short context)`; + : formatScanCost(result.cost); errorOutput.write(` ${paint("COST", 1)} ${costSummary}\n`); } errorOutput.write( @@ -8994,7 +9006,7 @@ function componentScanEventLine( } if (event.type !== "cost") return null; const cost = event.value; - return `codex-security: ${componentName} | Tokens: ${formatScanCostTokens(cost)}${showCost ? ` | Cost: ${formatUsd(cost.estimatedUsd)}` : ""}\n`; + return `codex-security: ${componentName} | Tokens: ${formatScanCostTokens(cost)}${showCost ? ` | Cost: ${formatScanCost(cost)}` : ""}\n`; } function protectedRootErrorMessage( @@ -9242,7 +9254,7 @@ export function parseCodexOverrides( return result; } -function workerStatusMessage(status: ScanWorkerStatus): string | null { +function workerStatusMessage(status: ScanWorkerStatus): string { if (status.kind === "preflight") { if (status.delegation === "unavailable") { return "Preflight: worker delegation unavailable; continuing without delegated workers."; diff --git a/sdk/typescript/src/cost-model.ts b/sdk/typescript/src/cost-model.ts index c6781bfb5..26c3a9b3d 100644 --- a/sdk/typescript/src/cost-model.ts +++ b/sdk/typescript/src/cost-model.ts @@ -5,23 +5,35 @@ export interface ScanCost { cacheWriteInputTokens: number; cacheWriteInputTokensReported?: boolean; outputTokens: number; + /** Short-context baseline retained for compatibility and spending limits. */ estimatedUsd: number; coverage?: "partial"; modelCosts?: readonly ScanCost[]; + /** Standard token-cost bounds for the observed usage, not a billing total. */ + estimatedUsdRange?: { + min: number; + /** null when a verified upper estimate is unavailable. */ + max: number | null; + context: "unknown"; + }; pricing?: { source: string; asOf: string; serviceTier: "standard"; context: "short"; - usdPerMillionTokens: { - input: number; - cacheRead: number; - cacheWrite: number; - output: number; - }; + /** Short-context rates used by estimatedUsd and the range minimum. */ + usdPerMillionTokens: TokenPrices; + longContextUsdPerMillionTokens?: TokenPrices; }; } +interface TokenPrices { + input: number; + cacheRead: number; + cacheWrite: number; + output: number; +} + type ModelPricing = readonly [ input: number, cachedInput: number, @@ -53,6 +65,33 @@ const MODEL_PRICING_NANODOLLARS: Readonly> = { "gpt-daybreak-red-latest": [12_500, 1_250, 15_625, 75_000], }; +// Verified Standard rates: https://developers.openai.com/api/docs/pricing +// GPT-5.5: https://developers.openai.com/api/docs/models/gpt-5.5 +// Do not infer tiers from aggregate scan tokens: the runtime does not report +// which usage received long-context pricing. Cyber/Daybreak Red has no verified +// long-context rate in the pricing table, so its upper estimate stays unavailable. +const LONG_CONTEXT_PRICING_NANODOLLARS: Readonly> = + { + "gpt-5.5": [10_000, 1_000, 10_000, 45_000], + "gpt-5.5-2026-04-23": [10_000, 1_000, 10_000, 45_000], + "gpt-6-astra": [20_000, 2_000, 25_000, 75_000], + "gpt-5.6": [8_000, 800, 10_000, 30_000], + "gpt-5.6-sol": [8_000, 800, 10_000, 30_000], + "gpt-5.6-terra": [4_000, 400, 5_000, 18_000], + "gpt-5.6-luna": [400, 40, 500, 1_800], + "gpt-daybreak-blue-latest": [8_000, 800, 10_000, 30_000], + }; + +function usdPerMillionTokens(pricing: ModelPricing): TokenPrices { + const [input, cacheRead, cacheWrite, output] = pricing; + return { + input: input / 1_000, + cacheRead: cacheRead / 1_000, + cacheWrite: cacheWrite / 1_000, + output: output / 1_000, + }; +} + export function tokenUsage(value: unknown): ScanTokenUsage | null { if (!isRecord(value)) return null; const input = value["input_tokens"]; @@ -134,6 +173,16 @@ export function estimateScanCost( : {}), outputTokens: total.output_tokens, estimatedUsd: sum("estimatedUsd"), + estimatedUsdRange: { + min: sum("estimatedUsd"), + max: costs.some((cost) => cost.estimatedUsdRange?.max == null) + ? null + : costs.reduce( + (value, cost) => value + cost.estimatedUsdRange!.max!, + 0, + ), + context: "unknown", + }, modelCosts: costs, ...(costs.length === 1 ? { pricing: costs[0]!.pricing } : {}), ...(usage["coverage"] === "partial" @@ -217,6 +266,24 @@ function estimateModelCost( outputTokens * outputRate; if (!Number.isSafeInteger(nanodollars)) return null; + const longPricing = LONG_CONTEXT_PRICING_NANODOLLARS[pricingModel]; + let maximumNanodollars: number | null = null; + if (longPricing !== undefined) { + const [longInput, longRead, longWrite, longOutput] = longPricing; + // Unclassified input may include additional cache writes. Preserve the + // reported subtotal, but include that uncertainty in the upper estimate. + const uncachedRate = + normalized.cache_write_input_tokens_reported === false + ? Math.max(longInput, longWrite) + : longInput; + const maximum = + (inputTokens - cachedInputTokens - cacheWriteInputTokens) * uncachedRate + + cachedInputTokens * longRead + + cacheWriteInputTokens * longWrite + + outputTokens * longOutput; + if (Number.isSafeInteger(maximum)) maximumNanodollars = maximum; + } + return { model, inputTokens, @@ -227,19 +294,23 @@ function estimateModelCost( : {}), outputTokens, estimatedUsd: nanodollars / 1_000_000_000, + estimatedUsdRange: { + min: nanodollars / 1_000_000_000, + max: + maximumNanodollars === null ? null : maximumNanodollars / 1_000_000_000, + context: "unknown", + }, pricing: { source: pricingModel.startsWith("gpt-5.5") ? "https://developers.openai.com/api/docs/models/gpt-5.5" : "https://developers.openai.com/api/docs/pricing", - asOf: "2026-09-09", + asOf: "2026-09-14", serviceTier: "standard", context: "short", - usdPerMillionTokens: { - input: inputRate / 1_000, - cacheRead: cachedInputRate / 1_000, - cacheWrite: cacheWriteInputRate / 1_000, - output: outputRate / 1_000, - }, + usdPerMillionTokens: usdPerMillionTokens(pricing), + ...(longPricing === undefined + ? {} + : { longContextUsdPerMillionTokens: usdPerMillionTokens(longPricing) }), }, }; } @@ -281,6 +352,36 @@ export function formatScanCostTokens(cost: Readonly): string { })!; } +export function formatScanCost(cost: Readonly): string { + return formatScanCosts([cost]); +} + +export function formatScanCosts(costs: readonly Readonly[]): string { + if (costs.some((cost) => cost.estimatedUsdRange === undefined)) { + return `${formatUsd(costs.reduce((sum, cost) => sum + cost.estimatedUsd, 0))} (legacy estimate, context unknown)`; + } + const minimum = costs.reduce( + (sum, cost) => sum + cost.estimatedUsdRange!.min, + 0, + ); + const maximum = costs.some((cost) => cost.estimatedUsdRange!.max === null) + ? null + : costs.reduce((sum, cost) => sum + cost.estimatedUsdRange!.max!, 0); + const cacheWrites = costs.some( + (cost) => cost.cacheWriteInputTokensReported === false, + ) + ? ", cache writes unknown" + : ""; + if (maximum === null) { + return `at least ${formatUsd(minimum)} (standard, upper estimate unavailable${cacheWrites})`; + } + const amount = + minimum === maximum + ? formatUsd(minimum) + : `${formatUsd(minimum)}–${formatUsd(maximum)}`; + return `${amount} (standard, context unknown${cacheWrites})`; +} + export function formatUsd(value: number): string { return new Intl.NumberFormat("en-US", { style: "currency", diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 6c5fd88ee..7125bed35 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -1,4 +1,4 @@ -import { formatUsd, type ScanCost } from "./cost-model.js"; +import { formatScanCost, formatUsd, type ScanCost } from "./cost-model.js"; /** Returns the original error message without altering its contents. */ export function errorMessage(error: unknown): string { @@ -106,7 +106,7 @@ export class ScanCostLimitExceededError extends ScanInterruptedError { scanDir: string, ) { super( - `Scan stopped: estimated cost ${formatUsd(cost.estimatedUsd)} exceeded the ${formatUsd(maxCostUsd)} limit; partial output remains at ${scanDir}.`, + `Scan stopped: short-context budget baseline ${formatUsd(cost.estimatedUsd)} exceeded the ${formatUsd(maxCostUsd)} limit; estimated cost ${formatScanCost(cost)}; partial output remains at ${scanDir}.`, scanDir, ); this.maxCostUsd = maxCostUsd; diff --git a/sdk/typescript/src/knowledge-base.ts b/sdk/typescript/src/knowledge-base.ts index 3bbbf5be1..425fe64cf 100644 --- a/sdk/typescript/src/knowledge-base.ts +++ b/sdk/typescript/src/knowledge-base.ts @@ -13,7 +13,7 @@ import { basename, extname, join, resolve } from "node:path"; import { unzipSync } from "fflate"; import { expandHome } from "./runtime.js"; -const SUPPORTED_EXTENSIONS = new Set([ +const DOCUMENT_EXTENSIONS = new Set([ ".md", ".markdown", ".txt", @@ -60,9 +60,6 @@ export async function prepareKnowledgeBase( ); } for (const document of selected) { - if (!SUPPORTED_EXTENSIONS.has(extname(document).toLowerCase())) { - throw new Error(`Unsupported knowledge base document: ${document}`); - } documents.add(document); } sources.add(source); @@ -133,10 +130,18 @@ async function discover( for (const document of await discover(path, signal)) { documents.push(document); } - } else if ( - entry.isFile() && - SUPPORTED_EXTENSIONS.has(extname(path).toLowerCase()) - ) { + } else if (entry.isFile()) { + if (!DOCUMENT_EXTENSIONS.has(extname(path).toLowerCase())) { + const bytes = await readFile(path, { + flag: constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + signal, + }); + try { + decodeText(path, bytes); + } catch { + continue; + } + } documents.push(path); } } @@ -144,6 +149,9 @@ async function discover( } function decodeText(path: string, bytes: Uint8Array): string { + if (bytes.includes(0)) { + throw new Error(`Knowledge base document contains binary data: ${path}`); + } try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch (error) { diff --git a/sdk/typescript/src/scan-dashboard.ts b/sdk/typescript/src/scan-dashboard.ts index 2ae8679ac..0f2c8a344 100644 --- a/sdk/typescript/src/scan-dashboard.ts +++ b/sdk/typescript/src/scan-dashboard.ts @@ -9,7 +9,12 @@ import type { ComponentScanResult, } from "./component-scan.js"; import { formatUsd, type ScanCost, type ScanSessionEvent } from "./cost.js"; -import { estimateScanCost, formatScanCostTokens } from "./cost-model.js"; +import { + estimateScanCost, + formatScanCost, + formatScanCosts, + formatScanCostTokens, +} from "./cost-model.js"; import type { ScanActivity } from "./scan-activity.js"; import type { ScanMode } from "./targets.js"; import { scanPhaseLabel, type ScanProgress } from "./worker-progress.js"; @@ -564,18 +569,6 @@ export class ScanDashboard { this.#files === null ? "waiting for inventory" : `${formatCount(this.#files.filesCompleted)} / ${formatCount(this.#files.filesTotal)} reviewed`; - const cost = - this.#cost === null - ? this.#options.maxCostUsd === undefined - ? estimateScanCost(this.#options.model?.model, { - input_tokens: 0, - output_tokens: 0, - }) === null - ? "unavailable (model pricing missing)" - : "waiting for usage" - : `— / ${formatUsd(this.#options.maxCostUsd)}` - : `${formatUsd(this.#cost.estimatedUsd)}${this.#options.maxCostUsd === undefined ? "" : ` / ${formatUsd(this.#options.maxCostUsd)} · ${budgetBar(this.#cost.estimatedUsd, this.#options.maxCostUsd)}`}`; - const history = this.#activityLines(width); const maximumOffset = Math.max(0, history.length - activityRows); this.#scrollOffset = Math.min(this.#scrollOffset, maximumOffset); @@ -622,7 +615,7 @@ export class ScanDashboard { ? [] : [` STAGE ${this.#stage}`, ` FILES ${files}`]), ...this.#tokenLines(), - ...(this.#showCost ? [` COST ${cost}`] : []), + ...this.#costLines(), ...(this.#budget === null ? [] : [ @@ -719,7 +712,10 @@ export class ScanDashboard { } #componentRows(): number { - return Math.max(1, (this.#stream.rows ?? 24) - (this.#showCost ? 11 : 10)); + return Math.max( + 1, + (this.#stream.rows ?? 24) - 10 - this.#componentCostLines().length, + ); } #componentFrame(): string { @@ -732,7 +728,16 @@ export class ScanDashboard { this.#components.length - rows, ), ); - const nameWidth = Math.max(10, width - (this.#showCost ? 61 : 52)); + const costWidth = Math.max( + 8, + ...this.#components.map(({ dashboard }) => + dashboard.#cost === null ? 1 : formatScanCost(dashboard.#cost).length, + ), + ); + const nameWidth = Math.max( + 10, + width - 52 - (this.#showCost ? costWidth + 1 : 0), + ); const row = ( marker: string, name: string, @@ -758,9 +763,7 @@ export class ScanDashboard { receipt.findingCount === undefined ? "—" : formatCount(receipt.findingCount), - dashboard.#cost === null - ? "—" - : formatUsd(dashboard.#cost.estimatedUsd), + dashboard.#cost === null ? "—" : formatScanCost(dashboard.#cost), ); }); if (table.length === 0) table.push(` ${this.#stage}…`); @@ -769,9 +772,6 @@ export class ScanDashboard { this.#components.filter(({ receipt }) => receipt.status === status) .length; const selected = this.#components[this.#selectedComponent]?.receipt; - const costs = this.#components.flatMap(({ dashboard }) => - dashboard.#cost === null ? [] : [dashboard.#cost.estimatedUsd], - ); const rawFindings = this.#components.reduce( (sum, { receipt }) => sum + (receipt.findingCount ?? 0), 0, @@ -791,11 +791,7 @@ export class ScanDashboard { divider, ` SCOPE ${selected?.paths.join(", ") ?? "waiting for component plan"}`, ` STATUS ${selected?.error ?? findings}`, - ...(this.#showCost - ? [ - ` COST ${costs.length === 0 ? "waiting for usage" : formatUsd(costs.reduce((sum, value) => sum + value, 0))} · component scans only`, - ] - : []), + ...this.#componentCostLines(), ` STAGE ${this.#stage}`, ` TIME ${formatElapsed(Math.max(0, Math.floor((this.#options.clock.now() - this.#startedAt) / 1_000)))} · ↑↓ select · Enter activity · Ctrl+C cancel`, ]); @@ -807,6 +803,18 @@ export class ScanDashboard { ); } + #componentCostLines(): string[] { + if (!this.#showCost) return []; + const costs = this.#components.flatMap(({ dashboard }) => + dashboard.#cost === null ? [] : [dashboard.#cost], + ); + return wrapActivity( + " COST ", + `${costs.length === 0 ? "waiting for usage" : formatScanCosts(costs)} · component scans only`, + this.#width(), + ); + } + #width(): number { return Math.max(1, Math.min(this.#stream.columns ?? 88, 160)); } @@ -820,7 +828,7 @@ export class ScanDashboard { (this.#options.presentation === "publication" || this.#options.presentation === "verification" ? 0 - : this.#tokenLines().length - 1 - (this.#showCost ? 0 : 1)) + + : this.#tokenLines().length - 1 + this.#costLines().length - 1) + (this.#options.presentation === "publication" ? 2 : this.#options.mode === "deep" @@ -837,6 +845,22 @@ export class ScanDashboard { return wrapActivity(" TOKENS ", tokens, this.#width()); } + #costLines(): string[] { + if (!this.#showCost) return []; + const cost = + this.#cost === null + ? this.#options.maxCostUsd === undefined + ? estimateScanCost(this.#options.model?.model, { + input_tokens: 0, + output_tokens: 0, + }) === null + ? "unavailable (model pricing missing)" + : "waiting for usage" + : `— / ${formatUsd(this.#options.maxCostUsd)}` + : `${formatScanCost(this.#cost)}${this.#options.maxCostUsd === undefined ? "" : `; short-context budget baseline: ${formatUsd(this.#cost.estimatedUsd)} / ${formatUsd(this.#options.maxCostUsd)} · ${budgetBar(this.#cost.estimatedUsd, this.#options.maxCostUsd)}`}`; + return wrapActivity(" COST ", cost, this.#width()); + } + #activityLines(width: number): DashboardActivityLine[] { if (this.#view === "details") { let cache = this.#detailsCache; diff --git a/sdk/typescript/src/security-policy-cli.ts b/sdk/typescript/src/security-policy-cli.ts index b60f40f6a..19a740267 100644 --- a/sdk/typescript/src/security-policy-cli.ts +++ b/sdk/typescript/src/security-policy-cli.ts @@ -1,7 +1,7 @@ import type { CodexSecurity, ScanAuthMode } from "./api.js"; import type { BulkScanPrompt } from "./bulk-scan-discovery.js"; import type { CodexSecurityConfig } from "./config.js"; -import { formatUsd } from "./cost.js"; +import { formatScanCost } from "./cost-model.js"; import { safeErrorMessage } from "./errors.js"; import { formatSecurityPolicyText as display, @@ -199,7 +199,7 @@ export async function runPolicyCommand( } const seconds = Math.max(0, (dependencies.now() - started) / 1000); write( - `Policy generation finished in ${seconds.toFixed(1)}s${cost === null ? "" : ` (${formatUsd(cost.estimatedUsd)} estimated)`}.`, + `Policy generation finished in ${seconds.toFixed(1)}s${cost === null ? "" : ` (estimated cost: ${formatScanCost(cost)})`}.`, ); return { exitCode: 0, diff --git a/sdk/typescript/tests-ts/api-policy.test.ts b/sdk/typescript/tests-ts/api-policy.test.ts index 899ffc59e..c3b1fac36 100644 --- a/sdk/typescript/tests-ts/api-policy.test.ts +++ b/sdk/typescript/tests-ts/api-policy.test.ts @@ -1245,6 +1245,12 @@ describe("CodexSecurity policy API", () => { expect(f.configuration()?.env?.["CODEX_SECURITY_SCAN_ID"]).toBeUndefined(); expect(result.cost?.inputTokens).toBe(300); expect(result.cost?.outputTokens).toBe(30); + expect(result.cost?.estimatedUsdRange).toEqual({ + min: 0.0018, + max: 0.0033, + context: "unknown", + }); + expect(result.cost?.pricing?.longContextUsdPerMillionTokens?.input).toBe(8); expect(costs).toHaveLength(3); expect(costs.at(-1)).toBe(result.cost?.estimatedUsd); expect(await readFile(result.draftPath, "utf8")).toBe(POLICY); diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 1e7d35f56..35f04ff98 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -1083,15 +1083,15 @@ describe("CodexSecurity orchestration", () => { test("validates knowledge-base documents before initializing the runtime", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); - const knowledgeBase = join(root, "threat-model.md"); + const knowledgeBase = join(root, "context.json"); const invalidDocument = join(root, "broken.pdf"); const unsupportedDocument = join(root, "unsupported.exe"); const emptyDirectory = join(root, "empty"); await mkdir(repository); await mkdir(emptyDirectory); - await writeFile(knowledgeBase, "# Threat model\nPublic API is in scope.\n"); + await writeFile(knowledgeBase, '{"scope":"Public API"}'); await writeFile(invalidDocument, "not a PDF"); - await writeFile(unsupportedDocument, "not a supported document"); + await writeFile(unsupportedDocument, new Uint8Array([0, 1, 2])); let runtimeStarted = false; const client = new TestClient( {}, @@ -1109,7 +1109,7 @@ describe("CodexSecurity orchestration", () => { ).resolves.toMatchObject({ knowledgeBasePaths: [knowledgeBase] }); const invalidDocuments: Array<[string, string]> = [ [join(root, "missing.md"), "ENOENT"], - [unsupportedDocument, "Unsupported knowledge base document"], + [unsupportedDocument, "contains binary data"], [invalidDocument, "Cannot extract text from knowledge base PDF"], [ emptyDirectory, @@ -5069,7 +5069,7 @@ describe("CodexSecurity orchestration", () => { "--scan-id", "scan_example_001", "--message", - `Scan stopped: estimated cost $0.00488 exceeded the $0.004 limit; partial output remains at ${scanDir}.`, + `Scan stopped: short-context budget baseline $0.00488 exceeded the $0.004 limit; estimated cost $0.00488–$0.01156 (standard, context unknown, cache writes unknown); partial output remains at ${scanDir}.`, "--cost-json", JSON.stringify(cost), ]); @@ -5206,7 +5206,7 @@ describe("CodexSecurity orchestration", () => { expect(recovered.threadId).toBe("scan-thread"); expect(recovered.cost?.estimatedUsd).toBe(0.00488); expect(warnings).toEqual([ - `Scan stopped: estimated cost $0.00488 exceeded the $0.004 limit; partial output remains at ${scanDir}.`, + `Scan stopped: short-context budget baseline $0.00488 exceeded the $0.004 limit; estimated cost $0.00488–$0.01156 (standard, context unknown, cache writes unknown); partial output remains at ${scanDir}.`, ]); expect(commands.some((args) => args[0] === "fail-scan")).toBe(false); } diff --git a/sdk/typescript/tests-ts/build-plugin.test.ts b/sdk/typescript/tests-ts/build-plugin.test.ts index db52ed062..e01a22e03 100644 --- a/sdk/typescript/tests-ts/build-plugin.test.ts +++ b/sdk/typescript/tests-ts/build-plugin.test.ts @@ -178,7 +178,7 @@ describe("bundled plugin build", () => { expect(result.stderr).toBe(""); }); - test("builds the MCP runtime with only MCP dependencies and no npm launcher", async () => { + test("builds the MCP runtime through a directory alias 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"); @@ -239,10 +239,27 @@ describe("bundled plugin build", () => { ); if (process.platform !== "win32") await chmod(join(bin, launcher), 0o755); + const alias = join(await temporaryDirectory(), "plugin link"); + await symlink( + root, + alias, + process.platform === "win32" ? "junction" : "dir", + ); const destination = join(root, "mcp"); await execFileAsync( "node", - [join(mcp, "scripts", "build_mcp_app.mjs"), "--output", destination], + [ + join( + alias, + "plugins", + "codex-security", + "mcp-app", + "scripts", + "build_mcp_app.mjs", + ), + "--output", + destination, + ], { env: { ...process.env, @@ -292,12 +309,6 @@ describe("bundled plugin build", () => { "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), diff --git a/sdk/typescript/tests-ts/cli-patch-results.test.ts b/sdk/typescript/tests-ts/cli-patch-results.test.ts index febd49243..8f3620a3e 100644 --- a/sdk/typescript/tests-ts/cli-patch-results.test.ts +++ b/sdk/typescript/tests-ts/cli-patch-results.test.ts @@ -13,7 +13,7 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }); }); -async function repositoryFixture() { +async function repositoryFixture({ initializeGit = true } = {}) { const directory = await mkdtemp(join(tmpdir(), "patch-results-")); directories.push(directory); const runRepositoryCommand: NonNullable< @@ -29,12 +29,14 @@ async function repositoryFixture() { }; const git = (...args: string[]) => runRepositoryCommand("git", args, directory); - git("init", "--initial-branch=main"); - git("config", "user.name", "Synthetic User"); - git("config", "user.email", "synthetic@example.test"); await writeFile(join(directory, "app.ts"), "original\n"); - git("add", "."); - git("commit", "-m", "Synthetic fixture"); + if (initializeGit) { + git("init", "--initial-branch=main"); + git("config", "user.name", "Synthetic User"); + git("config", "user.email", "synthetic@example.test"); + git("add", "."); + git("commit", "-m", "Synthetic fixture"); + } return { directory, git, @@ -180,11 +182,7 @@ lines.on("line", (line) => { test.each([false, true])( "checks patch changes outside a Git repository: %s", async (apply) => { - const fixture = await repositoryFixture(); - await rm(join(fixture.directory, ".git"), { - recursive: true, - force: true, - }); + const fixture = await repositoryFixture({ initializeGit: false }); const outcome = await fixture.patch(["Synthetic issue"], { onCodex: async () => { if (apply) @@ -192,11 +190,15 @@ lines.on("line", (line) => { return 0; }, }); - expect(outcome.status).toBe(apply ? 0 : 2); + expect(outcome.status, outcome.stderr).toBe(apply ? 0 : 2); expect(outcome.result).toMatchObject({ applied: apply, filesChanged: apply ? 1 : 0, }); + if (!apply) + expect(outcome.result.error).toMatchObject({ + code: "NO_PATCH_APPLIED", + }); }, ); diff --git a/sdk/typescript/tests-ts/cli-policy.test.ts b/sdk/typescript/tests-ts/cli-policy.test.ts index 67e5c1894..9a87f8551 100644 --- a/sdk/typescript/tests-ts/cli-policy.test.ts +++ b/sdk/typescript/tests-ts/cli-policy.test.ts @@ -143,6 +143,16 @@ describe("policy CLI", () => { test("generates a headless draft with machine-readable paths and no source edits", async () => { const f = await fixture(); + const draft = await f.generate(); + draft.cost = { + model: "synthetic-model", + inputTokens: 1, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + outputTokens: 1, + estimatedUsd: 1, + estimatedUsdRange: { min: 1, max: 2, context: "unknown" }, + }; const stdout = capture(); const stderr = capture(); let closed = false; @@ -162,6 +172,7 @@ describe("policy CLI", () => { stdout.stream, stderr.stream, policyDependencies(f, { + draft, onClose: () => { closed = true; }, @@ -175,6 +186,8 @@ describe("policy CLI", () => { expect(result.status).toBe("draft"); expect(result.targetPath).toBe(join(f.repository, "SECURITY.md")); expect(result.threatModelPath).toBe(join(f.outputDir, "THREAT_MODEL.md")); + expect(result.cost).toEqual(draft.cost); + expect(stderr.text()).toContain("$1.00–$2.00 (standard, context unknown)"); expect(stderr.text()).toContain("[1/3]"); expect(stderr.text()).not.toContain("+Requests must be authorized"); expect(config).toMatchObject({ diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 178d92ce4..d853d1a94 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -4727,12 +4727,12 @@ describe("CLI", () => { ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(result.toJSON()); expect(stderr.text()).toContain( - "Tokens: unavailable uncached input, 200 cache reads, unavailable cache writes, 30 output, 1,280 total. Estimated cost: $0.00488 USD.", + "Tokens: unavailable uncached input, 200 cache reads, unavailable cache writes, 30 output, 1,280 total. Estimated cost: $0.00488–$0.01156 (standard, context unknown, cache writes unknown).", ); expect(stderr.text()).toContain("Scan phase: reviewing files (0/8 files)."); expect(stderr.text()).toContain("Scan phase: reviewing files (3/8 files)."); expect(stderr.text()).toContain( - "Running scan: reviewing files | Workers: 4/6 | Files: 3/8 | Tokens: unavailable uncached input, 200 cache reads, unavailable cache writes, 30 output, 1,280 total | Cost: $0.00488", + "Running scan: reviewing files | Workers: 4/6 | Files: 3/8 | Tokens: unavailable uncached input, 200 cache reads, unavailable cache writes, 30 output, 1,280 total | Cost: $0.00488–$0.01156", ); }); @@ -4805,7 +4805,9 @@ describe("CLI", () => { expect(stderr.text()).toContain( "TOKENS unavailable uncached input, 200 cache reads, unavailable cache writes, 30 output, 1,280 total", ); - expect(stderr.text()).toContain("COST $0.00488"); + expect(stderr.text()).toContain( + "COST $0.00488–$0.01156 (standard, context unknown, cache writes unknown)", + ); expect(stderr.text()).toContain(`REPORT ${result.reportPath}`); expect(stderr.text()).toContain("RESULTS /tmp/scan"); expect(stderr.text()).not.toContain("Next:"); @@ -4848,7 +4850,7 @@ describe("CLI", () => { }, ); - test("reports the running cost against the scan budget", async () => { + test("reports a cost range while preserving the short-context scan budget", async () => { const stdout = capture(); const stderr = capture(); const result = fakeResult([], "complete", { @@ -4859,14 +4861,20 @@ describe("CLI", () => { expect( await main( - ["scan", ".", "--json", "--max-cost", "0.01"], + ["scan", ".", "--verbose", "--json", "--max-cost", "0.01"], stdout.stream, stderr.stream, dependencies({ result, costUpdates: [result.cost!] }), ), ).toBe(0); expect(JSON.parse(stdout.text())).toEqual(result.toJSON()); - expect(stderr.text()).toContain("Estimated cost: $0.00488 of $0.01 limit"); + expect(stderr.text()).toContain("Estimated cost: $0.00488–$0.01156"); + expect(stderr.text()).toContain( + "short-context budget baseline: $0.00488 of $0.01 limit", + ); + expect(stderr.text()).toContain( + 'estimated_usd=0.00488 cost_estimate="$0.00488–$0.01156 (standard, context unknown, cache writes unknown)"', + ); }); test("includes cache-write tokens in verbose cost diagnostics", async () => { @@ -4892,6 +4900,7 @@ describe("CLI", () => { expect(stderr.text()).toContain("codex-security: debug: cost.updated"); expect(stderr.text()).toContain("cache_write_input_tokens=200"); expect(stderr.text()).not.toContain("estimated_usd="); + expect(stderr.text()).not.toContain("cost_estimate="); }); test("reports and classifies a scan stopped when its live cost exceeds the limit", async () => { @@ -4918,7 +4927,7 @@ describe("CLI", () => { ).toBe(2); expect(stdout.text()).toBe(""); expect(stderr.text()).toContain( - "Scan stopped: estimated cost $0.00488 exceeded the $0.004 limit; partial output remains at /tmp/scan.", + "Scan stopped: short-context budget baseline $0.00488 exceeded the $0.004 limit; estimated cost $0.00488–$0.01156 (standard, context unknown, cache writes unknown); partial output remains at /tmp/scan.", ); expect(stderr.text()).toMatch( /scan\.configuration[^\n]*max_cost_usd=0\.004/u, diff --git a/sdk/typescript/tests-ts/cost-context.test.ts b/sdk/typescript/tests-ts/cost-context.test.ts new file mode 100644 index 000000000..437478cb4 --- /dev/null +++ b/sdk/typescript/tests-ts/cost-context.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "bun:test"; +import { + estimateScanCost, + formatScanCost, + formatScanCosts, +} from "../src/cost-model.js"; + +// Standard prices per million: input, cache reads, cache writes, output. +test.each([ + ["gpt-5.5", [5, 0.5, 5, 30], [10, 1, 10, 45]], + ["gpt-5.5-2026-04-23", [5, 0.5, 5, 30], [10, 1, 10, 45]], + ["gpt-6-astra", [10, 1, 12.5, 50], [20, 2, 25, 75]], + ["gpt-5.6", [4, 0.4, 5, 20], [8, 0.8, 10, 30]], + ["gpt-5.6-sol", [4, 0.4, 5, 20], [8, 0.8, 10, 30]], + ["gpt-5.6-terra", [2, 0.2, 2.5, 12], [4, 0.4, 5, 18]], + ["gpt-5.6-luna", [0.2, 0.02, 0.25, 1.2], [0.4, 0.04, 0.5, 1.8]], + ["gpt-daybreak-blue-latest", [4, 0.4, 5, 20], [8, 0.8, 10, 30]], +] as const)( + "%s bounds each token category with verified rates", + (model, short, long) => { + for (const category of [0, 1, 2, 3] as const) { + for (const selected of [model, `openai.${model}`]) { + const cost = estimateScanCost(selected, { + input_tokens: category === 3 ? 0 : 1_000_000, + cached_input_tokens: category === 1 ? 1_000_000 : 0, + cache_write_input_tokens: category === 2 ? 1_000_000 : 0, + output_tokens: category === 3 ? 1_000_000 : 0, + })!; + expect(cost.estimatedUsd).toBe(short[category]); + expect(cost.estimatedUsdRange).toEqual({ + min: short[category], + max: long[category], + context: "unknown", + }); + } + } + }, +); + +test("reports a range for cache-heavy scans without changing the budget baseline", () => { + const cost = estimateScanCost("gpt-5.6-sol", { + input_tokens: 150_000_000, + cached_input_tokens: 138_000_000, + cache_write_input_tokens: 11_700_000, + output_tokens: 900_000, + })!; + expect(cost.estimatedUsd).toBe(132.9); + expect(cost.estimatedUsdRange).toEqual({ + min: 132.9, + max: 256.8, + context: "unknown", + }); + expect(formatScanCost(cost)).toBe( + "$132.90–$256.80 (standard, context unknown)", + ); +}); + +test("widens the maximum for unreported cache writes without changing token counts", () => { + const usage = { + input_tokens: 1_000_000, + cached_input_tokens: 200_000, + output_tokens: 0, + }; + const missing = estimateScanCost("gpt-5.6-sol", usage)!; + const zero = estimateScanCost("gpt-5.6-sol", { + ...usage, + cache_write_input_tokens: 0, + })!; + const partial = estimateScanCost("gpt-5.6-sol", { + ...usage, + cache_write_input_tokens: 300_000, + cache_write_input_tokens_reported: false, + })!; + expect(missing.cacheWriteInputTokens).toBe(0); + expect(missing.estimatedUsdRange).toMatchObject({ min: 3.28, max: 8.16 }); + expect(zero.estimatedUsdRange).toMatchObject({ min: 3.28, max: 6.56 }); + expect(partial.cacheWriteInputTokens).toBe(300_000); + expect(partial.estimatedUsdRange).toMatchObject({ min: 3.58, max: 8.16 }); + expect(formatScanCost(missing)).toContain("cache writes unknown"); +}); + +test("keeps unverified long-context prices unavailable", () => { + const cost = estimateScanCost("gpt-daybreak-red-latest", { + input_tokens: 1_000_000, + cache_write_input_tokens: 0, + output_tokens: 0, + })!; + expect(cost.estimatedUsdRange).toEqual({ + min: 12.5, + max: null, + context: "unknown", + }); + expect(formatScanCost(cost)).toBe( + "at least $12.50 (standard, upper estimate unavailable)", + ); +}); + +test("unavailable upper arithmetic does not disable an existing budget estimate", () => { + const cost = estimateScanCost("gpt-6-astra", { + input_tokens: 600_000_000_000, + cache_write_input_tokens: 0, + output_tokens: 0, + })!; + expect(cost.estimatedUsd).toBe(6_000_000); + expect(cost.estimatedUsdRange?.max).toBeNull(); +}); + +test("component totals preserve uncertainty and label legacy records without repricing", () => { + const cost = estimateScanCost("gpt-5.6-sol", { + input_tokens: 1_000_000, + cache_write_input_tokens: 0, + output_tokens: 0, + })!; + expect(formatScanCosts([cost, cost])).toBe( + "$8.00–$16.00 (standard, context unknown)", + ); + const { estimatedUsdRange: _range, pricing: _pricing, ...legacy } = cost; + legacy.estimatedUsd = 3; + expect(formatScanCost(legacy)).toBe( + "$3.00 (legacy estimate, context unknown)", + ); + expect(formatScanCosts([legacy, cost])).toBe( + "$7.00 (legacy estimate, context unknown)", + ); + expect( + formatScanCosts([ + cost, + { ...cost, estimatedUsdRange: { min: 4, max: null, context: "unknown" } }, + ]), + ).toContain("at least $8.00"); +}); + +test.each([false, true])( + "attributed model totals retain context bounds and partial coverage (%s)", + (unknownUpper) => { + const first = { + model: "gpt-5.6-sol", + input_tokens: 1_000_000, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: 0, + }; + const second = { + ...first, + model: unknownUpper ? "gpt-daybreak-red-latest" : "gpt-5.6-terra", + }; + const cost = estimateScanCost("gpt-6-astra", { + input_tokens: 2_000_000, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: 0, + modelUsage: [first, second], + coverage: "partial", + })!; + expect(cost.coverage).toBe("partial"); + expect(cost.modelCosts?.map((part) => part.model)).toEqual([ + first.model, + second.model, + ]); + expect(cost.estimatedUsd).toBe(unknownUpper ? 16.5 : 6); + expect(cost.estimatedUsdRange).toEqual({ + min: cost.estimatedUsd, + max: unknownUpper ? null : 12, + context: "unknown", + }); + expect(formatScanCost(cost)).toContain( + unknownUpper ? "upper estimate unavailable" : "context unknown", + ); + }, +); diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index cfc1160aa..d04056615 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -236,9 +236,10 @@ describe("scan cost", () => { cacheWriteInputTokens: 300_000, outputTokens: 100_000, estimatedUsd: 13.95, + estimatedUsdRange: { min: 13.95, max: 25.4, context: "unknown" }, pricing: { source: "https://developers.openai.com/api/docs/pricing", - asOf: "2026-09-09", + asOf: "2026-09-14", serviceTier: "standard", context: "short", usdPerMillionTokens: { @@ -247,6 +248,12 @@ describe("scan cost", () => { cacheWrite: 12.5, output: 50, }, + longContextUsdPerMillionTokens: { + input: 20, + cacheRead: 2, + cacheWrite: 25, + output: 75, + }, }, }); }); diff --git a/sdk/typescript/tests-ts/errors.test.ts b/sdk/typescript/tests-ts/errors.test.ts index c710d5223..23db1958c 100644 --- a/sdk/typescript/tests-ts/errors.test.ts +++ b/sdk/typescript/tests-ts/errors.test.ts @@ -55,6 +55,8 @@ describe("error messages", () => { }); expect(limit.message).toContain("$2.00"); expect(limit.message).toContain("$1.00"); + expect(limit.message).toContain("short-context budget baseline $2.00"); + expect(limit.message).toContain("legacy estimate, context unknown"); expect(limit.message).toContain("/scan"); }); diff --git a/sdk/typescript/tests-ts/knowledge-base.test.ts b/sdk/typescript/tests-ts/knowledge-base.test.ts index 206483f35..4fb21cac0 100644 --- a/sdk/typescript/tests-ts/knowledge-base.test.ts +++ b/sdk/typescript/tests-ts/knowledge-base.test.ts @@ -86,7 +86,8 @@ describe("scan knowledge bases", () => { await writeFile(scope, "Ignore local debug endpoints."); await writeFile(join(nested, "deployment.MARKDOWN"), "Public API gateway."); await writeFile(join(nested, "notes.txt"), "Prioritize SSRF."); - await writeFile(join(root, "ignored.json"), "{}"); + await writeFile(join(root, "ignored.bin"), new Uint8Array([0, 1, 2])); + await writeFile(join(root, "invalid-utf8.bin"), new Uint8Array([0xff])); const knowledgeBase = await prepareKnowledgeBase([root, scope, scope]); temporaryDirectories.push(knowledgeBase.path); @@ -108,6 +109,27 @@ describe("scan knowledge bases", () => { } }); + test.each([ + ["context.json", '{"service":"Public API"}'], + ["results.sarif", '{"version":"2.1.0","runs":[]}'], + ["deployment.yaml", "service: public-api\n"], + ["context.custom", "Boundary: café → gateway\n"], + ["CONTEXT", "Public API gateway.\n"], + ])("accepts %s directly and in nested directories", async (name, text) => { + const root = await temporaryDirectory(); + const nested = join(root, "nested"); + await mkdir(nested); + const source = join(nested, name); + await writeFile(source, text); + + for (const paths of [[source], [root], [root, source]]) { + const knowledgeBase = await prepareKnowledgeBase(paths); + temporaryDirectories.push(knowledgeBase.path); + expect(await extractedDocuments(knowledgeBase.path)).toEqual([text]); + expect(knowledgeBase.sources).toEqual(paths); + } + }); + test("cancels recursive discovery before staging knowledge-base documents", async () => { const root = await temporaryDirectory(); const nested = join(root, "nested", "deeper"); @@ -263,17 +285,17 @@ describe("scan knowledge bases", () => { ]); }); - test("rejects missing and unsupported paths", async () => { + test("rejects missing paths, explicit binary files, and binary-only directories", async () => { const root = await temporaryDirectory(); const unsupported = join(root, "scope.doc"); - await writeFile(unsupported, "legacy document"); + await writeFile(unsupported, new Uint8Array([0, 1, 2])); await expect(prepareKnowledgeBase([""])).rejects.toThrow("cannot be empty"); await expect( prepareKnowledgeBase([join(root, "missing.md")]), ).rejects.toThrow(); await expect(prepareKnowledgeBase([unsupported])).rejects.toThrow( - "Unsupported knowledge base document", + "contains binary data", ); await expect(prepareKnowledgeBase([root])).rejects.toThrow( "contains no supported documents", diff --git a/sdk/typescript/tests-ts/mcp-launcher.test.ts b/sdk/typescript/tests-ts/mcp-launcher.test.ts index e1e0f3c00..3bc1b09de 100644 --- a/sdk/typescript/tests-ts/mcp-launcher.test.ts +++ b/sdk/typescript/tests-ts/mcp-launcher.test.ts @@ -146,7 +146,7 @@ test.each(["server", "helper"] as const)( env_vars: string[]; }; expect(config.env_vars).toContain("CODEX_MCP_NODE_PATH"); - let managedNode = node; + let managedNode: string; const marker = join(root, "managed-node-used"); if (process.platform !== "win32") { managedNode = join(root, "managed node"); diff --git a/sdk/typescript/tests-ts/patch-tui.test.ts b/sdk/typescript/tests-ts/patch-tui.test.ts index 686bbe040..1239c141a 100644 --- a/sdk/typescript/tests-ts/patch-tui.test.ts +++ b/sdk/typescript/tests-ts/patch-tui.test.ts @@ -346,7 +346,8 @@ describe("interactive patch finding browser", () => { await press(app, "i"); await press(app, "Discard this guidance."); - await press(app, "\u001B"); + // A complete Escape sequence avoids Ink's delay for an ambiguous bare Escape. + await press(app, "\u001B[27u"); expect(selected).toEqual([]); expect(app.lastFrame()).not.toContain("Discard this guidance."); diff --git a/sdk/typescript/tests-ts/result.test.ts b/sdk/typescript/tests-ts/result.test.ts index 9c9e7622f..9b0bdf990 100644 --- a/sdk/typescript/tests-ts/result.test.ts +++ b/sdk/typescript/tests-ts/result.test.ts @@ -128,7 +128,19 @@ describe("ScanResult", () => { }); expect(result.cost?.estimatedUsd).toBe(0.00488); - expect(result.toJSON()["cost"]).toEqual(result.cost); + const serialized = JSON.parse(JSON.stringify(result)); + expect(serialized.cost).toEqual(result.cost); + expect(serialized.cost).toMatchObject({ + estimatedUsdRange: { min: 0.00488, max: 0.01156, context: "unknown" }, + pricing: { + longContextUsdPerMillionTokens: { + input: 8, + cacheRead: 0.8, + cacheWrite: 10, + output: 30, + }, + }, + }); }); test("discovers SARIF at its canonical scan path", async () => { diff --git a/sdk/typescript/tests-ts/scan-dashboard.test.ts b/sdk/typescript/tests-ts/scan-dashboard.test.ts index 531cefc64..a5c093983 100644 --- a/sdk/typescript/tests-ts/scan-dashboard.test.ts +++ b/sdk/typescript/tests-ts/scan-dashboard.test.ts @@ -41,6 +41,38 @@ class DashboardTestInput extends EventEmitter { } describe("live scan dashboard", () => { + test("keeps cost bounds and assumptions readable on a narrow terminal", () => { + const stderr = capture(true); + const dashboard = new ScanDashboard( + { ...stderr.stream, columns: 60, rows: 24 }, + { + repository: "/synthetic/repository", + showCost: true, + clock: fakeClock(), + }, + ); + dashboard.start(); + dashboard.setCost({ + model: "synthetic-model", + inputTokens: 1, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + cacheWriteInputTokensReported: false, + outputTokens: 1, + estimatedUsd: 1, + estimatedUsdRange: { min: 1, max: 2, context: "unknown" }, + }); + const frame = stripVTControlCharacters( + stderr.text().split("\u001B[H").at(-1)!, + ); + expect(frame.replace(/\s+/gu, " ")).toContain( + "$1.00–$2.00 (standard, context unknown, cache writes unknown)", + ); + expect(frame.split("\n")).toHaveLength(24); + expect(frame.split("\n").every((line) => line.length <= 60)).toBe(true); + dashboard.stop(); + }); + test("edits a higher total budget while continuing to show live cost", async () => { const stderr = capture(true); const input = new DashboardTestInput(); @@ -55,6 +87,7 @@ describe("live scan dashboard", () => { ...fakeResult([], "complete", { input_tokens: 100, output_tokens: 1 }) .cost!, estimatedUsd: 16, + estimatedUsdRange: { min: 16, max: 32, context: "unknown" as const }, }; dashboard.start(); dashboard.setCost(cost); @@ -68,14 +101,24 @@ describe("live scan dashboard", () => { input.emit("data", "\u00150\r"); input.emit("data", "\u0015Infinity\r"); input.emit("data", "\u001520\r"); - dashboard.setCost({ ...cost, estimatedUsd: 21 }); + const updatedCost = { + ...cost, + estimatedUsd: 21, + estimatedUsdRange: { min: 21, max: 42, context: "unknown" as const }, + }; + dashboard.setCost(updatedCost); input.emit("data", "\u001520.5\r"); expect(stderr.text()).toContain("above $21.00"); - expect(stderr.text()).toContain("$21.00 / $20.00"); + expect( + stripVTControlCharacters(stderr.text()).replace(/\s+/gu, " "), + ).toContain("short-context budget baseline: $21.00 / $20.00"); + expect(stderr.text()).toContain("$21.00–$42.00"); input.emit("data", "\u0015300\u007F\r"); await expect(answer).resolves.toBe(30); - dashboard.setCost({ ...cost, estimatedUsd: 21 }, 30); - expect(stderr.text()).toContain("$21.00 / $30.00"); + dashboard.setCost(updatedCost, 30); + expect( + stripVTControlCharacters(stderr.text()).replace(/\s+/gu, " "), + ).toContain("short-context budget baseline: $21.00 / $30.00"); dashboard.stop(); expect(input.isRaw).toBe(false); expect(input.listenerCount("data")).toBe(0); @@ -180,7 +223,15 @@ describe("live scan dashboard", () => { dashboard.recordComponentEvent({ componentId: receipt.id, type: "cost", - value: { ...cost, estimatedUsd: index + 1 }, + value: { + ...cost, + estimatedUsd: index + 1, + estimatedUsdRange: { + min: index + 1, + max: (index + 1) * 2, + context: "unknown", + }, + }, }); dashboard.recordComponentEvent({ componentId: receipt.id, @@ -213,7 +264,8 @@ describe("live scan dashboard", () => { expect(frame()).toContain("validating findings"); expect(frame()).toContain("1/10"); expect(frame()).toContain("2/10"); - expect(frame()).toContain("$3.00 · component scans only"); + expect(frame()).toContain("$3.00–$6.00 (standard, context unknown"); + expect(frame()).toContain("component scans only"); expect(frame()).toContain("before deduplication"); expect(frame().split("\n")).toHaveLength(20); input.emit("data", "\r"); diff --git a/sdk/typescript/tests-ts/scan-resume.test.ts b/sdk/typescript/tests-ts/scan-resume.test.ts index 5cc0628c6..741cced07 100644 --- a/sdk/typescript/tests-ts/scan-resume.test.ts +++ b/sdk/typescript/tests-ts/scan-resume.test.ts @@ -65,7 +65,7 @@ async function interruptedScan( "initial", ); const source = join(root, "source"); - await cp(repository, source, { recursive: true }); + git("clone", "--quiet", "--no-hardlinks", repository, source); const task = { id: "repo", repository: source, From 8c5b632741ffa7558bbd5df8d0564a328fc6b570 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 15 Sep 2026 09:00:35 +0000 Subject: [PATCH 132/133] test: remove unused publication crash fixture --- .../test_publication_stop_interleavings.py | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/plugins/codex-security/tests/test_publication_stop_interleavings.py b/plugins/codex-security/tests/test_publication_stop_interleavings.py index fdd8db225..8a6c2ff4f 100644 --- a/plugins/codex-security/tests/test_publication_stop_interleavings.py +++ b/plugins/codex-security/tests/test_publication_stop_interleavings.py @@ -56,45 +56,6 @@ def published_bytes(scan): } -_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 From aff95e71270c5c73fb928398dbf90e30e0288828 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Tue, 15 Sep 2026 10:41:02 +0000 Subject: [PATCH 133/133] test(sdk): bind budget recovery fixture to registered snapshot Use the registered target contract when authoring the saved directory snapshot. Assert its format and preservation through budget-exhausted completion. --- sdk/typescript/tests-ts/deep-scan-workbench.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts index 6d2dc7374..75c7427e3 100644 --- a/sdk/typescript/tests-ts/deep-scan-workbench.test.ts +++ b/sdk/typescript/tests-ts/deep-scan-workbench.test.ts @@ -643,6 +643,14 @@ describe("deep scan workbench ownership", () => { ]); const scanId = registration["scanId"] as string; const targetId = registration["targetId"] as string; + const snapshotDigest = ( + registration["contract"] as { + target: { requiredSnapshotDigest: string }; + } + ).target.requiredSnapshotDigest; + expect(snapshotDigest).toMatch( + /^codex-security-snapshot\/v1:sha256:[0-9a-f]{64}$/, + ); command([ "begin-deep-scan", "--scan-id", @@ -754,6 +762,7 @@ describe("deep scan workbench ownership", () => { kind: "directory_snapshot", targetId, displayName: "repository", + snapshotDigest, }, scope: { limitations: [], validationMode: "incomplete" }, }, @@ -813,6 +822,10 @@ describe("deep scan workbench ownership", () => { }; expect(scan.progress.status).toBe("complete"); expect(scan.warnings).toContain(warning); + const manifest = JSON.parse( + await readFile(join(scanDir, "scan-manifest.json"), "utf8"), + ) as { scan: { target: { snapshotDigest: string } } }; + expect(manifest.scan.target.snapshotDigest).toBe(snapshotDigest); const findings = JSON.parse( await readFile(join(scanDir, "findings.json"), "utf8"), ) as { findings: unknown[] };