From 1dfb35e546f96842d2673db0a8638102320fd3a2 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo <269034524+mldangelo-oai@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:12:17 -0700 Subject: [PATCH 1/2] feat(cli): allow interactive scan budget increases --- .../codex-security/scripts/workbench_cli.py | 4 + .../codex-security/scripts/workbench_db.py | 30 ++ .../codex-security/tests/test_workbench_db.py | 57 ++++ sdk/typescript/README.md | 21 ++ sdk/typescript/src/api.ts | 119 +++++++- sdk/typescript/src/cli.ts | 36 ++- sdk/typescript/src/cost.ts | 38 ++- sdk/typescript/src/index.ts | 1 + sdk/typescript/src/scan-dashboard.ts | 83 +++++- sdk/typescript/tests-ts/api.test.ts | 280 +++++++++++++++++- sdk/typescript/tests-ts/cli.test.ts | 39 ++- sdk/typescript/tests-ts/cost.test.ts | 67 ++++- .../tests-ts/custom-validation.test.ts | 7 +- .../tests-ts/scan-dashboard.test.ts | 84 ++++++ sdk/typescript/tests-ts/support/api-events.ts | 6 +- 15 files changed, 825 insertions(+), 47 deletions(-) diff --git a/plugins/codex-security/scripts/workbench_cli.py b/plugins/codex-security/scripts/workbench_cli.py index aed24fa69..adb3066b6 100644 --- a/plugins/codex-security/scripts/workbench_cli.py +++ b/plugins/codex-security/scripts/workbench_cli.py @@ -160,6 +160,10 @@ def parse_args(description: str) -> argparse.Namespace: set_scan_thread.add_argument("--scan-id", required=True) set_scan_thread.add_argument("--thread-id", required=True) + set_scan_cost_limit = subparsers.add_parser("set-scan-cost-limit") + set_scan_cost_limit.add_argument("--scan-id", required=True) + set_scan_cost_limit.add_argument("--max-cost-usd", required=True, type=float) + get_scan_recipe = subparsers.add_parser("get-scan-recipe") get_scan_recipe.add_argument("--scan-id", required=True) diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index b5227826f..d9bc217ee 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -7,6 +7,7 @@ import errno import hashlib import json +import math import os import re import sqlite3 @@ -1789,6 +1790,33 @@ def set_scan_thread(connection: sqlite3.Connection, args: argparse.Namespace) -> return {"scanId": scan["id"], "threadId": args.thread_id} +def set_scan_cost_limit( + connection: sqlite3.Connection, args: argparse.Namespace +) -> dict[str, Any]: + scan_id = require_uuid(args.scan_id, "scan-id") + limit = args.max_cost_usd + if not math.isfinite(limit) or limit <= 0: + raise SystemExit("The scan cost limit must be a positive finite USD amount.") + with scan_completion_lock(scan_id), connection: + scan = require_scan(connection, scan_id) + if scan["status"] != "running" or scan["recipe_json"] is None: + raise SystemExit("Only a running CLI scan can increase its cost limit.") + recipe = json.loads(scan["recipe_json"], parse_constant=reject_non_finite_json) + previous = recipe.get("maxCostUsd") + if ( + not isinstance(previous, (int, float)) + or isinstance(previous, bool) + or limit <= previous + ): + raise SystemExit("The new cost limit must exceed the current limit.") + recipe["maxCostUsd"] = limit + connection.execute( + "UPDATE scans SET recipe_json = ?, updated_at = ? WHERE id = ?", + (json.dumps(recipe, allow_nan=False), now(), scan["id"]), + ) + return {"scanId": scan["id"], "maxCostUsd": limit} + + def parse_scan_recipe(value: str, repository: Path) -> dict[str, Any]: if len(value.encode("utf-8")) > SCAN_RECIPE_MAX_BYTES: raise SystemExit("Scan launch recipe must be no larger than 256 KiB.") @@ -3468,6 +3496,8 @@ def main() -> None: result = register_cli_scan(connection, args) elif args.command == "set-scan-thread": result = set_scan_thread(connection, args) + elif args.command == "set-scan-cost-limit": + result = set_scan_cost_limit(connection, args) elif args.command == "get-scan-recipe": result = get_scan_recipe(connection, args) elif args.command == "compare-scans": diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 136a038b8..6037eb834 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -180,6 +180,63 @@ def complete_budget_scan(state_dir: Path, scan_id: str, *, check: bool = True) - ) +def test_cost_limit_increases_are_saved_without_replacing_the_scan_recipe( + tmp_path: Path, +) -> None: + state_dir, _, _, scan_id, _ = budget_scan_fixture(tmp_path) + original = run_workbench(state_dir, "get-scan-recipe", "--scan-id", scan_id)[ + "recipe" + ] + for limit in (0.0055, 0.006): + run_workbench( + state_dir, + "set-scan-cost-limit", + "--scan-id", + scan_id, + "--max-cost-usd", + str(limit), + ) + saved = run_workbench(state_dir, "get-scan-recipe", "--scan-id", scan_id)["recipe"] + assert saved == {**original, "maxCostUsd": 0.006} + assert ( + complete_budget_scan(state_dir, scan_id)["scan"]["progress"]["status"] + == "complete" + ) + stopped = run_workbench( + state_dir, + "set-scan-cost-limit", + "--scan-id", + scan_id, + "--max-cost-usd", + "1", + check=False, + ) + assert stopped["returncode"] != 0 + + +@pytest.mark.parametrize("limit", ["0", "-1", "nan", "inf", "0.004", "0.005"]) +def test_cost_limit_rejects_invalid_or_nonincreasing_totals( + tmp_path: Path, limit: str +) -> None: + state_dir, _, _, scan_id, _ = budget_scan_fixture(tmp_path, mode="standard") + result = run_workbench( + state_dir, + "set-scan-cost-limit", + "--scan-id", + scan_id, + "--max-cost-usd", + limit, + check=False, + ) + assert result["returncode"] != 0 + assert ( + run_workbench(state_dir, "get-scan-recipe", "--scan-id", scan_id)["recipe"][ + "maxCostUsd" + ] + == 0.005 + ) + + def test_budget_exhaustion_preserves_unvalidated_discovery_as_deferred_work( tmp_path: Path, ) -> None: diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 30cd43a7d..bd8cdcf76 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -513,6 +513,27 @@ discovery has finished, the scan returns a sealed partial report without more model calls and lists unvalidated candidates as follow-up work. Bulk scans apply the limit per repository attempt. +For a single scan in the interactive dashboard, reaching 80% of the limit +offers a higher **total** USD limit. Enter a larger amount to approve it, or +press Enter with an empty input or Escape to keep the current limit. The scan +continues running while you decide, and the existing limit remains enforced +until the increase is saved. Increases keep the same scan and accumulated cost; +they do not restart work or extend time or discovery limits. CI, JSON/JSONL, +`--headless`, and `--verbose` scans do not offer budget increases. If usage crosses the limit +before an increase is approved, the scan still stops. + +SDK callers can supply `onBudgetApproaching({ maxCostUsd, cost, signal })` and +return a higher total limit, or `undefined` to keep the current limit. The +callback runs once per limit at 80% usage without blocking tracking or +execution. Its signal aborts when the scan stops or finishes model work; late +answers are ignored. Invalid increases or failures to save them leave the +existing limit in place and report a warning. `onCost(cost, maxCostUsd)` reports +the current limit, including after an approved increase. + +These amounts estimate API-equivalent model usage, not ChatGPT subscription +allowance. Post-scan prompts run after scan cost tracking ends and are outside +this limit. + ### Bulk scans Run `gh auth login`, then `npx @openai/codex-security bulk-scan` to select diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index ca308aadf..6c6865aa7 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -58,7 +58,6 @@ import { import { estimateScanCost, ScanCostTracker, - sumTokenUsage, type ScanCost, type ScanSessionEvent, } from "./cost.js"; @@ -241,7 +240,10 @@ export interface ScanOptions extends DeepScanOptions { expectedPluginVersion?: string; failureSeverity?: SeverityLevel; maxCostUsd?: number; - onCost?: (cost: Readonly) => void; + onCost?: (cost: Readonly, maxCostUsd?: number) => void; + onBudgetApproaching?: ( + budget: ScanBudget, + ) => number | undefined | Promise; onOutputArchived?: (archiveDir: string) => void; onOutputDirReady?: (scanDir: string) => void; onAuthentication?: (authentication: ScanAuthentication) => void; @@ -332,6 +334,12 @@ export interface ScanWarningDetails { kind: "target_changed"; } +export interface ScanBudget { + maxCostUsd: number; + cost: Readonly; + signal: AbortSignal; +} + type ScanObserverName = | "onAuthentication" | "onCost" @@ -747,6 +755,14 @@ export class CodexSecurity { costAbortController.signal, ...(options.signal === undefined ? [] : [options.signal]), ]); + const budgetAbortController = new AbortController(); + const budgetSignal = AbortSignal.any([ + signal, + budgetAbortController.signal, + ]); + let maxCostUsd = options.maxCostUsd; + let latestCost: Readonly | null = null; + let notifiedLimit: number | undefined; let scanDir = ""; let archivedScanDir: string | null = null; let targetPathsFile: string | null = null; @@ -1007,24 +1023,86 @@ export class CodexSecurity { options.onCost === undefined && options.maxCostUsd === undefined ? undefined : (cost) => { + latestCost = cost; notifyObserver( "onCost", options.onCost, options.onObserverError, cost, + maxCostUsd, ); if ( - options.maxCostUsd !== undefined && - cost.estimatedUsd > options.maxCostUsd + maxCostUsd !== undefined && + cost.estimatedUsd > maxCostUsd ) { costAbortController.abort( - new ScanCostLimitExceededError( - options.maxCostUsd, - cost, - scanDir, - ), + new ScanCostLimitExceededError(maxCostUsd, cost, scanDir), ); + return; } + const request = options.onBudgetApproaching; + if ( + request === undefined || + maxCostUsd === undefined || + budgetSignal.aborted || + notifiedLimit === maxCostUsd || + cost.estimatedUsd < maxCostUsd * 0.8 + ) + return; + const limit = maxCostUsd; + notifiedLimit = limit; + void Promise.resolve() + .then(async () => { + if (budgetSignal.aborted) return; + const next = await request({ + maxCostUsd: limit, + cost, + signal: budgetSignal, + }); + if ( + next === undefined || + budgetSignal.aborted || + activeScan === null + ) + return; + if ( + !Number.isFinite(next) || + next <= Math.max(limit, latestCost!.estimatedUsd) + ) { + throw new CodexSecurityError( + "The new cost limit must exceed the current limit and estimated cost.", + ); + } + await workbench( + { ...activeScan.options, signal: budgetSignal }, + [ + "set-scan-cost-limit", + "--scan-id", + activeScan.id, + "--max-cost-usd", + String(next), + ], + ); + if (budgetSignal.aborted) return; + maxCostUsd = next; + notifyObserver( + "onCost", + options.onCost, + options.onObserverError, + latestCost!, + maxCostUsd, + ); + }) + .catch((error: unknown) => { + if (!budgetSignal.aborted) { + notifyObserver( + "onWarning", + options.onWarning, + options.onObserverError, + `Could not increase scan cost limit: ${errorMessage(error)}`, + ); + } + }); }, onError: reportTrackingError, }); @@ -1354,6 +1432,9 @@ export class CodexSecurity { }, onFinalize: async (usage) => { if (options.validationPrompt !== undefined) { + tracker.recordUsage(usage); + await tracker.refresh().catch(reportTrackingError); + checkOpen(); await runCustomValidation({ repository: repo, target: normalized, @@ -1399,12 +1480,16 @@ export class CodexSecurity { turn.lastStreamError ?? "The custom validation turn did not complete.", ); - usage = sumTokenUsage(usage, turn.usage); + budgetAbortController.abort(); + tracker.recordUsage(turn.usage, turn.threadId); + await tracker.refresh().catch(reportTrackingError); + checkOpen(); return turn.finalResponse; }, }); customValidationComplete = true; } + budgetAbortController.abort(); const snapshot = await tracker.stop(usage).catch((error: unknown) => { if (options.maxCostUsd !== undefined) throw error; reportTrackingError(error); @@ -1633,10 +1718,21 @@ export class CodexSecurity { // scan, and cleanup must treat all of those as a failure it is not allowed to mask. scanFailure = true; const snapshot = await costTracker?.stop().catch(() => null); - const failure = + let failure = signal.reason instanceof ScanCostLimitExceededError ? signal.reason : error; + if ( + failure instanceof ScanCostLimitExceededError && + snapshot?.cost && + snapshot.cost.estimatedUsd > failure.cost.estimatedUsd + ) { + failure = new ScanCostLimitExceededError( + failure.maxCostUsd, + snapshot.cost, + scanDir, + ); + } if ( failure instanceof ScanCostLimitExceededError && budgetRecovery !== null && @@ -1759,6 +1855,7 @@ export class CodexSecurity { } throw failure; } finally { + budgetAbortController.abort(); deepProgressTracker?.stop(); // Removing the temporary scan inputs is best effort. A throw here would replace the // outcome the try and catch blocks already produced, so these failures are reported diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 33bf8aca9..544d1f32b 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -1125,6 +1125,7 @@ interface CliDependencies { ) => Promise; hasStoredChatGPTSignIn?: (signal?: AbortSignal) => Promise; scanAuthenticationPrompt?: Pick; + scanInput?: ConstructorParameters[1]["input"]; publishPrompt?: Pick & Partial>; checkScanPublication?: typeof checkScanPublication; @@ -2906,7 +2907,9 @@ export async function main( .number() .positive() .optional() - .describe("Stop the scan if estimated USD cost exceeds AMOUNT."), + .describe( + "Stop above AMOUNT in estimated USD; the dashboard offers increases near the limit.", + ), headless: z .boolean() .default(false) @@ -6294,6 +6297,7 @@ async function executeScan( interactive = true, ): Promise { let scanDir: string | null = null; + const scanInput = dependencies.scanInput ?? process.stdin; let requestedSignal: SignalName | null = null; let firstSignalAt = 0; let progress: Progress | null = null; @@ -6303,6 +6307,7 @@ async function executeScan( let workerCapacity: { planned: number; started: number } | null = null; let fileProgress: ScanProgress | null = null; let runningCost: Readonly | null = null; + let maxCostUsd = arguments_.maxCostUsd; let phase: string | null = null; const targetWarnings: string[] = []; const configuredLogLevel = @@ -6495,7 +6500,7 @@ async function executeScan( clock: dependencies, color: dependencies.environment["NO_COLOR"] === undefined, sanitize: safeErrorMessage, - input: process.stdin, + input: scanInput, onInterrupt, }); } @@ -6564,7 +6569,11 @@ async function executeScan( expectedPluginVersion: arguments_.expectedPluginVersion, failureSeverity: arguments_.failOnSeverity, maxCostUsd: arguments_.maxCostUsd, - onCost: (cost) => { + onCost: (cost, limit = maxCostUsd) => { + if (limit !== maxCostUsd && limit !== undefined) { + dashboard?.note(`Total cost limit increased to ${formatUsd(limit)}.`); + } + maxCostUsd = limit; diagnostic("cost.updated", { model: cost.model, estimated_usd: cost.estimatedUsd, @@ -6572,15 +6581,15 @@ async function executeScan( cached_input_tokens: cost.cachedInputTokens, cache_write_input_tokens: cost.cacheWriteInputTokens, output_tokens: cost.outputTokens, - max_cost_usd: arguments_.maxCostUsd, + max_cost_usd: maxCostUsd, }); runningCost = cost; if (dashboard !== null) { - dashboard.setCost(cost); + dashboard.setCost(cost, maxCostUsd); return; } progress?.stopTimer(); - if (arguments_.maxCostUsd === undefined) { + if (maxCostUsd === undefined) { const tokens = formatTokenUsage({ input_tokens: cost.inputTokens, cached_input_tokens: cost.cachedInputTokens, @@ -6591,16 +6600,17 @@ async function executeScan( ); } else { progress?.stage( - `Estimated cost: ${formatUsd(cost.estimatedUsd)} of ${formatUsd(arguments_.maxCostUsd)} limit`, + `Estimated cost: ${formatUsd(cost.estimatedUsd)} of ${formatUsd(maxCostUsd)} limit`, ); } - if ( - arguments_.maxCostUsd === undefined || - cost.estimatedUsd <= arguments_.maxCostUsd - ) { + if (maxCostUsd === undefined || cost.estimatedUsd <= maxCostUsd) { progress?.startTimer(runningMessage()); } }, + onBudgetApproaching: + scanInput.isTTY === true + ? dashboard?.requestBudgetIncrease.bind(dashboard) + : undefined, onOutputArchived: (archiveDir) => { diagnostic("scan.output_archived", { archive_dir: archiveDir }); if (dashboard !== null) { @@ -6710,7 +6720,7 @@ async function executeScan( } }, onSessionEvent: - process.stdin.isTTY === true + scanInput.isTTY === true ? dashboard?.recordDetails.bind(dashboard) : undefined, onProgress: (update) => { @@ -6906,7 +6916,7 @@ async function executeScan( if (arguments_.mode === "deep") { deepScanStop = (await readDeepScanStop( result, - arguments_.maxCostUsd, + maxCostUsd, dependencies.runWorkbench, ).catch(() => undefined)) ?? { reason: "Stop reason unavailable. See the report for details.", diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 05b72254f..8db94b604 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -108,6 +108,7 @@ function createSessionUsage(): SessionUsage { export class ScanCostTracker { readonly #options: ScanCostTrackerOptions; readonly #sessions = new Map(); + readonly #receipts = new Map(); readonly #workers = new Map(); readonly #workerProgress = new Map(); readonly #reportedProgress = new Set(); @@ -128,6 +129,13 @@ export class ScanCostTracker { this.#expectedFilesTotal = filesTotal; } + public recordUsage(usage: unknown, threadId = this.#threadId): void { + const normalized = tokenUsage(usage); + if (threadId !== null) { + this.#receipts.set(threadId, normalized); + } + } + public start(threadId: string): void { if (this.#threadId !== null) return; this.#threadId = threadId; @@ -179,8 +187,10 @@ export class ScanCostTracker { clearInterval(this.#timer); this.#timer = null; } + if (fallbackUsage !== undefined) this.recordUsage(fallbackUsage); await this.refresh(); - if (this.#snapshot.usage !== null) return this.#snapshot; + if (this.#receipts.size > 0 || this.#snapshot.usage !== null) + return this.#snapshot; const cost = estimateScanCost(this.#options.model, fallbackUsage); this.#snapshot = { usage: fallbackUsage ?? null, cost }; this.#reportCost(cost); @@ -206,7 +216,7 @@ export class ScanCostTracker { } } - const included = new Set([this.#threadId]); + const included = new Set([this.#threadId, ...this.#receipts.keys()]); if (this.#options.scanDirectory !== undefined) { const scanStartedAt = [...this.#sessions.values()].find( @@ -252,7 +262,7 @@ export class ScanCostTracker { if (included.has(session.threadId!)) throw error; } - let usage: ScanTokenUsage | null = null; + const usages = new Map(this.#receipts); for (const [path, tracked] of this.#sessions) { const threadId = tracked.threadId; if (threadId === null || !included.has(threadId)) continue; @@ -290,9 +300,20 @@ export class ScanCostTracker { } this.#reportWorkerProgress(session); } - if (session.usage !== null) { - usage = addTokenUsage(usage, session.usage); + if ( + session.usage !== null && + session.usage.total_tokens > (usages.get(threadId)?.total_tokens ?? -1) + ) { + usages.set(threadId, session.usage); + } + } + let usage: ScanTokenUsage | null = null; + for (const value of usages.values()) { + if (value === null) { + this.#snapshot = { usage: null, cost: null }; + return; } + usage = addTokenUsage(usage, value); } if (usage === null) return; const cost = estimateScanCost(this.#options.model, usage); @@ -767,13 +788,6 @@ function addTokenUsage( }; } -/** @internal Sum complete turn receipts when session usage is unavailable. */ -export function sumTokenUsage(first: unknown, second: unknown): unknown { - const left = tokenUsage(first); - const right = tokenUsage(second); - return left === null || right === null ? null : addTokenUsage(left, right); -} - function subtractTokenUsage( usage: ScanTokenUsage, inherited: ScanTokenUsage, diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 29de039dd..c964670de 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -22,6 +22,7 @@ export type { DeepScanOptions, ScanAuthMode, ScanAuthentication, + ScanBudget, ScanOptions, ScanPreflight, ScanReconnectDetails, diff --git a/sdk/typescript/src/scan-dashboard.ts b/sdk/typescript/src/scan-dashboard.ts index c476cdeab..3d9bfec22 100644 --- a/sdk/typescript/src/scan-dashboard.ts +++ b/sdk/typescript/src/scan-dashboard.ts @@ -1,6 +1,7 @@ import { basename, isAbsolute } from "node:path"; import { pathToFileURL } from "node:url"; import { stripVTControlCharacters } from "node:util"; +import type { ScanBudget } from "./api.js"; import type { ScanModelConfiguration } from "./config.js"; import type { ComponentReceipt, @@ -128,6 +129,12 @@ export class ScanDashboard { #files: ScanProgress | null = null; #publicationProgress: { completed: number; total: number } | null = null; #cost: Readonly | null = null; + #budget: { + request: ScanBudget; + input: string; + error: string; + finish: (limit?: number) => void; + } | null = null; #timer: NodeJS.Timeout | null = null; #scrollOffset = 0; #view: "activity" | "details" = "activity"; @@ -139,6 +146,39 @@ export class ScanDashboard { readonly #onInput = (chunk: string | Uint8Array): void => { const input = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + if (this.#budget !== null) { + for (const key of input.match(/\u001B\[[0-?]*[ -/]*[@-~]|[\s\S]/gu) ?? + []) { + const budget = this.#budget; + if (budget === null) break; + if (key === "\u0003" || key === "\u0004") { + budget.finish(); + this.#options.onInterrupt?.(); + } else if (key === "\u001B") { + budget.finish(); + } else if (key === "\r" || key === "\n") { + const value = budget.input.trim(); + const limit = Number(value); + const minimum = Math.max( + budget.request.maxCostUsd, + this.#cost?.estimatedUsd ?? budget.request.cost.estimatedUsd, + ); + if (value === "") budget.finish(); + else if (Number.isFinite(limit) && limit > minimum) + budget.finish(limit); + else + budget.error = `Enter a finite total above ${formatUsd(minimum)}.`; + } else if (key === "\u007F" || key === "\b") { + budget.input = budget.input.slice(0, -1); + } else if (key === "\u0015") { + budget.input = ""; + } else if (!key.startsWith("\u001B") && key >= " ") { + budget.input += key; + } + } + this.#refresh(); + return; + } if (this.#options.presentation === "components") { this.#componentInput(input); return; @@ -237,6 +277,7 @@ export class ScanDashboard { if (this.#timer === null) return; this.#options.clock.clearInterval(this.#timer); this.#timer = null; + this.#budget?.finish(); const input = this.#options.input; try { if (input?.isTTY === true) { @@ -366,11 +407,40 @@ export class ScanDashboard { this.#refresh(); } - public setCost(cost: Readonly): void { + public setCost( + cost: Readonly, + maxCostUsd = this.#options.maxCostUsd, + ): void { this.#cost = cost; + this.#options.maxCostUsd = maxCostUsd; this.#refresh(); } + public requestBudgetIncrease( + request: ScanBudget, + ): Promise { + if ( + request.signal.aborted || + this.#timer === null || + this.#options.input?.isTTY !== true || + this.#budget !== null + ) { + return Promise.resolve(undefined); + } + return new Promise((resolve) => { + const abort = () => finish(); + const finish = (limit?: number) => { + request.signal.removeEventListener("abort", abort); + this.#budget = null; + this.#refresh(); + resolve(limit); + }; + this.#budget = { request, input: "", error: "", finish }; + request.signal.addEventListener("abort", abort, { once: true }); + this.#refresh(); + }); + } + public note(description: string): void { this.record({ id: `scan-note-${++this.#noteCount}`, @@ -550,8 +620,14 @@ export class ScanDashboard { : [` STAGE ${this.#stage}`, ` FILES ${files}`]), ` TOKENS ${tokens}`, ` COST ${cost}`, + ...(this.#budget === null + ? [] + : [ + ` BUDGET Raise total USD limit: ${this.#budget.input}_`, + ` ${this.#budget.error || "Scan running. Enter blank/Esc keeps limit."}`, + ]), ]), - ` TIME ${time} · ${scrollStatus}`, + ` TIME ${time} · ${this.#budget === null ? scrollStatus : "Enter to apply · Ctrl+C to exit"}`, ]; return this.#formatFrame(lines); @@ -726,7 +802,8 @@ export class ScanDashboard { return Math.max( 1, (this.#stream.rows ?? 24) - - FIXED_SCREEN_ROWS + + FIXED_SCREEN_ROWS - + (this.#budget === null ? 0 : 2) + (this.#options.presentation === "publication" ? 2 : this.#options.mode === "deep" diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index e258155f5..5b6448998 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -265,11 +265,12 @@ async function writeUsageSession( threadId: string, usage: Record, parentThreadId?: string, -): Promise { +): Promise { const directory = join(codexHome, "sessions", "2026", "07", "26"); await mkdir(directory, { recursive: true }); + const path = join(directory, `rollout-${threadId}.jsonl`); await writeFile( - join(directory, `rollout-${threadId}.jsonl`), + path, [ JSON.stringify({ type: "session_meta", @@ -290,6 +291,22 @@ async function writeUsageSession( "", ].join("\n"), ); + return path; +} + +async function appendUsage(path: string, inputTokens: number): Promise { + await appendFile( + path, + `${JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: inputTokens, output_tokens: 0 }, + }, + }, + })}\n`, + ); } describe("CodexSecurity finding validation", () => { @@ -4107,6 +4124,265 @@ describe("CodexSecurity orchestration", () => { }, ); + test("raises a live budget twice without restarting or resetting accumulated usage", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await Promise.all([mkdir(repository), mkdir(codexHome), mkdir(scanDir)]); + const approvals = new Map void>(); + const firstApproval = new Promise((resolve) => + approvals.set(0.01, resolve), + ); + const secondApproval = new Promise((resolve) => + approvals.set(0.02, resolve), + ); + const requests: number[] = []; + const commands: Array = []; + let starts = 0; + let budgetSignal: AbortSignal | undefined; + 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); + return mockWorkbench(args, input); + }, + createCodex: () => ({ + startThread: () => { + starts += 1; + return { + id: null, + async runStreamed() { + async function* events(): AsyncGenerator { + yield { type: "thread.started", thread_id: "scan-thread" }; + const path = await writeUsageSession( + codexHome, + "scan-thread", + { input_tokens: 800, output_tokens: 0 }, + ); + await writeUsageSession( + codexHome, + "worker-thread", + { input_tokens: 100, output_tokens: 0 }, + "scan-thread", + ); + await firstApproval; + await appendUsage(path, 1_700); + await secondApproval; + await copyCompletedScan(root); + yield { + type: "turn.completed", + usage: { + input_tokens: 2_500, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + }, + }; + } + return { events: events() }; + }, + }; + }, + }), + }, + ); + const keepAlive = setTimeout(() => {}, 10_000); + try { + const result = await client.run(repository, { + maxCostUsd: 0.005, + signal: AbortSignal.timeout(5_000), + onBudgetApproaching: ({ maxCostUsd, signal }) => { + requests.push(maxCostUsd); + budgetSignal = signal; + return maxCostUsd * 2; + }, + onCost: (_cost, limit) => { + if (limit !== undefined) approvals.get(limit)?.(); + }, + }); + expect(result.cost).toMatchObject({ + inputTokens: 2_600, + estimatedUsd: 0.013, + }); + expect(starts).toBe(1); + expect(requests).toEqual([0.005, 0.01]); + expect( + commands + .filter(([command]) => command === "set-scan-cost-limit") + .map((args) => args.at(-1)), + ).toEqual(["0.01", "0.02"]); + expect(commands.some(([command]) => command === "fail-scan")).toBe(false); + expect(budgetSignal?.aborted).toBe(true); + } finally { + clearTimeout(keepAlive); + await client.close(); + } + }); + + test.each([ + "declined", + "pending", + "saving", + "invalid", + "save-failed", + "completed", + "canceled", + ] as const)( + "keeps the original budget enforceable when an increase is %s", + async (scenario) => { + 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)]); + let requested!: () => void; + const requestStarted = new Promise((resolve) => { + requested = resolve; + }); + let observed!: () => void; + const nextCost = new Promise((resolve) => { + observed = resolve; + }); + let answer!: (limit: number) => void; + const lateAnswer = new Promise((resolve) => { + answer = resolve; + }); + const controller = new AbortController(); + const commands: Array = []; + const warnings: string[] = []; + let requestCount = 0; + let reportedLimit: number | undefined; + let budgetSignal: AbortSignal | undefined; + 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 (scenario === "save-failed" && args[0] === "set-scan-cost-limit") + throw new Error("Synthetic save failure"); + if (scenario === "saving" && args[0] === "set-scan-cost-limit") + await lateAnswer; + return mockWorkbench(args, input); + }, + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed( + _input: string, + { signal }: { signal: AbortSignal }, + ) { + async function* events(): AsyncGenerator { + yield { type: "thread.started", thread_id: "scan-thread" }; + const path = await writeUsageSession( + codexHome, + "scan-thread", + { input_tokens: 900, output_tokens: 0 }, + ); + await requestStarted; + if (scenario === "completed") { + await copyCompletedScan(root); + yield { + type: "turn.completed", + usage: { + input_tokens: 900, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + }, + }; + return; + } + await appendUsage(path, 950); + await nextCost; + if (scenario === "canceled") controller.abort(); + else await appendUsage(path, 1_200); + await new Promise((resolve) => { + if (signal.aborted) resolve(); + else + signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + await appendUsage(path, 2_000); + throw new DOMException("aborted", "AbortError"); + } + return { events: events() }; + }, + }), + }), + }, + ); + const keepAlive = setTimeout(() => {}, 10_000); + try { + const scan = client.run(repository, { + maxCostUsd: 0.005, + signal: AbortSignal.any([ + controller.signal, + AbortSignal.timeout(5_000), + ]), + onBudgetApproaching: ({ signal }) => { + requestCount += 1; + budgetSignal = signal; + requested(); + if (scenario === "declined") return undefined; + if (scenario === "invalid") return 0.005; + if (scenario === "save-failed" || scenario === "saving") + return 0.02; + return lateAnswer; + }, + onCost: (cost, limit) => { + reportedLimit = limit; + if (cost.inputTokens === 950) observed(); + }, + onWarning: (warning) => warnings.push(warning), + }); + if (scenario === "completed") + await expect(scan).resolves.toMatchObject({ + cost: { estimatedUsd: 0.0045 }, + }); + else if (scenario === "canceled") + await expect(scan).rejects.toBeInstanceOf(ScanInterruptedError); + else + await expect(scan).rejects.toMatchObject({ + name: ScanCostLimitExceededError.name, + maxCostUsd: 0.005, + cost: { estimatedUsd: 0.01 }, + }); + answer(0.02); + await new Promise((resolve) => setImmediate(resolve)); + expect(requestCount).toBe(1); + expect(reportedLimit).toBe(0.005); + expect(budgetSignal?.aborted).toBe(true); + expect( + commands.filter(([command]) => command === "set-scan-cost-limit"), + ).toHaveLength( + scenario === "save-failed" || scenario === "saving" ? 1 : 0, + ); + if (scenario === "invalid" || scenario === "save-failed") + expect(warnings).toContainEqual( + expect.stringContaining("Could not increase scan cost limit"), + ); + } 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/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 398724d3d..29dd20605 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -10,7 +10,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, join, normalize } from "node:path"; -import { Writable } from "node:stream"; +import { PassThrough, Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { stripVTControlCharacters } from "node:util"; import { describe, expect, test } from "bun:test"; @@ -4781,6 +4781,43 @@ describe("CLI", () => { expect(stderr.text()).not.toContain("Next:"); }); + test.each([ + [[], {}, true, true, true], + [["--headless"], {}, true, true, false], + [["--verbose"], {}, true, true, false], + [["--json"], {}, true, true, false], + [["--format", "jsonl"], {}, true, true, false], + [[], { CI: "true" }, true, true, false], + [[], { TERM: "dumb" }, true, true, false], + [[], {}, false, true, false], + [[], {}, true, false, false], + ] as const)( + "gates budget interaction for flags %j, environment %j, input TTY %s, output TTY %s", + async (flags, environment, inputTty, outputTty, expected) => { + const input = Object.assign(new PassThrough(), { isTTY: inputTty }); + let budgetCallback: ScanOptions["onBudgetApproaching"]; + expect( + await main( + ["scan", ".", "--max-cost", "20", ...flags], + capture().stream, + capture(outputTty).stream, + { + ...dependencies({ + environment, + onTurn: (_repository, options) => { + budgetCallback = (options as ScanOptions).onBudgetApproaching; + }, + }), + scanInput: input, + }, + ), + ).toBe(0); + expect(budgetCallback !== undefined).toBe(expected); + expect(input.listenerCount("data")).toBe(0); + input.destroy(); + }, + ); + test("reports the running cost against the scan budget", async () => { const stdout = capture(); const stderr = capture(); diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 3d0fbe317..81628dc68 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -1982,6 +1982,39 @@ describe("live scan cost tracking", () => { expect(updates).toEqual([0.00625]); }); + test.each([undefined, 100, 1_000, 1_500])( + "reconciles the parent receipt with worker usage when logged parent tokens are %s", + async (parentTokens) => { + const home = await codexHome(); + if (parentTokens !== undefined) { + await writeSession(home, "scan-thread", { + input_tokens: parentTokens, + output_tokens: 0, + }); + } + await writeSession( + home, + "worker-thread", + { input_tokens: 100, output_tokens: 0 }, + "scan-thread", + ); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + tracker.start("scan-thread"); + + const snapshot = await tracker.stop({ + input_tokens: 1_000, + output_tokens: 0, + }); + + expect(snapshot.cost?.inputTokens).toBe( + Math.max(parentTokens ?? 0, 1_000) + 100, + ); + }, + ); + test("falls back to the completed turn when session logs are unavailable", async () => { const tracker = new ScanCostTracker({ codexHome: await codexHome(), @@ -1991,7 +2024,13 @@ describe("live scan cost tracking", () => { tracker.start("scan-thread"); expect(await tracker.stop(usage)).toEqual({ - usage, + usage: { + ...usage, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 1_020, + }, cost: { model: "gpt-5.6-luna", inputTokens: 1_000, @@ -2002,4 +2041,30 @@ describe("live scan cost tracking", () => { }, }); }); + + test.each(["receipt", "receipt-and-log", "unknown"] as const)( + "accounts for a separate validation turn with %s usage", + async (source) => { + const home = await codexHome(); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + }); + tracker.start("scan-thread"); + const usage = { input_tokens: 500, output_tokens: 0 }; + tracker.recordUsage( + source === "unknown" ? null : usage, + "validation-thread", + ); + if (source === "receipt-and-log") + await writeSession(home, "validation-thread", usage); + const snapshot = await tracker.stop({ + input_tokens: 1_000, + output_tokens: 0, + }); + if (source === "unknown") + expect(snapshot).toEqual({ usage: null, cost: null }); + else expect(snapshot.cost?.inputTokens).toBe(1_500); + }, + ); }); diff --git a/sdk/typescript/tests-ts/custom-validation.test.ts b/sdk/typescript/tests-ts/custom-validation.test.ts index 6da81a99e..c1b3abe2b 100644 --- a/sdk/typescript/tests-ts/custom-validation.test.ts +++ b/sdk/typescript/tests-ts/custom-validation.test.ts @@ -140,7 +140,7 @@ async function* responseEvents( value: unknown, activity?: string, ): AsyncGenerator { - for await (const event of completedEvents()) { + for await (const event of completedEvents("validation-thread")) { if ( event.type === "item.completed" && event.item.type === "agent_message" @@ -402,7 +402,10 @@ describe("custom validation", () => { expect(threadOptions.threadSource).toBe("security_scan"); workingDirectories.push(threadOptions.workingDirectory); return { - id: "thread-1", + id: + workingDirectories.length === 1 + ? "thread-1" + : "validation-thread", async runStreamed(prompt, turnOptions) { turns += 1; if (turns === 1) { diff --git a/sdk/typescript/tests-ts/scan-dashboard.test.ts b/sdk/typescript/tests-ts/scan-dashboard.test.ts index db1ca1a14..d6bded3a7 100644 --- a/sdk/typescript/tests-ts/scan-dashboard.test.ts +++ b/sdk/typescript/tests-ts/scan-dashboard.test.ts @@ -41,6 +41,90 @@ class DashboardTestInput extends EventEmitter { } describe("live scan dashboard", () => { + test("edits a higher total budget while continuing to show live cost", async () => { + const stderr = capture(true); + const input = new DashboardTestInput(); + const dashboard = new ScanDashboard(stderr.stream, { + repository: "/synthetic/repository", + maxCostUsd: 20, + clock: fakeClock(), + input, + }); + const controller = new AbortController(); + const cost = { + ...fakeResult([], "complete", { input_tokens: 100, output_tokens: 1 }) + .cost!, + estimatedUsd: 16, + }; + dashboard.start(); + dashboard.setCost(cost); + const answer = dashboard.requestBudgetIncrease({ + maxCostUsd: 20, + cost, + signal: controller.signal, + }); + input.emit("data", "-30\r"); + expect(stderr.text()).toContain("Enter a finite total above"); + input.emit("data", "\u00150\r"); + input.emit("data", "\u0015Infinity\r"); + input.emit("data", "\u001520\r"); + dashboard.setCost({ ...cost, estimatedUsd: 21 }); + input.emit("data", "\u001520.5\r"); + expect(stderr.text()).toContain("above $21.00"); + expect(stderr.text()).toContain("$21.00 / $20.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.stop(); + expect(input.isRaw).toBe(false); + expect(input.listenerCount("data")).toBe(0); + }); + + test.each(["enter", "escape", "abort", "stop", "interrupt", "eof"] as const)( + "dismisses a budget prompt without increasing the limit on %s", + async (action) => { + const stderr = capture(true); + const input = new DashboardTestInput(); + let interrupted = false; + const dashboard = new ScanDashboard(stderr.stream, { + repository: "/synthetic/repository", + maxCostUsd: 20, + clock: fakeClock(), + input, + onInterrupt: () => { + interrupted = true; + }, + }); + const controller = new AbortController(); + dashboard.start(); + const answer = dashboard.requestBudgetIncrease({ + maxCostUsd: 20, + cost: { + ...fakeResult([], "complete", { input_tokens: 100, output_tokens: 1 }) + .cost!, + estimatedUsd: 16, + }, + signal: controller.signal, + }); + if (action === "abort") controller.abort(); + else if (action === "stop") dashboard.stop(); + else + input.emit( + "data", + { enter: "\r", escape: "\u001B", interrupt: "\u0003", eof: "\u0004" }[ + action + ], + ); + await expect(answer).resolves.toBeUndefined(); + expect(interrupted).toBe(action === "interrupt" || action === "eof"); + input.emit("data", "30\r"); + dashboard.stop(); + expect(input.isRaw).toBe(false); + expect(input.listenerCount("data")).toBe(0); + }, + ); + test("shows concurrent components and keeps their activity and costs separate", () => { const stderr = capture(true); const input = new DashboardTestInput(); diff --git a/sdk/typescript/tests-ts/support/api-events.ts b/sdk/typescript/tests-ts/support/api-events.ts index 2b5d501cd..2306bea65 100644 --- a/sdk/typescript/tests-ts/support/api-events.ts +++ b/sdk/typescript/tests-ts/support/api-events.ts @@ -80,8 +80,10 @@ export function createApiTestFixtures() { }; } -export async function* completedEvents(): AsyncGenerator { - yield { type: "thread.started", thread_id: "thread-1" }; +export async function* completedEvents( + threadId = "thread-1", +): AsyncGenerator { + yield { type: "thread.started", thread_id: threadId }; yield { type: "turn.started" }; yield { type: "item.completed", From e99ea0e4d96bda40c7157c6a6ec3161167911d2e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo <269034524+mldangelo-oai@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:50:25 -0700 Subject: [PATCH 2/2] fix(ci): initialize Python for Windows scan tests --- .github/workflows/node-ci.yml | 5 +++++ plugins/codex-security/scripts/workbench_db.py | 4 +--- .../codex-security/tests/test_workbench_db.py | 17 ++++------------- sdk/typescript/tests-ts/api.test.ts | 2 ++ 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 0bf017df4..35592d5a3 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -300,6 +300,11 @@ jobs: with: persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index d9bc217ee..4ee2fdf52 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1790,9 +1790,7 @@ def set_scan_thread(connection: sqlite3.Connection, args: argparse.Namespace) -> return {"scanId": scan["id"], "threadId": args.thread_id} -def set_scan_cost_limit( - connection: sqlite3.Connection, args: argparse.Namespace -) -> dict[str, Any]: +def set_scan_cost_limit(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: scan_id = require_uuid(args.scan_id, "scan-id") limit = args.max_cost_usd if not math.isfinite(limit) or limit <= 0: diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 6037eb834..14a8af33d 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -184,9 +184,7 @@ def test_cost_limit_increases_are_saved_without_replacing_the_scan_recipe( tmp_path: Path, ) -> None: state_dir, _, _, scan_id, _ = budget_scan_fixture(tmp_path) - original = run_workbench(state_dir, "get-scan-recipe", "--scan-id", scan_id)[ - "recipe" - ] + original = run_workbench(state_dir, "get-scan-recipe", "--scan-id", scan_id)["recipe"] for limit in (0.0055, 0.006): run_workbench( state_dir, @@ -198,10 +196,7 @@ def test_cost_limit_increases_are_saved_without_replacing_the_scan_recipe( ) saved = run_workbench(state_dir, "get-scan-recipe", "--scan-id", scan_id)["recipe"] assert saved == {**original, "maxCostUsd": 0.006} - assert ( - complete_budget_scan(state_dir, scan_id)["scan"]["progress"]["status"] - == "complete" - ) + assert complete_budget_scan(state_dir, scan_id)["scan"]["progress"]["status"] == "complete" stopped = run_workbench( state_dir, "set-scan-cost-limit", @@ -215,9 +210,7 @@ def test_cost_limit_increases_are_saved_without_replacing_the_scan_recipe( @pytest.mark.parametrize("limit", ["0", "-1", "nan", "inf", "0.004", "0.005"]) -def test_cost_limit_rejects_invalid_or_nonincreasing_totals( - tmp_path: Path, limit: str -) -> None: +def test_cost_limit_rejects_invalid_or_nonincreasing_totals(tmp_path: Path, limit: str) -> None: state_dir, _, _, scan_id, _ = budget_scan_fixture(tmp_path, mode="standard") result = run_workbench( state_dir, @@ -230,9 +223,7 @@ def test_cost_limit_rejects_invalid_or_nonincreasing_totals( ) assert result["returncode"] != 0 assert ( - run_workbench(state_dir, "get-scan-recipe", "--scan-id", scan_id)["recipe"][ - "maxCostUsd" - ] + run_workbench(state_dir, "get-scan-recipe", "--scan-id", scan_id)["recipe"]["maxCostUsd"] == 0.005 ) diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 5b6448998..1380f9467 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -91,6 +91,8 @@ test.each(["completed", "receipt-lost", "scan-interrupted"])( const environment = { PATH: process.env["PATH"], SystemRoot: process.env["SystemRoot"], + TEMP: process.env["TEMP"], + TMP: process.env["TMP"], CODEX_SECURITY_STATE_DIR: join(root, "state"), }; const workflowId = "durable-scan";