diff --git a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json index bfeb6cd23..4345f1e2f 100644 --- a/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json +++ b/sdk/typescript/_bundled_plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.60", + "version": "0.1.75", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 index 8a861abee..331c23878 100644 Binary files a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 and b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-000 differ diff --git a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 index 774602a86..e6b78b24f 100644 Binary files a/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 and b/sdk/typescript/_bundled_plugin/mcp/server.mjs.br.part-001 differ diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py index 2019115f8..5c3bc4b59 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_constants.py @@ -68,6 +68,10 @@ "GIT_WORK_TREE", ) EMPTY_GIT_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +EMPTY_GIT_TREES = { + "sha1": EMPTY_GIT_TREE, + "sha256": "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321", +} def main() -> None: diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index f703f1269..4a79a8f0b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -17,6 +17,7 @@ import tempfile import time import uuid +from collections.abc import Callable from contextlib import closing, contextmanager from datetime import datetime, timedelta, timezone from pathlib import Path, PurePosixPath @@ -66,7 +67,7 @@ CLAIM_LEASE_SECONDS, DELIVERED_ACTION_LEASE_SECONDS, DIFF_TARGET_KINDS, - EMPTY_GIT_TREE, + EMPTY_GIT_TREES, FINDING_ABSOLUTE_PATH_BYTES, FINDING_LEVEL_BYTES, FINDING_LOCATION_PATH_BYTES, @@ -104,10 +105,13 @@ from workbench_source_excerpt import finding_source_excerpt, safe_source_path from workbench_target import ( clean_worktree_content_digest, + committed_diff_content_snapshot, + committed_diff_object_identity, copy_directory_excluding, copy_git_worktree_files, directory_content_digest, directory_snapshot_regular_file_count, + empty_git_tree, git_bytes, git_command, git_output, @@ -249,6 +253,82 @@ def connect() -> sqlite3.Connection: raise AssertionError("SQLite retry loop exhausted unexpectedly.") +def begin_immediate_after_external_inspection( + connection: sqlite3.Connection, + inspect: Callable[[], bool], + validate: Callable[[], bool], + inspection_unnecessary: Callable[[], bool] | None = None, +) -> bool: + """Inspect outside the lock, then validate immutable Git coordinates under it.""" + connection.execute("BEGIN IMMEDIATE") + try: + if inspection_unnecessary is not None and inspection_unnecessary(): + return True + except BaseException: + connection.rollback() + raise + connection.rollback() + busy_timeout = int(connection.execute("PRAGMA busy_timeout").fetchone()[0]) + while True: + try: + inspected = inspect() + except (Exception, SystemExit): + if inspection_unnecessary is None: + raise + connection.execute("BEGIN IMMEDIATE") + try: + if inspection_unnecessary(): + return True + except BaseException: + connection.rollback() + raise + connection.rollback() + raise + blocked = False + connection.execute("PRAGMA busy_timeout = 0") + try: + connection.execute("BEGIN IMMEDIATE") + except sqlite3.OperationalError as exc: + if not sqlite_busy(exc): + raise + blocked = True + finally: + connection.execute(f"PRAGMA busy_timeout = {busy_timeout}") + if not blocked: + try: + if inspection_unnecessary is not None and inspection_unnecessary(): + return True + return bool(inspected and validate()) + except BaseException: + connection.rollback() + raise + + connection.execute("BEGIN IMMEDIATE") + try: + if inspection_unnecessary is not None and inspection_unnecessary(): + return True + except BaseException: + connection.rollback() + raise + connection.rollback() + + +def begin_immediate_with_target_inspection( + connection: sqlite3.Connection, + inspect: Callable[[], bool], + inspection_unnecessary: Callable[[], bool] | None = None, +) -> bool: + """Inspect a mutable target while holding the writer transaction.""" + connection.execute("BEGIN IMMEDIATE") + try: + if inspection_unnecessary is not None and inspection_unnecessary(): + return True + return inspect() + except BaseException: + connection.rollback() + raise + + def apply_migrations(connection: sqlite3.Connection) -> None: apply_schema_migrations(connection, MIGRATIONS, now, backfill_security_targets) @@ -272,7 +352,13 @@ def inspect_target(target_path: str) -> dict[str, Any]: } -def resolve_git_commit(target: Path, revision: str, label: str) -> str: +def resolve_git_commit( + target: Path, + revision: str, + label: str, + *, + local_objects_only: bool = False, +) -> str: value = optional_text(revision, maximum=512) if not value: raise SystemExit(f"{label} is required.") @@ -282,12 +368,226 @@ def resolve_git_commit(target: Path, revision: str, label: str) -> str: "--verify", "--end-of-options", f"{value}^{{commit}}", + local_objects_only=local_objects_only, ) if resolved is None: raise SystemExit(f"{label} does not resolve to a local Git commit: {value}") return resolved +def is_canonical_git_object_id(value: Any, object_id_length: int) -> bool: + return ( + isinstance(value, str) + and len(value) == object_id_length + and all(character in "0123456789abcdef" for character in value) + ) + + +def require_committed_diff_snapshot( + target: Path, + base: str, + head: str, + selected_digest: str | None, +) -> tuple[str, str]: + current_digest, object_identity = committed_diff_content_snapshot(target, base, head) + if selected_digest is not None and selected_digest != current_digest: + raise SystemExit( + "The committed changes selected for review no longer produce the same " + "diff. Select the changes to review again." + ) + return current_digest, object_identity + + +class ResolvedDiffTargetError(SystemExit): + def __init__( + self, + message: str, + *, + kind: str, + base_revision: str | None, + head_revision: str, + ) -> None: + super().__init__(message) + self.kind = kind + self.base_revision = base_revision + self.head_revision = head_revision + + +def require_resolved_committed_diff_snapshot( + target: Path, + kind: str, + base: str, + head: str, + content_digest: str | None, + *, + persisted_base: str | None, +) -> tuple[dict[str, str], str]: + try: + digest, object_identity = require_committed_diff_snapshot( + target, + base, + head, + content_digest, + ) + except SystemExit as exc: + raise ResolvedDiffTargetError( + str(exc), + kind=kind, + base_revision=persisted_base, + head_revision=head, + ) from None + return ( + { + "kind": kind, + "baseRevision": base, + "headRevision": head, + "contentDigest": digest, + }, + object_identity, + ) + + +def resolve_committed_diff_coordinates( + target: Path, + kind: str, + base_revision: str | None, + head_revision: str | None, + *, + allow_equal_range: bool = False, + local_objects_only: bool = False, +) -> tuple[str, str, str | None]: + if kind == "commit": + head = resolve_git_commit( + target, + head_revision or "", + "Commit", + local_objects_only=local_objects_only, + ) + commit = git_bytes( + target, + "cat-file", + "-p", + head, + local_objects_only=local_objects_only, + ) + if commit is None: + raise SystemExit(f"Commit is not available in the local checkout: {head}") + parent_line = next( + (line for line in commit.splitlines() if line.startswith(b"parent ")), + None, + ) + if parent_line is None: + parent = empty_git_tree(target) + else: + parent = resolve_git_commit( + target, + parent_line.removeprefix(b"parent ").decode("ascii").strip(), + "Commit parent", + local_objects_only=local_objects_only, + ) + if base_revision and base_revision != parent: + supplied_base = ( + base_revision + if base_revision in EMPTY_GIT_TREES.values() + else resolve_git_commit( + target, + base_revision, + "Commit base", + local_objects_only=local_objects_only, + ) + ) + if supplied_base != parent: + raise SystemExit("Commit base revision must match the selected commit's parent.") + return parent, head, parent if base_revision is not None else None + if kind != "range": + raise ValueError(f"Unsupported committed diff kind: {kind}") + base = resolve_git_commit( + target, + base_revision or "", + "Base revision", + local_objects_only=local_objects_only, + ) + head = resolve_git_commit( + target, + head_revision or "", + "Head revision", + local_objects_only=local_objects_only, + ) + if base == head and not allow_equal_range: + raise SystemExit("Base and head revisions must identify different commits.") + return base, head, base + + +def committed_diff_lock_identity( + target: Path, + kind: str, + base: str, + head: str, + object_identity: str | None = None, +) -> tuple[int, int, str, str, str, str]: + metadata = target.stat() + return ( + metadata.st_dev, + metadata.st_ino, + kind, + base, + head, + object_identity + if object_identity is not None + else committed_diff_object_identity(target, base, head), + ) + + +def committed_diff_lock_validator( + target: Path, + diff_target: dict[str, str], + object_identity: str, + base_revision: str | None, + head_revision: str | None, + *, + allow_equal_range: bool = False, +) -> tuple[ + Callable[[Path, dict[str, str], str], bool], + Callable[[], bool], +]: + target_path = str(target) + kind = diff_target["kind"] + inspected_identity = committed_diff_lock_identity( + target, + kind, + diff_target["baseRevision"], + diff_target["headRevision"], + object_identity, + ) + + def matches_inspected_identity( + target: Path, + diff_target: dict[str, str], + object_identity: str, + ) -> bool: + return committed_diff_lock_identity( + target, + diff_target["kind"], + diff_target["baseRevision"], + diff_target["headRevision"], + object_identity, + ) == inspected_identity + + def validate() -> bool: + target = require_remediation_target(target_path) + base, head, _ = resolve_committed_diff_coordinates( + target, + kind, + base_revision, + head_revision, + allow_equal_range=allow_equal_range, + local_objects_only=True, + ) + return committed_diff_lock_identity(target, kind, base, head) == inspected_identity + + return matches_inspected_identity, validate + + def require_diff_target( target: Path, kind: str | None, @@ -318,37 +618,89 @@ def require_diff_target( "headRevision": current_head, "contentDigest": current_digest, } - if kind == "commit": - head = resolve_git_commit(target, head_revision or "", "Commit") - commit = git_bytes(target, "cat-file", "-p", head) - if commit is None: - raise SystemExit(f"Commit is not available in the local checkout: {head}") - parent_line = next( - (line for line in commit.splitlines() if line.startswith(b"parent ")), + base, head, persisted_base = resolve_committed_diff_coordinates( + target, + kind, + base_revision, + head_revision, + ) + return require_resolved_committed_diff_snapshot( + target, + kind, + base, + head, + content_digest, + persisted_base=persisted_base, + )[0] + + +def require_diff_target_snapshot( + target: Path, + kind: str | None, + base_revision: str | None, + head_revision: str | None, + content_digest: str | None, +) -> tuple[dict[str, str], str | None]: + if kind not in {"commit", "range"}: + return ( + require_diff_target( + target, + kind, + base_revision, + head_revision, + content_digest, + ), None, ) - if parent_line is None: - parent = EMPTY_GIT_TREE - else: - parent = resolve_git_commit( - target, - parent_line.removeprefix(b"parent ").decode("ascii").strip(), - "Commit parent", - ) - if base_revision and base_revision != parent: - supplied_base = ( - base_revision - if base_revision == EMPTY_GIT_TREE - else resolve_git_commit(target, base_revision, "Commit base") + require_review_changes_target(target) + base, head, persisted_base = resolve_committed_diff_coordinates( + target, + kind, + base_revision, + head_revision, + ) + return require_resolved_committed_diff_snapshot( + target, + kind, + base, + head, + content_digest, + persisted_base=persisted_base, + ) + + +def require_registered_diff_target_snapshot( + target: Path, + diff_target: dict[str, str], +) -> tuple[dict[str, str], str | None]: + kind = diff_target["kind"] + base = resolve_git_commit(target, diff_target["baseRevision"], "Base revision") + head = resolve_git_commit(target, diff_target["headRevision"], "Head revision") + if kind == "working_tree": + if head != require_review_changes_target(target): + raise SystemExit("Working-tree HEAD changed before the scan started.") + current_digest = worktree_content_digest(target) + if current_digest != diff_target["contentDigest"]: + raise SystemExit( + "Working-tree contents changed after they were selected. " + "Select Uncommitted changes again." ) - if supplied_base != parent: - raise SystemExit("Commit base revision must match the selected commit's parent.") - return {"kind": kind, "baseRevision": parent, "headRevision": head} - base = resolve_git_commit(target, base_revision or "", "Base revision") - head = resolve_git_commit(target, head_revision or "", "Head revision") - if base == head: - raise SystemExit("Base and head revisions must identify different commits.") - return {"kind": kind, "baseRevision": base, "headRevision": head} + else: + current_digest, object_identity = require_committed_diff_snapshot( + target, + base, + head, + diff_target["contentDigest"], + ) + return ( + { + "kind": kind, + "baseRevision": base, + "headRevision": head, + "contentDigest": current_digest, + }, + None if kind == "working_tree" else object_identity, + ) def inspect_setup_values( @@ -359,6 +711,8 @@ def inspect_setup_values( diff_base_revision: str | None, diff_head_revision: str | None, diff_content_digest: str | None, + *, + include_committed_identity: bool = False, ) -> dict[str, Any]: target = require_target(target_path) require_scannable_target(target) @@ -375,8 +729,8 @@ def inspect_setup_values( ) ): raise SystemExit("A Git diff target requires Review changes mode.") - diff_target = ( - require_diff_target( + diff_target, committed_identity = ( + require_diff_target_snapshot( target, diff_target_kind, diff_base_revision, @@ -384,13 +738,16 @@ def inspect_setup_values( diff_content_digest, ) if mode == "diff" - else None + else (None, None) ) - return { + inspected = { "diffTarget": diff_target, "scope": normalized_scope, "target": inspect_target(str(target)), } + if include_committed_identity: + inspected["committedDiffIdentity"] = committed_identity + return inspected def inspect_setup(args: argparse.Namespace) -> dict[str, Any]: @@ -510,7 +867,7 @@ def workbench_completion_binding(scan: sqlite3.Row, completed_at: str) -> dict[s if scan["mode"] == "diff": target["baseRevision"] = scan["diff_base_revision"] target["headRevision"] = scan["diff_head_revision"] - if scan["diff_target_kind"] == "working_tree" and scan["diff_content_digest"]: + if scan["diff_content_digest"]: target["snapshotDigest"] = scan["diff_content_digest"] else: if scan["target_revision"] != "unversioned": @@ -580,11 +937,11 @@ def verify_manifest_binding(scan: sqlite3.Row, manifest: dict[str, Any]) -> None ) if ( scan["diff_target_kind"] == "working_tree" - and target.get("snapshotDigest") != scan["diff_content_digest"] - ): + or scan["diff_content_digest"] is not None + ) and target.get("snapshotDigest") != scan["diff_content_digest"]: raise SystemExit( "scan-manifest.json target snapshotDigest must match the selected " - "working-tree contents." + "reviewed contents." ) scope = manifest_scan.get("scope") if not isinstance(scope, dict): @@ -678,6 +1035,9 @@ def create_workspace(connection: sqlite3.Connection, args: argparse.Namespace) - diff_content_digest = ( optional_text(args.diff_content_digest, maximum=128) if args.mode == "diff" else None ) + inspected = None + inspection_error = None + inspected_target_metadata = None if target_path: try: inspected = inspect_setup_values( @@ -696,8 +1056,18 @@ def create_workspace(connection: sqlite3.Connection, args: argparse.Namespace) - diff_base_revision = inspected["diffTarget"]["baseRevision"] diff_head_revision = inspected["diffTarget"]["headRevision"] diff_content_digest = inspected["diffTarget"].get("contentDigest") - except SystemExit: - pass + except SystemExit as exc: + inspection_error = str(exc) + if isinstance(exc, ResolvedDiffTargetError): + diff_target_kind = exc.kind + diff_base_revision = exc.base_revision + diff_head_revision = exc.head_revision + try: + inspected_target = require_target(target_path) + target_path = str(inspected_target) + inspected_target_metadata = git_target_metadata(inspected_target) + except SystemExit: + pass with connection: target_id = ( ensure_security_target(connection, target_path) if target_path is not None else None @@ -729,7 +1099,15 @@ def create_workspace(connection: sqlite3.Connection, args: argparse.Namespace) - timestamp, ), ) - return workspace_state(connection, workspace_id) + result = inspected_workspace_state( + connection, + workspace_id, + expected_updated_at=timestamp, + inspection_error=inspection_error, + inspected_setup=inspected, + inspected_target_metadata=inspected_target_metadata, + ) + return result def save_workspace(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: @@ -748,6 +1126,60 @@ def save_workspace(connection: sqlite3.Connection, args: argparse.Namespace) -> target = Path(inspected["target"]["targetPath"]) scope = inspected["scope"] target_path = str(target) + diff_target = inspected["diffTarget"] + stored_committed_selection = ( + diff_target is not None + and diff_target["kind"] != "working_tree" + and workspace["target_path"] == target_path + and workspace["diff_target_kind"] == diff_target["kind"] + ) + if stored_committed_selection and workspace["submitted"]: + object_id_length = len(empty_git_tree(target)) + if ( + not is_canonical_git_object_id( + workspace["diff_head_revision"], object_id_length + ) + or ( + workspace["diff_base_revision"] is not None + and not is_canonical_git_object_id( + workspace["diff_base_revision"], object_id_length + ) + ) + or ( + diff_target["kind"] == "range" + and workspace["diff_base_revision"] is None + ) + ): + raise SystemExit( + "The committed changes selected for review no longer produce the same " + "diff. Select the changes to review again." + ) + same_committed_identity = ( + stored_committed_selection + and workspace["diff_head_revision"] == diff_target["headRevision"] + and ( + diff_target["kind"] == "commit" + or workspace["diff_base_revision"] == diff_target["baseRevision"] + ) + ) + if same_committed_identity: + stored_digest = workspace["diff_content_digest"] + base_compatible = workspace["diff_base_revision"] == diff_target[ + "baseRevision" + ] or ( + diff_target["kind"] == "commit" + and workspace["diff_base_revision"] is None + and stored_digest is None + ) + digest_compatible = stored_digest == diff_target["contentDigest"] or ( + stored_digest is None + and args.diff_content_digest == diff_target["contentDigest"] + ) + if not base_compatible or not digest_compatible: + raise SystemExit( + "The committed changes selected for review no longer produce the same " + "diff. Select the changes to review again." + ) target_changed = workspace["target_path"] != target_path target_title = target.name if target_changed else workspace["target_title"] target_summary = ( @@ -757,7 +1189,6 @@ def save_workspace(connection: sqlite3.Connection, args: argparse.Namespace) -> if target_changed else workspace["target_summary"] ) - diff_target = inspected["diffTarget"] if diff_target and not target_summary: target_summary = diff_target_summary(diff_target) timestamp = now() @@ -792,7 +1223,13 @@ def save_workspace(connection: sqlite3.Connection, args: argparse.Namespace) -> raise SystemExit( "This workspace already has a scan. Open a new workspace to change setup." ) - return workspace_state(connection, workspace["id"]) + result = inspected_workspace_state( + connection, + workspace["id"], + expected_updated_at=timestamp, + inspected_setup=inspected, + ) + return result def scan_target_root(scan_root: str | None, target: Path) -> Path: @@ -810,14 +1247,18 @@ def start_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict workspace = require_workspace(connection, workspace_id) if not workspace["submitted"] or not workspace["target_path"]: raise SystemExit("Save the Codex Security setup before starting the scan.") - active = connection.execute( - """ - SELECT * - FROM scans - WHERE workspace_id = ? AND status = 'running' AND canceled_at IS NULL - """, - (workspace["id"],), - ).fetchone() + + def active_scan() -> sqlite3.Row | None: + return connection.execute( + """ + SELECT * + FROM scans + WHERE workspace_id = ? AND status = 'running' AND canceled_at IS NULL + """, + (workspace_id,), + ).fetchone() + + active = active_scan() if active is not None: return workspace_state(connection, workspace["id"]) workspace_version = workspace["updated_at"] @@ -828,8 +1269,9 @@ def start_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict target_metadata = target.stat() scope = require_scope(workspace["default_scope"], workspace["default_mode"], target) diff_target = None + committed_object_identity = None if workspace["default_mode"] == "diff": - diff_target = require_diff_target( + diff_target, committed_object_identity = require_diff_target_snapshot( target, workspace["diff_target_kind"], workspace["diff_base_revision"], @@ -849,31 +1291,89 @@ def start_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict diff_target, metadata=target_metadata, ) + match_committed_diff_identity: ( + Callable[[Path, dict[str, str], str], bool] | None + ) = None + validate_committed_diff: Callable[[], bool] | None = None + if diff_target is not None and diff_target["kind"] in {"commit", "range"}: + assert committed_object_identity is not None + match_committed_diff_identity, validate_committed_diff = ( + committed_diff_lock_validator( + target, + diff_target, + committed_object_identity, + workspace["diff_base_revision"], + workspace["diff_head_revision"], + ) + ) + + def target_matches_initial_snapshot() -> bool: + current_target = require_remediation_target(str(target)) + current_target_metadata = current_target.stat() + if (current_target_metadata.st_dev, current_target_metadata.st_ino) != ( + target_metadata.st_dev, + target_metadata.st_ino, + ): + return False + current_diff_target, current_object_identity = ( + require_diff_target_snapshot( + current_target, + workspace["diff_target_kind"], + workspace["diff_base_revision"], + workspace["diff_head_revision"], + workspace["diff_content_digest"], + ) + if workspace["default_mode"] == "diff" + else (None, None) + ) + matches = ( + scan_target_identity( + current_target, + current_diff_target, + metadata=current_target_metadata, + ) + == target_identity + and scan_diff_identity(current_diff_target) == scan_diff_identity(diff_target) + ) + if matches and match_committed_diff_identity is not None: + assert current_diff_target is not None + assert current_object_identity is not None + matches = match_committed_diff_identity( + current_target, + current_diff_target, + current_object_identity, + ) + return matches + target_root = scan_target_root(args.scan_root, target) target_root.mkdir(parents=True, exist_ok=True) if manages_transaction: - connection.execute("BEGIN IMMEDIATE") + if validate_committed_diff is None: + target_is_current = begin_immediate_with_target_inspection( + connection, + target_matches_initial_snapshot, + active_scan, + ) + else: + target_is_current = begin_immediate_after_external_inspection( + connection, + target_matches_initial_snapshot, + validate_committed_diff, + active_scan, + ) + else: + target_is_current = ( + True if active_scan() is not None else target_matches_initial_snapshot() + ) workspace = require_workspace(connection, workspace_id) - active = connection.execute( - """ - SELECT * - FROM scans - WHERE workspace_id = ? AND status = 'running' AND canceled_at IS NULL - """, - (workspace["id"],), - ).fetchone() + active = active_scan() if active is not None: if manages_transaction: connection.commit() return workspace_state(connection, workspace["id"]) if workspace["updated_at"] != workspace_version: raise SystemExit("Codex Security setup changed while the scan was starting. Try again.") - current_target = require_remediation_target(str(target)) - current_target_metadata = current_target.stat() - if (current_target_metadata.st_dev, current_target_metadata.st_ino) != ( - target_metadata.st_dev, - target_metadata.st_ino, - ): + if not target_is_current: raise SystemExit( "The selected scan target changed while the scan was starting. Try again." ) @@ -939,11 +1439,13 @@ def _start_prompt_driven_scan( args.diff_base_revision, args.diff_head_revision, args.diff_content_digest, + include_committed_identity=True, ) target = Path(inspected["target"]["targetPath"]) target_path = str(target) scope = inspected["scope"] diff_target = inspected["diffTarget"] + committed_object_identity = inspected["committedDiffIdentity"] user_context = user_context_argument(args) target_summary = optional_text(args.target_summary, maximum=2400) if diff_target is not None and not target_summary: @@ -953,13 +1455,26 @@ def _start_prompt_driven_scan( ) diff_identity = scan_diff_identity(diff_target) target_identity = scan_target_identity(target, diff_target) - target_root = scan_target_root(args.scan_root, target) + match_committed_diff_identity: ( + Callable[[Path, dict[str, str], str], bool] | None + ) = None + validate_committed_diff: Callable[[], bool] | None = None + if diff_target is not None and diff_target["kind"] in {"commit", "range"}: + assert committed_object_identity is not None + match_committed_diff_identity, validate_committed_diff = ( + committed_diff_lock_validator( + target, + diff_target, + committed_object_identity, + args.diff_base_revision, + args.diff_head_revision, + ) + ) - connection.execute("BEGIN IMMEDIATE") - try: + def target_matches_initial_snapshot() -> bool: current_target = require_remediation_target(target_path) - current_diff_target = ( - require_diff_target( + current_diff_target, current_object_identity = ( + require_diff_target_snapshot( current_target, args.diff_target_kind, args.diff_base_revision, @@ -967,12 +1482,40 @@ def _start_prompt_driven_scan( args.diff_content_digest, ) if args.mode == "diff" - else None + else (None, None) ) - if ( - scan_target_identity(current_target, current_diff_target) != target_identity - or scan_diff_identity(current_diff_target) != diff_identity - ): + matches = ( + scan_target_identity(current_target, current_diff_target) == target_identity + and scan_diff_identity(current_diff_target) == diff_identity + ) + if matches and match_committed_diff_identity is not None: + assert current_diff_target is not None + assert current_object_identity is not None + matches = match_committed_diff_identity( + current_target, + current_diff_target, + current_object_identity, + ) + return matches + + target_root = scan_target_root(args.scan_root, target) + allow_legacy_digest = int( + diff_target is not None + and diff_target["kind"] in {"commit", "range"} + and args.diff_content_digest is None + ) + if validate_committed_diff is None: + target_is_current = begin_immediate_with_target_inspection( + connection, target_matches_initial_snapshot + ) + else: + target_is_current = begin_immediate_after_external_inspection( + connection, + target_matches_initial_snapshot, + validate_committed_diff, + ) + try: + if not target_is_current: raise SystemExit( "The selected scan target changed while the scan was starting. Try again." ) @@ -984,7 +1527,17 @@ def _start_prompt_driven_scan( AND workspaces.default_scope = ? AND workspaces.default_mode = ? AND workspaces.user_context IS ? AND workspaces.target_summary IS ? AND workspaces.diff_target_kind IS ? AND workspaces.diff_base_revision IS ? - AND workspaces.diff_head_revision IS ? AND workspaces.diff_content_digest IS ? + AND workspaces.diff_head_revision IS ? + AND ( + ( + workspaces.diff_content_digest IS ? + AND scans.diff_content_digest IS ? + ) + OR ( + ? = 1 AND workspaces.diff_content_digest IS NULL + AND scans.diff_content_digest IS NULL + ) + ) AND workspaces.submitted = 1 AND scans.target_revision = ? AND scans.target_snapshot_digest IS ? AND scans.target_device = ? AND scans.target_inode = ? AND scans.status = 'running' @@ -996,7 +1549,10 @@ def _start_prompt_driven_scan( AND scans.continuation_thread_id = ? ) ) - ORDER BY scans.updated_at DESC, scans.started_at DESC, scans.id LIMIT 1 + ORDER BY + CASE WHEN workspaces.diff_content_digest IS ? + AND scans.diff_content_digest IS ? THEN 0 ELSE 1 END, + scans.updated_at DESC, scans.started_at DESC, scans.id LIMIT 1 """, ( thread_id, @@ -1005,11 +1561,16 @@ def _start_prompt_driven_scan( args.mode, user_context, target_summary, - *diff_identity, + *diff_identity[:3], + diff_identity[3], + diff_identity[3], + allow_legacy_digest, *target_identity, int(headless_standard), int(headless_standard), thread_id, + diff_identity[3], + diff_identity[3], ), ).fetchone() if existing is not None: @@ -1467,7 +2028,6 @@ def add_warning() -> None: if warning not in items: items.append(warning) - add_warning() scan_dir = require_canonical_scan_directory(Path(scan["scan_dir"])) completion_timestamp = now() completion_binding = workbench_completion_binding(scan, completion_timestamp) @@ -1662,6 +2222,7 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) paths = requested_target["paths"] scope = paths[0] if len(paths) == 1 else "." diff_target = None + committed_object_identity = None if requested_target["kind"] in {"refs", "working_tree"}: current_head = require_review_changes_target(repository) base = resolve_git_commit(repository, requested_target["base"], "Base revision") @@ -1675,6 +2236,11 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) if head != current_head: raise SystemExit("Working-tree HEAD changed before the scan started.") diff_target["contentDigest"] = worktree_content_digest(repository) + else: + ( + diff_target["contentDigest"], + committed_object_identity, + ) = committed_diff_content_snapshot(repository, base, head) mode = "diff" if diff_target is not None else recipe["mode"] target_identity = scan_target_identity(repository, diff_target) scope_file_count = ( @@ -1696,8 +2262,59 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace) scan_id = str(uuid.uuid4()) workspace_id = str(uuid.uuid4()) - connection.execute("BEGIN IMMEDIATE") + match_committed_diff_identity: ( + Callable[[Path, dict[str, str], str], bool] | None + ) = None + validate_committed_diff: Callable[[], bool] | None = None + if diff_target is not None and diff_target["kind"] == "range": + assert committed_object_identity is not None + match_committed_diff_identity, validate_committed_diff = ( + committed_diff_lock_validator( + repository, + diff_target, + committed_object_identity, + requested_target["base"], + requested_target["head"], + allow_equal_range=True, + ) + ) + + def target_matches_initial_snapshot() -> bool: + current_repository = require_remediation_target(str(repository)) + current_diff_target, current_object_identity = ( + require_registered_diff_target_snapshot(current_repository, diff_target) + if diff_target is not None + else (None, None) + ) + matches = ( + scan_target_identity(current_repository, current_diff_target) == target_identity + and scan_diff_identity(current_diff_target) == scan_diff_identity(diff_target) + ) + if matches and match_committed_diff_identity is not None: + assert current_diff_target is not None + assert current_object_identity is not None + matches = match_committed_diff_identity( + current_repository, + current_diff_target, + current_object_identity, + ) + return matches + + if validate_committed_diff is None: + target_is_current = begin_immediate_with_target_inspection( + connection, target_matches_initial_snapshot + ) + else: + target_is_current = begin_immediate_after_external_inspection( + connection, + target_matches_initial_snapshot, + validate_committed_diff, + ) try: + if not target_is_current: + raise SystemExit( + "The selected scan target changed while the scan was starting. Try again." + ) archive_scan(connection, args, scan_dir, timestamp, require_canonical_scan_directory) target_id = ensure_security_target(connection, str(repository)) if parent_scan_id is not None: @@ -3081,6 +3698,63 @@ def workspace_state( refresh_stopped: bool = False, ) -> dict[str, Any]: workspace = require_workspace(connection, workspace_id) + return render_workspace_state( + connection, + workspace, + result_scan_id=result_scan_id, + thread_id=thread_id, + refresh_stopped=refresh_stopped, + ) + + +def inspected_workspace_state( + connection: sqlite3.Connection, + workspace_id: str, + *, + expected_updated_at: str, + inspection_error: str | None = None, + inspected_setup: dict[str, Any] | None, + inspected_target_metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + manages_transaction = not connection.in_transaction + if manages_transaction: + connection.execute("BEGIN IMMEDIATE") + try: + workspace = require_workspace(connection, workspace_id) + if ( + workspace["updated_at"] != expected_updated_at + or workspace["active_scan_id"] is not None + ): + raise SystemExit("Codex Security setup changed while it was being saved. Try again.") + result = render_workspace_state( + connection, + workspace, + inspection_complete=True, + inspection_error=inspection_error, + inspected_setup=inspected_setup, + inspected_target_metadata=inspected_target_metadata, + ) + if manages_transaction: + connection.commit() + return result + except BaseException: + if manages_transaction: + connection.rollback() + raise + + +def render_workspace_state( + connection: sqlite3.Connection, + workspace: sqlite3.Row, + *, + result_scan_id: str | None = None, + thread_id: str | None = None, + refresh_stopped: bool = False, + inspection_complete: bool = False, + inspection_error: str | None = None, + inspected_setup: dict[str, Any] | None = None, + inspected_target_metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: if thread_id is not None and workspace["thread_id"] != optional_text(thread_id, maximum=512): raise SystemExit("Codex Security workspace not found in this thread.") persisted_diff_target = stored_diff_target(workspace) @@ -3109,7 +3783,13 @@ def workspace_state( target_metadata = None setup_error = None validated_diff_target = None - if workspace["target_path"]: + if inspected_setup is not None: + target_metadata = inspected_setup["target"]["targetMetadata"] + validated_diff_target = inspected_setup["diffTarget"] + elif inspection_complete: + setup_error = inspection_error + target_metadata = inspected_target_metadata + elif workspace["target_path"]: try: inspected = inspect_setup_values( workspace["target_path"], diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py index 3e26d5acd..7de9db55e 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_target.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_target.py @@ -4,19 +4,24 @@ import argparse import hashlib +import io import os import shutil import sqlite3 import stat import subprocess import sys +import tempfile from pathlib import Path -from typing import Any +from typing import IO, Any # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) from filesystem_identity import stored_filesystem_identity_matches -from workbench_constants import GIT_REPOSITORY_ENVIRONMENT +from workbench_constants import ( + EMPTY_GIT_TREES, + GIT_REPOSITORY_ENVIRONMENT, +) def git_output( @@ -24,8 +29,18 @@ def git_output( *args: str, git_dir: Path | None = None, work_tree: Path | None = None, + local_objects_only: bool = False, + object_directory: Path | None = None, ) -> str | None: - completed = git_command(target, *args, text=False, git_dir=git_dir, work_tree=work_tree) + completed = git_command( + target, + *args, + text=False, + git_dir=git_dir, + work_tree=work_tree, + local_objects_only=local_objects_only, + object_directory=object_directory, + ) output = os.fsdecode(completed.stdout).strip() return output if completed.returncode == 0 and output else None @@ -35,11 +50,117 @@ def git_bytes( *args: str, git_dir: Path | None = None, work_tree: Path | None = None, + local_objects_only: bool = False, ) -> bytes | None: - completed = git_command(target, *args, text=False, git_dir=git_dir, work_tree=work_tree) + completed = git_command( + target, + *args, + text=False, + git_dir=git_dir, + work_tree=work_tree, + local_objects_only=local_objects_only, + ) return completed.stdout if completed.returncode == 0 else None +def read_stream_nul_field(stream: IO[bytes]) -> bytes | None: + """Read one NUL-terminated field, returning ``None`` only at clean EOF.""" + field = bytearray() + while character := stream.read(1): + if character == b"\0": + return bytes(field) + field.extend(character) + if field: + raise ValueError("missing NUL terminator") + return None + + +def write_committed_diff_object_requests( + metadata: IO[bytes], + requests: IO[bytes], +) -> None: + """Write ordered changed-blob requests from NUL-framed raw diff records.""" + seen: set[bytes] = set() + while (header := read_stream_nul_field(metadata)) is not None: + if not header or not read_stream_nul_field(metadata): + raise ValueError("invalid raw Git diff record") + fields = header.split() + if len(fields) != 5 or not fields[0].startswith(b":"): + raise ValueError("invalid raw Git diff record") + for mode, object_name in ( + (fields[0][1:], fields[2]), + (fields[1], fields[3]), + ): + if mode in {b"000000", b"160000"}: + continue + if ( + len(object_name) not in {40, 64} + or any( + character not in b"0123456789abcdef" + for character in object_name + ) + ): + raise ValueError("invalid raw Git diff record") + if object_name in seen: + continue + seen.add(object_name) + requests.write(object_name + b"\0") + + +def validate_git_batch_blob_stream( + requests: IO[bytes], + output: IO[bytes], +) -> None: + """Require one exact, ordered blob response for every object request.""" + while (expected_object := read_stream_nul_field(requests)) is not None: + if not expected_object: + raise ValueError("invalid empty object request") + if _read_git_batch_blob(output, expected_object, require_object_match=True) is None: + raise ValueError("missing batch object") + if output.read(1): + raise ValueError("unexpected batch response") + + +def _read_git_batch_blob( + output: IO[bytes], + expected_request: bytes, + *, + require_object_match: bool, + collect: bool = False, +) -> bytes | None: + """Read one strict newline-framed response for a NUL-terminated request.""" + start = output.tell() + missing = expected_request + b" missing\n" + if output.read(len(missing)) == missing: + return None + output.seek(start) + header = output.readline() + if not header.endswith(b"\n"): + raise ValueError("unterminated batch header") + fields = header[:-1].split(b" ") + if ( + len(fields) != 3 + or len(fields[0]) not in {40, 64} + or any(character not in b"0123456789abcdef" for character in fields[0]) + or (require_object_match and fields[0] != expected_request) + or fields[1] != b"blob" + or not fields[2].isdigit() + ): + raise ValueError("invalid batch response") + remaining = int(fields[2]) + chunks: list[bytes] = [] + while remaining: + chunk = output.read(min(1024 * 1024, remaining)) + if not chunk: + raise ValueError("truncated blob response") + if collect: + chunks.append(chunk) + remaining -= len(chunk) + if output.read(1) != b"\n": + raise ValueError("missing blob terminator") + return b"".join(chunks) + + def git_blob_bytes( target: Path, object_names: list[str], @@ -47,77 +168,45 @@ def git_blob_bytes( git_dir: Path | None = None, work_tree: Path | None = None, ) -> list[bytes | None]: - """Read ordered raw blobs with one NUL-framed ``git cat-file --batch`` call.""" + """Read ordered raw blobs with one NUL-request ``git cat-file --batch`` call.""" if not object_names: return [] - request = b"\0".join(os.fsencode(name) for name in object_names) + b"\0" + encoded_names = [os.fsencode(name) for name in object_names] + request = b"\0".join(encoded_names) + b"\0" completed = git_command( target, "cat-file", "--batch", - "-Z", + "-z", text=False, input_data=request, git_dir=git_dir, work_tree=work_tree, + local_objects_only=True, ) if completed.returncode != 0: return [None] * len(object_names) try: - return _decode_git_batch_blobs(completed.stdout, len(object_names)) + return _decode_git_batch_blobs(completed.stdout, encoded_names) except ValueError: return [None] * len(object_names) -def _decode_git_batch_blobs(output: bytes, count: int) -> list[bytes | None]: - """Decode ordered ``cat-file --batch -Z`` records without scanning blob bytes.""" - blobs: list[bytes | None] = [] - offset = 0 - for _ in range(count): - header, offset = _read_nul_field(output, offset) - size = _git_batch_blob_size(header) - if size is None: - blobs.append(None) - continue - blob, offset = _read_sized_nul_field(output, offset, size) - blobs.append(blob) - return blobs - - -def _read_nul_field(output: bytes, offset: int) -> tuple[bytes, int]: - """Read one NUL-terminated protocol field and return the next offset.""" - end = output.find(b"\0", offset) - if end < 0: - raise ValueError("missing NUL terminator") - return output[offset:end], end + 1 - - -def _git_batch_blob_size(header: bytes) -> int | None: - """Return a blob header's byte count, or ``None`` for a non-blob record.""" - fields = header.rsplit(b" ", 2) - if len(fields) != 3 or fields[1] != b"blob": - return None - try: - size = int(fields[2]) - except ValueError as error: - raise ValueError("invalid blob size") from error - if size < 0: - raise ValueError("invalid blob size") - return size - - -def _read_sized_nul_field( +def _decode_git_batch_blobs( output: bytes, - offset: int, - size: int, -) -> tuple[bytes, int]: - """Read exactly ``size`` blob bytes followed by one NUL record terminator.""" - end = offset + size - if output[end : end + 1] != b"\0": - raise ValueError("missing blob terminator") - return output[offset:end], end + 1 + expected_requests: list[bytes], +) -> list[bytes | None]: + """Decode exactly one strict newline-framed response per ordered request.""" + stream = io.BytesIO(output) + blobs = [ + _read_git_batch_blob(stream, request, require_object_match=False, collect=True) + for request in expected_requests + ] + if stream.read(1): + raise ValueError("unexpected batch response") + return blobs def git_command( @@ -127,12 +216,21 @@ def git_command( input_data: str | bytes | None = None, git_dir: Path | None = None, work_tree: Path | None = None, + stdin: IO[bytes] | None = None, + stdout: IO[bytes] | None = None, + local_objects_only: bool = False, + object_directory: Path | None = None, ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[bytes]: - if (git_dir is None) != (work_tree is None): - raise ValueError("git_dir and work_tree must be provided together") + if work_tree is not None and git_dir is None: + raise ValueError("work_tree requires git_dir") environment = os.environ.copy() for name in GIT_REPOSITORY_ENVIRONMENT: environment.pop(name, None) + if object_directory is not None: + environment["GIT_OBJECT_DIRECTORY"] = os.fspath(object_directory) + if local_objects_only: + environment["GIT_ALLOW_PROTOCOL"] = "" + environment["GIT_NO_LAZY_FETCH"] = "1" environment["GIT_LITERAL_PATHSPECS"] = "1" # Repository-local config is untrusted; fsmonitor may name an executable hook. command = [ @@ -144,14 +242,18 @@ def git_command( "-C", str(target), ] - if git_dir is not None and work_tree is not None: - command.extend(["--git-dir", str(git_dir), "--work-tree", str(work_tree)]) + if git_dir is not None: + command.extend(["--git-dir", str(git_dir)]) + if work_tree is not None: + command.extend(["--work-tree", str(work_tree)]) full_command = [*command, *args] try: return subprocess.run( full_command, check=False, - capture_output=True, + stdin=stdin, + stdout=subprocess.PIPE if stdout is None else stdout, + stderr=subprocess.PIPE, env=environment, text=text, encoding="utf-8" if text else None, @@ -172,12 +274,408 @@ def update_digest_field(digest: Any, label: bytes, value: bytes) -> None: digest.update(value) +def update_digest_stream_field(digest: Any, label: bytes, stream: IO[bytes]) -> None: + """Hash a length-framed stream without loading it into memory.""" + digest.update(len(label).to_bytes(4, "big")) + digest.update(label) + digest.update(os.fstat(stream.fileno()).st_size.to_bytes(8, "big")) + stream.seek(0) + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + + def worktree_content_digest(target: Path) -> str: require_clean_submodule_worktrees(target) repository, pathspec = git_worktree_context(target) return worktree_content_digest_for_context(repository, pathspec) +def empty_git_tree(target: Path) -> str: + object_format = git_output(target, "rev-parse", "--show-object-format") + return EMPTY_GIT_TREES.get(object_format or "", EMPTY_GIT_TREES["sha1"]) + + +def _replacement_refs( + repository: Path, + *, + object_id_length: int, + git_dir: Path | None = None, + object_directory: Path | None = None, +) -> bytes: + replacement_ref_base = os.fsencode( + os.environ.get("GIT_REPLACE_REF_BASE", "refs/replace/") + ) + replacements = git_command( + repository, + "for-each-ref", + "--sort=refname", + "--format=%(refname)%00%(objectname)", + text=False, + local_objects_only=True, + git_dir=git_dir, + object_directory=object_directory, + ) + if replacements.returncode != 0: + raise SystemExit("Could not inspect the selected committed changes.") + selected = bytearray() + for record in replacements.stdout.splitlines(): + fields = record.split(b"\0") + if len(fields) != 2: + raise SystemExit("Could not inspect the selected committed changes.") + if not fields[0].startswith(replacement_ref_base): + continue + original_object_id = fields[0][len(replacement_ref_base) :].rsplit(b"/", 1)[-1] + if ( + len(original_object_id) != object_id_length + or any( + character not in b"0123456789abcdefABCDEF" + for character in original_object_id + ) + ): + continue + selected.extend(record) + selected.extend(b"\n") + return bytes(selected) + + +def _replacement_refs_enabled( + repository: Path, + *, + git_dir: Path | None = None, + object_directory: Path | None = None, +) -> bool: + if "GIT_NO_REPLACE_OBJECTS" in os.environ: + return False + configured = git_command( + repository, + "config", + "--bool", + "--get", + "core.useReplaceRefs", + text=False, + local_objects_only=True, + git_dir=git_dir, + object_directory=object_directory, + ) + value = configured.stdout.strip() + if configured.returncode == 1 and not value: + return True + if configured.returncode == 0 and value in {b"true", b"false"}: + return value == b"true" + raise SystemExit("Could not inspect the selected committed changes.") + + +def _create_committed_diff_view(repository: Path, view: Path) -> Path: + object_format = git_output( + repository, + "rev-parse", + "--show-object-format", + local_objects_only=True, + ) + object_directory_value = git_output( + repository, + "rev-parse", + "--path-format=absolute", + "--git-path", + "objects", + local_objects_only=True, + ) + if object_format not in {"sha1", "sha256"} or object_directory_value is None: + raise SystemExit("Could not snapshot the selected committed changes.") + object_directory = Path(object_directory_value) + if not object_directory.is_dir(): + raise SystemExit("Could not snapshot the selected committed changes.") + + object_id_length = 40 if object_format == "sha1" else 64 + replacements = _replacement_refs( + repository, + object_id_length=object_id_length, + ) + replacements_enabled = _replacement_refs_enabled(repository) + parsed_replacements: list[tuple[bytes, bytes]] = [] + for record in replacements.splitlines(): + fields = record.split(b"\0") + if ( + len(fields) != 2 + or len(fields[1]) != object_id_length + or any(character not in b"0123456789abcdef" for character in fields[1]) + ): + raise SystemExit("Could not snapshot the selected committed changes.") + refname = os.fsdecode(fields[0]) + parts = refname.split("/") + if ( + len(parts) < 2 + or parts[0] != "refs" + or "\\" in refname + or any(part in {"", ".", ".."} for part in parts) + ): + raise SystemExit("Could not snapshot the selected committed changes.") + parsed_replacements.append((fields[0], fields[1])) + + try: + view.mkdir(mode=0o700) + (view / "objects" / "info").mkdir(parents=True) + (view / "objects" / "pack").mkdir() + (view / "refs" / "heads").mkdir(parents=True) + repository_format_version = 0 if object_format == "sha1" else 1 + config = ( + ( + b"[extensions]\n\tobjectformat = sha256\n" + if object_format == "sha256" + else b"" + ) + + b"[core]\n\trepositoryformatversion = " + + str(repository_format_version).encode("ascii") + + b"\n\tfilemode = false\n\tbare = true\n\tuseReplaceRefs = " + + (b"true" if replacements_enabled else b"false") + + b"\n" + ) + (view / "config").write_bytes(config) + (view / "HEAD").write_bytes(b"ref: refs/heads/codex-security-snapshot\n") + packed_refs = bytearray(b"# pack-refs with: sorted\n") + for refname, object_id in parsed_replacements: + packed_refs.extend(object_id) + packed_refs.extend(b" ") + packed_refs.extend(refname) + packed_refs.extend(b"\n") + (view / "packed-refs").write_bytes(packed_refs) + except OSError as exc: + raise SystemExit("Could not snapshot the selected committed changes.") from exc + return object_directory + + +def committed_diff_content_snapshot( + target: Path, + base: str, + head: str, +) -> tuple[str, str]: + """Return a digest and identity from one frozen replacement view.""" + repository, pathspec = git_worktree_context(target) + configured_state = os.environ.get("CODEX_SECURITY_STATE_DIR") + state_directory = None + if configured_state: + state_directory = Path(configured_state).expanduser().resolve() + state_directory.mkdir(mode=0o700, parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=( + "codex-security-committed-view-" + if configured_state + else "codex-security-committed-diff-" + ), + dir=state_directory, + ) as directory: + return _committed_diff_content_snapshot( + repository, + pathspec, + base, + head, + Path(directory), + ) + + +def committed_diff_content_digest(target: Path, base: str, head: str) -> str: + """Bind normalized committed-diff metadata to every changed blob byte.""" + return committed_diff_content_snapshot(target, base, head)[0] + + +def _committed_diff_object_identity( + repository: Path, + pathspec: str, + base: str, + head: str, + *, + git_dir: Path | None = None, + object_directory: Path | None = None, +) -> str: + digest = hashlib.sha256() + update_digest_field(digest, b"format", b"codex-security-committed-objects/v1") + update_digest_field( + digest, + b"pathspec", + pathspec.encode("utf-8", errors="surrogateescape"), + ) + for label, revision in ((b"base", base), (b"head", head)): + if revision in EMPTY_GIT_TREES.values(): + tree = revision + else: + tree = git_output( + repository, + "rev-parse", + "--verify", + "--end-of-options", + f"{revision}^{{tree}}", + local_objects_only=True, + git_dir=git_dir, + object_directory=object_directory, + ) + if tree is None: + raise SystemExit("Could not inspect the selected committed changes.") + update_digest_field(digest, label, os.fsencode(revision)) + update_digest_field(digest, label + b"-tree", os.fsencode(tree)) + + replacement_ref_base = os.environ.get("GIT_REPLACE_REF_BASE", "refs/replace/") + object_format = git_output( + repository, + "rev-parse", + "--show-object-format", + local_objects_only=True, + git_dir=git_dir, + object_directory=object_directory, + ) + if object_format not in {"sha1", "sha256"}: + raise SystemExit("Could not inspect the selected committed changes.") + replacements_enabled = _replacement_refs_enabled( + repository, + git_dir=git_dir, + object_directory=object_directory, + ) + replacements = _replacement_refs( + repository, + object_id_length=40 if object_format == "sha1" else 64, + git_dir=git_dir, + object_directory=object_directory, + ) + update_digest_field( + digest, + b"replacement-ref-base", + os.fsencode(replacement_ref_base), + ) + update_digest_field( + digest, + b"replacement-objects-disabled", + os.fsencode(os.environ.get("GIT_NO_REPLACE_OBJECTS", "")), + ) + update_digest_field( + digest, + b"replacement-refs-enabled", + b"true" if replacements_enabled else b"false", + ) + update_digest_field(digest, b"replacement-refs", replacements) + return f"codex-security-committed-objects/v1:sha256:{digest.hexdigest()}" + + +def committed_diff_object_identity(target: Path, base: str, head: str) -> str: + """Bind immutable commit trees to the replacement view used by Git.""" + repository, pathspec = git_worktree_context(target) + return _committed_diff_object_identity(repository, pathspec, base, head) + + +def _committed_diff_content_snapshot( + repository: Path, + pathspec: str, + base: str, + head: str, + operation_directory: Path, +) -> tuple[str, str]: + view = operation_directory / "repository.git" + object_directory = _create_committed_diff_view(repository, view) + before = _committed_diff_object_identity( + view, + pathspec, + base, + head, + git_dir=view, + object_directory=object_directory, + ) + content_digest = _committed_diff_content_digest( + view, + pathspec, + base, + head, + operation_directory, + git_dir=view, + object_directory=object_directory, + ) + after = _committed_diff_object_identity( + view, + pathspec, + base, + head, + git_dir=view, + object_directory=object_directory, + ) + if before != after: + raise SystemExit("Could not snapshot the selected committed changes.") + return content_digest, after + + +def _committed_diff_content_digest( + repository: Path, + pathspec: str, + base: str, + head: str, + state_directory: Path, + *, + git_dir: Path | None = None, + object_directory: Path | None = None, +) -> str: + digest = hashlib.sha256() + update_digest_field(digest, b"format", b"codex-security-snapshot/v1") + with ( + tempfile.TemporaryFile(dir=state_directory) as metadata, + tempfile.TemporaryFile(dir=state_directory) as requests, + tempfile.TemporaryFile(dir=state_directory) as objects, + ): + diff = git_command( + repository, + "-c", + f"diff.orderFile={os.devnull}", + "diff", + "--raw", + "-z", + "--no-abbrev", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--no-relative", + "--no-renames", + "--ignore-submodules=none", + base, + head, + "--", + pathspec, + text=False, + stdout=metadata, + local_objects_only=True, + git_dir=git_dir, + object_directory=object_directory, + ) + if diff.returncode != 0: + raise SystemExit("Could not snapshot the selected committed changes.") + metadata.seek(0) + try: + write_committed_diff_object_requests(metadata, requests) + except ValueError as exc: + raise SystemExit("Could not snapshot the selected committed changes.") from exc + update_digest_stream_field(digest, b"tracked-diff", metadata) + + requests.seek(0, os.SEEK_END) + if requests.tell(): + requests.seek(0) + batch = git_command( + repository, + "cat-file", + "--batch", + "-z", + text=False, + stdin=requests, + stdout=objects, + local_objects_only=True, + git_dir=git_dir, + object_directory=object_directory, + ) + if batch.returncode != 0: + raise SystemExit("Could not snapshot the selected committed changes.") + requests.seek(0) + objects.seek(0) + try: + validate_git_batch_blob_stream(requests, objects) + except ValueError as exc: + raise SystemExit("Could not snapshot the selected committed changes.") from exc + update_digest_stream_field(digest, b"tracked-objects", objects) + return f"codex-security-snapshot/v1:sha256:{digest.hexdigest()}" + + def remediation_checkout_snapshot( scan: sqlite3.Row, *, expected_revision: str | None = None ) -> tuple[str, str | None]: @@ -636,10 +1134,31 @@ def require_git_worktree_head(target: Path) -> str: def scan_target_warning(scan: sqlite3.Row) -> str | None: - if scan["diff_target_kind"] != "working_tree" and not scan["target_snapshot_digest"]: + committed_diff = scan["diff_target_kind"] in {"commit", "range"} + if ( + not committed_diff + and scan["diff_target_kind"] != "working_tree" + and not scan["target_snapshot_digest"] + ): + return None + if committed_diff and not scan["diff_content_digest"]: return None try: target = require_scan_target_identity(scan) + if committed_diff: + if ( + committed_diff_content_digest( + target, + scan["diff_base_revision"], + scan["diff_head_revision"], + ) + != scan["diff_content_digest"] + ): + return ( + "Committed changes changed while the scan was running; " + "results were saved for the original snapshot." + ) + return None if scan["target_revision"] == "unversioned": if ( directory_content_digest(target, excluded=(Path(scan["scan_dir"]),)) diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 95861c52e..9597e7f42 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -9,7 +9,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.60" as const; +export const BUNDLED_PLUGIN_VERSION = "0.1.75" as const; const PACKAGE_NAME = "@openai/codex-security"; diff --git a/sdk/typescript/tests-ts/compact-diff-scan.test.ts b/sdk/typescript/tests-ts/compact-diff-scan.test.ts index f117cb0c3..094ffe93c 100644 --- a/sdk/typescript/tests-ts/compact-diff-scan.test.ts +++ b/sdk/typescript/tests-ts/compact-diff-scan.test.ts @@ -1,10 +1,12 @@ import { execFileSync, spawn, spawnSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { + existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, + renameSync, rmSync, writeFileSync, } from "node:fs"; @@ -36,6 +38,12 @@ function createRepository(): { root: string; repository: string } { return { root, repository }; } +function createMcpDirectories(root: string): void { + mkdirSync(join(root, "scans"), { mode: 0o700 }); + mkdirSync(join(root, "scans", "repository"), { mode: 0o700 }); + mkdirSync(join(root, "state"), { mode: 0o700 }); +} + function git(repository: string, ...args: string[]): string { return execFileSync( "git", @@ -80,6 +88,77 @@ function candidate(path: string): JsonObject { }; } +function workbenchState(root: string): { + scans: number; + submitted: number; + workspaces: number; +} { + const command = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(command).not.toBeNull(); + const result = spawnSync( + command!, + [ + "-I", + "-B", + "-c", + [ + "import json, sqlite3, sys", + "with sqlite3.connect(sys.argv[1]) as connection:", + " print(json.dumps({", + " 'workspaces': connection.execute('SELECT COUNT(*) FROM workspaces').fetchone()[0],", + " 'submitted': connection.execute('SELECT COUNT(*) FROM workspaces WHERE submitted = 1').fetchone()[0],", + " 'scans': connection.execute('SELECT COUNT(*) FROM scans').fetchone()[0],", + " }))", + ].join("\n"), + join(root, "state", "workbench.sqlite3"), + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as { + scans: number; + submitted: number; + workspaces: number; + }; +} + +function workspaceDiffSelection( + root: string, + workspaceId: string, +): { + baseRevision: string | null; + contentDigest: string | null; + headRevision: string | null; +} { + const command = + Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + expect(command).not.toBeNull(); + const result = spawnSync( + command!, + [ + "-I", + "-B", + "-c", + [ + "import json, sqlite3, sys", + "with sqlite3.connect(sys.argv[1]) as connection:", + " row = connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest FROM workspaces WHERE id = ?', (sys.argv[2],)).fetchone()", + " print(json.dumps({'baseRevision': row[0], 'headRevision': row[1], 'contentDigest': row[2]}))", + ].join("\n"), + join(root, "state", "workbench.sqlite3"), + workspaceId, + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as { + baseRevision: string | null; + contentDigest: string | null; + headRevision: string | null; + }; +} + async function startMcp(root: string) { const child = spawn( process.execPath, @@ -420,8 +499,7 @@ describe("compact diff scan", () => { test("streams maximum-size preflight checks through the MCP workbench", async () => { const { root, repository } = createRepository(); writeSource(repository, "src/handler.py", "value = 1\n"); - mkdirSync(join(root, "scans")); - mkdirSync(join(root, "state")); + createMcpDirectories(root); const client = await startMcp(root); const owner = "preflight-stdin-owner"; @@ -455,8 +533,7 @@ describe("compact diff scan", () => { test("streams oversized option-like user context through the MCP workbench", async () => { const { root, repository } = createRepository(); writeSource(repository, "src/handler.py", "value = 1\n"); - mkdirSync(join(root, "scans")); - mkdirSync(join(root, "state")); + createMcpDirectories(root); const client = await startMcp(root); const userContext = `--${"é".repeat(64 * 1_024)}`; @@ -503,174 +580,223 @@ describe("compact diff scan", () => { } }, 30_000); - test.each(["object", "Markdown"])("MCP diff retains %s", async (format) => { - const { root, repository } = createRepository(); - writeSource(repository, "src/guard.py", "allowed = True\n"); - writeSource(repository, "src/handler.py", "value = 1\n"); - git(repository, "add", "."); - git(repository, "commit", "-qm", "base"); - const baseRevision = git(repository, "rev-parse", "HEAD"); - rmSync(join(repository, "src", "guard.py")); - writeSource(repository, "src/handler.py", "value = 2\n"); - git(repository, "add", "."); - git(repository, "commit", "-qm", "changed"); - const headRevision = git(repository, "rev-parse", "HEAD"); - mkdirSync(join(root, "scans")); - mkdirSync(join(root, "state")); - const client = await startMcp(root); - const owner = "compact-diff-owner"; - const call = (name: string, args: JsonObject) => - client.call(name, args, owner); + test.each(["object", "Markdown"])( + "MCP diff retains %s", + async (format) => { + const { root, repository } = createRepository(); + writeSource(repository, "src/guard.py", "allowed = True\n"); + writeSource(repository, "src/handler.py", "value = 1\n"); + git(repository, "add", "."); + git(repository, "commit", "-qm", "base"); + const baseRevision = git(repository, "rev-parse", "HEAD"); + rmSync(join(repository, "src", "guard.py")); + writeSource(repository, "src/handler.py", "value = 2\n"); + git(repository, "add", "."); + git(repository, "commit", "-qm", "changed"); + const headRevision = git(repository, "rev-parse", "HEAD"); + createMcpDirectories(root); + const client = await startMcp(root); + const owner = "compact-diff-owner"; + const call = (name: string, args: JsonObject) => + client.call(name, args, owner); - try { - const tools = (await client.request("tools/list", {}))["tools"] as { - name: string; - inputSchema: { properties: Record }; - }[]; - expect(tools.map((tool) => tool.name)).toContain( - "prepare_codex_security_review_items", - ); - expect(tools.map((tool) => tool.name)).toContain( - "record_codex_security_discovery_candidates", - ); - const preservedContextMaxLength = tools.find( - (tool) => tool.name === "start_codex_security_standard_scan", - )?.inputSchema.properties["userContext"]?.maxLength; - expect(preservedContextMaxLength).toBeUndefined(); + try { + const tools = (await client.request("tools/list", {}))["tools"] as { + name: string; + inputSchema: { properties: Record }; + }[]; + expect(tools.map((tool) => tool.name)).toContain( + "prepare_codex_security_review_items", + ); + expect(tools.map((tool) => tool.name)).toContain( + "record_codex_security_discovery_candidates", + ); + const preservedContextMaxLength = tools.find( + (tool) => tool.name === "start_codex_security_standard_scan", + )?.inputSchema.properties["userContext"]?.maxLength; + expect(preservedContextMaxLength).toBeUndefined(); - const selection = { - targetPath: repository, - scope: ".", - mode: "diff", - diffTarget: { kind: "range", baseRevision, headRevision }, - }; - const opened = await call("open_codex_security_workspace", selection); - const sessionId = (opened["workspace"] as JsonObject)["id"] as string; - await call("submit_codex_security_setup", { ...selection, sessionId }); - const started = await call("start_codex_security_scan", { sessionId }); - const results = (started["workspace"] as JsonObject)[ - "results" - ] as JsonObject; - const scanId = results["scanId"] as string; - const handoffClaimToken = randomUUID(); - await call("claim_codex_security_scan_handoff_delivery", { - scanId, - claimToken: handoffClaimToken, - }); - await call("attach_codex_security_scan_continuation_thread", { - scanId, - claimToken: handoffClaimToken, - threadId: owner, - }); - const context = await call("get_codex_security_scan_context", { - scanId, - handoffClaimToken, - }); - const scanDir = (context["scan"] as JsonObject)["scanDir"] as string; + const selection = { + targetPath: repository, + scope: ".", + mode: "diff", + diffTarget: { kind: "range", baseRevision, headRevision }, + }; + const opened = await call("open_codex_security_workspace", selection); + const openedWorkspace = opened["workspace"] as JsonObject; + const selectedDiffTarget = openedWorkspace["diffTarget"] as JsonObject; + const contentDigest = selectedDiffTarget["contentDigest"] as string; + expect(contentDigest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + const sessionId = openedWorkspace["id"] as string; + await call("submit_codex_security_setup", { + ...selection, + diffTarget: selectedDiffTarget, + sessionId, + }); + const started = await call("start_codex_security_scan", { sessionId }); + const results = (started["workspace"] as JsonObject)[ + "results" + ] as JsonObject; + const scanId = results["scanId"] as string; + const handoffClaimToken = randomUUID(); + await call("claim_codex_security_scan_handoff_delivery", { + scanId, + claimToken: handoffClaimToken, + }); + await call("attach_codex_security_scan_continuation_thread", { + scanId, + claimToken: handoffClaimToken, + threadId: owner, + }); + const context = await call("get_codex_security_scan_context", { + scanId, + handoffClaimToken, + }); + const scanDir = (context["scan"] as JsonObject)["scanDir"] as string; - const inventory = await call("prepare_codex_security_review_items", { - scanId, - handoffClaimToken, - }); - expect(inventory["reviewItemsTotal"]).toBe(2); - const items = await call("list_codex_security_review_items", { - scanId, - handoffClaimToken, - }); - expect(items["items"]).toEqual([ - { path: "src/guard.py" }, - { path: "src/handler.py" }, - ]); - - await call("record_codex_security_discovery_candidates", { - scanId, - candidates: [candidate("src/handler.py")], - }); - const listed = await call("list_codex_security_candidates", { scanId }); - const rows = listed["rows"] as JsonObject[]; - expect(rows).toHaveLength(1); - await call("record_codex_security_candidate_validations", { - scanId, - validations: [ + const inventory = await call("prepare_codex_security_review_items", { + scanId, + handoffClaimToken, + }); + expect(inventory["reviewItemsTotal"]).toBe(2); + const items = await call("list_codex_security_review_items", { + scanId, + handoffClaimToken, + }); + expect(items["items"]).toEqual([ + { path: "src/guard.py" }, + { path: "src/handler.py" }, + ]); + + await call("record_codex_security_discovery_candidates", { + scanId, + candidates: [candidate("src/handler.py")], + }); + const listed = await call("list_codex_security_candidates", { scanId }); + const rows = listed["rows"] as JsonObject[]; + expect(rows).toHaveLength(1); + await call("record_codex_security_candidate_validations", { + scanId, + validations: [ + { + candidateId: rows[0]?.["candidate_id"], + validation: { + disposition: "suppressed", + method: "Static review of the changed handler.", + confidence: "high", + confidence_rationale: "The assignment is directly visible.", + rubric: ["The assignment does not cross a trust boundary."], + evidence: ["value = 2"], + counterevidence_or_proof_gap: "No sensitive operation exists.", + remaining_uncertainty: "", + }, + }, + ], + }); + await call("record_candidate_attack_paths", { + scanId, + attackPaths: [], + }); + const canonicalModel = { + summary: + "A local handler processes selected input (src/handler.py:1).", + assets: ["Integrity of the selected result."], + trustBoundaries: [ + "Caller input reaches the handler without authority over private state (src/handler.py:1).", + ], + attackerCapabilities: [ + "A caller can choose input but cannot choose another user's state.", + ], + securityObjectives: ["Keep each result bound to its selected input."], + assumptions: [ + "A shared-service deployment has not been established.", + ], + }; + const markdownFact = + "Selected input stays separate from private state (src/handler.py:1)."; + const savedModelPath = join( + scanDir, + "artifacts", + "01_context", + "threat_model.md", + ); + mkdirSync(dirname(savedModelPath), { recursive: true, mode: 0o700 }); + writeFileSync( + savedModelPath, + `# Saved threat model\n\n${markdownFact}\n`, + ); + const threatModel = + format === "Markdown" + ? { summary: readFileSync(savedModelPath, "utf8") } + : canonicalModel; + const openQuestions = [ { - candidateId: rows[0]?.["candidate_id"], - validation: { - disposition: "suppressed", - method: "Static review of the changed handler.", - confidence: "high", - confidence_rationale: "The assignment is directly visible.", - rubric: ["The assignment does not cross a trust boundary."], - evidence: ["value = 2"], - counterevidence_or_proof_gap: "No sensitive operation exists.", - remaining_uncertainty: "", + question: + "Does a supported embedding share this worker across callers?", + followUpPrompt: + "Confirm the deployment's ownership and isolation controls.", + }, + ]; + const coverageNote = + "The handler does not grant access to another caller's state (src/handler.py:1)."; + const finding = { + ruleId: "path-traversal.archive-extraction", + title: "Unsafe archive extraction", + summary: "An untrusted archive entry reaches a filesystem write.", + severity: { level: "high" }, + confidence: { + level: "high", + rationale: "Source evidence establishes reachability.", + }, + taxonomy: { category: "path-traversal", cwe: ["CWE-22"] }, + locations: [{ path: "src/handler.py", startLine: 1 }], + remediation: "Validate each output path before writing.", + provenance: { source: "local_plugin" }, + }; + const invalidRootCauseReference = await client.request("tools/call", { + name: "record_codex_security_scan_draft", + arguments: { + scanId, + handoffClaimToken, + findings: [ + { + ...finding, + root_cause: { + evidenceRefs: ["missing-root-cause-evidence"], + }, + }, + ], + coverage: { + completeness: "complete", + surfaces: [{ label: "Changed files", disposition: "rejected" }], + explicitExclusions: [], + deferred: [], }, }, - ], - }); - await call("record_candidate_attack_paths", { scanId, attackPaths: [] }); - const canonicalModel = { - summary: "A local handler processes selected input (src/handler.py:1).", - assets: ["Integrity of the selected result."], - trustBoundaries: [ - "Caller input reaches the handler without authority over private state (src/handler.py:1).", - ], - attackerCapabilities: [ - "A caller can choose input but cannot choose another user's state.", - ], - securityObjectives: ["Keep each result bound to its selected input."], - assumptions: ["A shared-service deployment has not been established."], - }; - const markdownFact = - "Selected input stays separate from private state (src/handler.py:1)."; - const savedModelPath = join( - scanDir, - "artifacts", - "01_context", - "threat_model.md", - ); - mkdirSync(dirname(savedModelPath), { recursive: true, mode: 0o700 }); - writeFileSync( - savedModelPath, - `# Saved threat model\n\n${markdownFact}\n`, - ); - const threatModel = - format === "Markdown" - ? { summary: readFileSync(savedModelPath, "utf8") } - : canonicalModel; - const openQuestions = [ - { - question: - "Does a supported embedding share this worker across callers?", - followUpPrompt: - "Confirm the deployment's ownership and isolation controls.", - }, - ]; - const coverageNote = - "The handler does not grant access to another caller's state (src/handler.py:1)."; - const finding = { - ruleId: "path-traversal.archive-extraction", - title: "Unsafe archive extraction", - summary: "An untrusted archive entry reaches a filesystem write.", - severity: { level: "high" }, - confidence: { - level: "high", - rationale: "Source evidence establishes reachability.", - }, - taxonomy: { category: "path-traversal", cwe: ["CWE-22"] }, - locations: [{ path: "src/handler.py", startLine: 1 }], - remediation: "Validate each output path before writing.", - provenance: { source: "local_plugin" }, - }; - const invalidRootCauseReference = await client.request("tools/call", { - name: "record_codex_security_scan_draft", - arguments: { + _meta: { "openai/threadId": owner }, + }); + expect(invalidRootCauseReference["isError"]).toBe(true); + expect(JSON.stringify(invalidRootCauseReference)).toContain( + "root_cause.evidenceRefs", + ); + await call("record_codex_security_scan_draft", { scanId, handoffClaimToken, findings: [ { ...finding, - root_cause: { - evidenceRefs: ["missing-root-cause-evidence"], + identity: { + anchor: "candidate-duplicate-instance", + instance: "dss-147-a", + }, + }, + { + ...finding, + extensions: { + candidateId: "candidate-duplicate-instance", + reportId: "DSS-147-A", }, }, ], @@ -680,261 +806,628 @@ describe("compact diff scan", () => { explicitExclusions: [], deferred: [], }, - }, - _meta: { "openai/threadId": owner }, - }); - expect(invalidRootCauseReference["isError"]).toBe(true); - expect(JSON.stringify(invalidRootCauseReference)).toContain( - "root_cause.evidenceRefs", - ); - await call("record_codex_security_scan_draft", { - scanId, - handoffClaimToken, - findings: [ - { - ...finding, - identity: { - anchor: "candidate-duplicate-instance", - instance: "dss-147-a", - }, - }, - { - ...finding, - extensions: { - candidateId: "candidate-duplicate-instance", - reportId: "DSS-147-A", + }); + expect( + ( + JSON.parse( + readFileSync(join(scanDir, "findings.json"), "utf8"), + ) as { + findings: JsonObject[]; + } + ).findings.map((draftFinding) => draftFinding["identity"]), + ).toEqual([ + { anchor: "candidate-duplicate-instance", instance: "dss-147-a" }, + { anchor: "candidate-duplicate-instance", instance: "dss-147-a" }, + ]); + await call("record_codex_security_scan_draft", { + scanId, + handoffClaimToken, + findings: [ + { + ...finding, + extensions: { + candidateId: "candidate-singleton", + reportId: "DSS-144-A", + }, }, - }, - ], - coverage: { - completeness: "complete", - surfaces: [{ label: "Changed files", disposition: "rejected" }], - explicitExclusions: [], - deferred: [], - }, - }); - expect( - ( - JSON.parse(readFileSync(join(scanDir, "findings.json"), "utf8")) as { - findings: JsonObject[]; - } - ).findings.map((draftFinding) => draftFinding["identity"]), - ).toEqual([ - { anchor: "candidate-duplicate-instance", instance: "dss-147-a" }, - { anchor: "candidate-duplicate-instance", instance: "dss-147-a" }, - ]); - await call("record_codex_security_scan_draft", { - scanId, - handoffClaimToken, - findings: [ - { - ...finding, - extensions: { - candidateId: "candidate-singleton", - reportId: "DSS-144-A", + { + ...finding, + code_evidence: [ + { + code: "value = 2", + id: "legacy-source", + }, + ], + attackPath: { + dataflow: { evidence_refs: ["legacy-source"] }, + }, }, - }, - { - ...finding, - code_evidence: [ - { - code: "value = 2", - id: "legacy-source", + { + ...finding, + ruleId: "path-traversal.archive-upload", + identity: { + anchor: "candidate-cross-rule", + instance: "shared-report", }, - ], - attackPath: { - dataflow: { evidence_refs: ["legacy-source"] }, }, - }, - { - ...finding, - ruleId: "path-traversal.archive-upload", - identity: { - anchor: "candidate-cross-rule", - instance: "shared-report", + { + ...finding, + extensions: { + candidateId: "candidate-cross-rule", + reportId: "shared-report", + }, }, - }, - { - ...finding, - extensions: { - candidateId: "candidate-cross-rule", - reportId: "shared-report", + { + ...finding, + extensions: { + candidateId: "candidate-cross-rule", + reportId: "second-report", + }, }, - }, - { - ...finding, - extensions: { - candidateId: "candidate-cross-rule", - reportId: "second-report", + { + ...finding, + identity: { + anchor: "candidate-authored-instance", + instance: "dss-147-a", + }, }, - }, - { - ...finding, - identity: { - anchor: "candidate-authored-instance", - instance: "dss-147-a", + { + ...finding, + extensions: { + candidateId: "candidate-authored-instance", + reportId: "DSS-147-B", + }, }, - }, - { - ...finding, - extensions: { - candidateId: "candidate-authored-instance", - reportId: "DSS-147-B", + { + ...finding, + extensions: { + candidateId: "candidate-authored-instance", + ledgerRowId: "ledger-row-c", + }, }, + ], + threatModel, + coverage: { + completeness: "complete", + surfaces: [ + { + label: "Changed files", + disposition: "rejected", + notes: coverageNote, + }, + ], + explicitExclusions: [], + deferred: [], + openQuestions, }, + }); + const draftManifest = JSON.parse( + readFileSync(join(scanDir, "scan-manifest.json"), "utf8"), + ) as JsonObject; + const draftTarget = (draftManifest["scan"] as JsonObject)[ + "target" + ] as JsonObject; + expect(draftTarget["snapshotDigest"]).toBe(contentDigest); + const canonicalDraftIdentities = ( + JSON.parse(readFileSync(join(scanDir, "findings.json"), "utf8")) as { + findings: JsonObject[]; + } + ).findings.map((draftFinding) => draftFinding["identity"]); + expect(canonicalDraftIdentities).toHaveLength(9); + const legacyCoordinateDigest = `codex-security-snapshot/v1:sha256:${createHash( + "sha256", + ) + .update("codex-security-diff/v1\0") + .update("range") + .update("\0") + .update(baseRevision) + .update("\0") + .update(headRevision) + .digest("hex")}`; + await call("complete_codex_security_scan", { + scanId, + handoffClaimToken, + }); + const completed = await call("get_codex_security_completed_scan", { + scanId, + handoffClaimToken, + }); + const target = ( + (completed["manifest"] as JsonObject)["scan"] as JsonObject + )["target"] as JsonObject; + expect(target["snapshotDigest"]).toBe(contentDigest); + expect((completed["coverage"] as JsonObject)["inventoryStrategy"]).toBe( + "diff", + ); + const completedIdentities = ( + (completed["findings"] as JsonObject)["findings"] as JsonObject[] + ).map((completedFinding) => completedFinding["identity"]); + expect(completedIdentities).toEqual(canonicalDraftIdentities); + expect(completedIdentities).toEqual([ + { anchor: "candidate-singleton", instance: "dss-144-a" }, + { anchor: "unsafe-archive-extraction" }, + { anchor: "candidate-cross-rule", instance: "shared-report" }, + { anchor: "candidate-cross-rule", instance: "shared-report" }, + { anchor: "candidate-cross-rule", instance: "second-report" }, + { anchor: "candidate-authored-instance", instance: "dss-147-a" }, { - ...finding, - extensions: { - candidateId: "candidate-authored-instance", - ledgerRowId: "ledger-row-c", - }, + anchor: "candidate-authored-instance", + instance: "dss-147-b", }, - ], - threatModel, - coverage: { - completeness: "complete", - surfaces: [ - { - label: "Changed files", - disposition: "rejected", - notes: coverageNote, - }, + { anchor: "candidate-authored-instance", instance: "ledger-row-c" }, + { anchor: "candidate-duplicate-instance", instance: "dss-147-a" }, + ]); + const legacyFinding = ( + (completed["findings"] as JsonObject)["findings"] as JsonObject[] + )[1]; + expect(legacyFinding?.["code_evidence"]).toEqual([ + { code: "value = 2", id: "legacy-source" }, + ]); + expect(legacyFinding?.["attackPath"]).toEqual({ + dataflow: { evidence_refs: ["legacy-source"] }, + }); + expect( + ((completed["manifest"] as JsonObject)["scan"] as JsonObject)[ + "threatModel" ], - explicitExclusions: [], - deferred: [], + ).toEqual(threatModel); + expect((completed["coverage"] as JsonObject)["openQuestions"]).toEqual( openQuestions, - }, - }); - const canonicalDraftIdentities = ( - JSON.parse(readFileSync(join(scanDir, "findings.json"), "utf8")) as { - findings: JsonObject[]; + ); + const contract = await loadContract(scanDir, { + pluginRoot: PLUGIN_ROOT, + }); + expect(contract.manifest.scan.threatModel).toEqual(threatModel); + expect(contract.coverage.openQuestions).toEqual(openQuestions); + expect(contract.coverage.surfaces[0]?.notes).toBe(coverageNote); + const report = readFileSync(join(scanDir, "report.md"), "utf8"); + const modelFacts = + format === "Markdown" + ? [markdownFact] + : Object.values(canonicalModel).flat(); + for (const fact of modelFacts) { + expect(report).toContain(fact); } - ).findings.map((draftFinding) => draftFinding["identity"]); - expect(canonicalDraftIdentities).toHaveLength(9); - await call("complete_codex_security_scan", { - scanId, - handoffClaimToken, - }); - const completed = await call("get_codex_security_completed_scan", { - scanId, - handoffClaimToken, - }); - const target = ( - (completed["manifest"] as JsonObject)["scan"] as JsonObject - )["target"] as JsonObject; - const digest = createHash("sha256") - .update("codex-security-diff/v1\0") - .update("range") - .update("\0") - .update(baseRevision) - .update("\0") - .update(headRevision) - .digest("hex"); - expect(target["snapshotDigest"]).toBe( - `codex-security-snapshot/v1:sha256:${digest}`, - ); - expect((completed["coverage"] as JsonObject)["inventoryStrategy"]).toBe( - "diff", - ); - const completedIdentities = ( - (completed["findings"] as JsonObject)["findings"] as JsonObject[] - ).map((completedFinding) => completedFinding["identity"]); - expect(completedIdentities).toEqual(canonicalDraftIdentities); - expect(completedIdentities).toEqual([ - { anchor: "candidate-singleton", instance: "dss-144-a" }, - { anchor: "unsafe-archive-extraction" }, - { anchor: "candidate-cross-rule", instance: "shared-report" }, - { anchor: "candidate-cross-rule", instance: "shared-report" }, - { anchor: "candidate-cross-rule", instance: "second-report" }, - { anchor: "candidate-authored-instance", instance: "dss-147-a" }, - { - anchor: "candidate-authored-instance", - instance: "dss-147-b", - }, - { anchor: "candidate-authored-instance", instance: "ledger-row-c" }, - { anchor: "candidate-duplicate-instance", instance: "dss-147-a" }, - ]); - const legacyFinding = ( - (completed["findings"] as JsonObject)["findings"] as JsonObject[] - )[1]; - expect(legacyFinding?.["code_evidence"]).toEqual([ - { code: "value = 2", id: "legacy-source" }, - ]); - expect(legacyFinding?.["attackPath"]).toEqual({ - dataflow: { evidence_refs: ["legacy-source"] }, - }); - expect( - ((completed["manifest"] as JsonObject)["scan"] as JsonObject)[ - "threatModel" - ], - ).toEqual(threatModel); - expect((completed["coverage"] as JsonObject)["openQuestions"]).toEqual( - openQuestions, - ); - const contract = await loadContract(scanDir, { pluginRoot: PLUGIN_ROOT }); - expect(contract.manifest.scan.threatModel).toEqual(threatModel); - expect(contract.coverage.openQuestions).toEqual(openQuestions); - expect(contract.coverage.surfaces[0]?.notes).toBe(coverageNote); - const report = readFileSync(join(scanDir, "report.md"), "utf8"); - const modelFacts = - format === "Markdown" - ? [markdownFact] - : Object.values(canonicalModel).flat(); - for (const fact of modelFacts) { - expect(report).toContain(fact); + expect(report).toContain(openQuestions[0]!.question); + expect(report).toContain(openQuestions[0]!.followUpPrompt); + expect(report).toContain(coverageNote); + + const terminalDir = join(root, "terminal-scan"); + mkdirSync(terminalDir, { mode: 0o700 }); + const markdownModel = `# Existing threat model\n\n## Assumptions\n\n${markdownFact}\n`; + const terminalManifest = structuredClone( + completed["manifest"], + ) as JsonObject; + const terminalScan = terminalManifest["scan"] as JsonObject; + terminalScan["threatModel"] = { summary: markdownModel }; + (terminalScan["target"] as JsonObject)["snapshotDigest"] = + legacyCoordinateDigest; + delete terminalScan["sealedAt"]; + delete terminalScan["artifacts"]; + for (const [name, document] of [ + ["scan-manifest.json", terminalManifest], + ["findings.json", completed["findings"]], + ["coverage.json", completed["coverage"]], + ] as const) { + writeFileSync(join(terminalDir, name), JSON.stringify(document)); + } + const finalized = python( + "finalize_scan_contract.py", + "--scan-dir", + terminalDir, + "--source-root", + repository, + ); + expect(finalized.status, finalized.stderr).toBe(0); + const validated = python( + "validate_scan_contract.py", + "--scan-dir", + terminalDir, + ); + expect(validated.status, validated.stderr).toBe(0); + const terminalResult = JSON.parse( + readFileSync(join(terminalDir, "scan-manifest.json"), "utf8"), + ) as { + scan: { + sealedAt: string; + target: { snapshotDigest: string }; + threatModel: unknown; + }; + }; + expect(terminalResult.scan.threatModel).toEqual({ + summary: markdownModel, + }); + expect(terminalResult.scan.target.snapshotDigest).toBe( + legacyCoordinateDigest, + ); + expect(terminalResult.scan.sealedAt).toBeDefined(); + const terminalReport = readFileSync( + join(terminalDir, "report.md"), + "utf8", + ); + expect(terminalReport).toContain(markdownFact); + expect(terminalReport.match(/^#{1,2} .+$/gm)).toEqual( + report.match(/^#{1,2} .+$/gm), + ); + } finally { + await client.close(); } - expect(report).toContain(openQuestions[0]!.question); - expect(report).toContain(openQuestions[0]!.followUpPrompt); - expect(report).toContain(coverageNote); - - const terminalDir = join(root, "terminal-scan"); - mkdirSync(terminalDir, { mode: 0o700 }); - const markdownModel = `# Existing threat model\n\n## Assumptions\n\n${markdownFact}\n`; - const terminalManifest = structuredClone( - completed["manifest"], - ) as JsonObject; - const terminalScan = terminalManifest["scan"] as JsonObject; - terminalScan["threatModel"] = { summary: markdownModel }; - delete terminalScan["sealedAt"]; - delete terminalScan["artifacts"]; - for (const [name, document] of [ - ["scan-manifest.json", terminalManifest], - ["findings.json", completed["findings"]], - ["coverage.json", completed["coverage"]], - ] as const) { - writeFileSync(join(terminalDir, name), JSON.stringify(document)); + }, + 30_000, + ); + + test.each(["commit", "range"] as const)( + "accepts a new clean %s selection without a repeated digest", + async (kind) => { + const { root, repository } = createRepository(); + writeSource(repository, "fixture.txt", "base\n"); + git(repository, "add", "fixture.txt"); + git(repository, "commit", "-qm", "base"); + const baseRevision = git(repository, "rev-parse", "HEAD"); + writeSource(repository, "fixture.txt", "head\n"); + git(repository, "add", "fixture.txt"); + git(repository, "commit", "-qm", "head"); + const headRevision = git(repository, "rev-parse", "HEAD"); + createMcpDirectories(root); + const client = await startMcp(root); + const owner = `new-${kind}-owner`; + const selection = { + targetPath: repository, + scope: ".", + mode: "diff", + diffTarget: + kind === "commit" + ? { kind, headRevision } + : { kind, baseRevision, headRevision }, + }; + + try { + const opened = await client.call( + "open_codex_security_workspace", + selection, + owner, + ); + const sessionId = (opened["workspace"] as JsonObject)["id"] as string; + writeSource(repository, "fixture.txt", "next\n"); + git(repository, "add", "fixture.txt"); + git(repository, "commit", "-qm", "next"); + const nextRevision = git(repository, "rev-parse", "HEAD"); + const nextDiffTarget = + kind === "commit" + ? { kind, headRevision: nextRevision } + : { + kind, + baseRevision: headRevision, + headRevision: nextRevision, + }; + + const submitted = await client.call( + "submit_codex_security_setup", + { + targetPath: repository, + scope: ".", + mode: "diff", + diffTarget: nextDiffTarget, + sessionId, + }, + owner, + ); + const submittedDiffTarget = (submitted["workspace"] as JsonObject)[ + "diffTarget" + ] as JsonObject; + expect(submittedDiffTarget).toMatchObject(nextDiffTarget); + expect(submittedDiffTarget["contentDigest"]).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + expect(workspaceDiffSelection(root, sessionId).contentDigest).toBe( + submittedDiffTarget["contentDigest"] as string, + ); + expect(workbenchState(root)).toEqual({ + workspaces: 1, + submitted: 1, + scans: 0, + }); + + await client.call("start_codex_security_scan", { sessionId }, owner); + expect(workbenchState(root)).toEqual({ + workspaces: 1, + submitted: 1, + scans: 1, + }); + } finally { + await client.close(); } - const finalized = python( - "finalize_scan_contract.py", - "--scan-dir", - terminalDir, - "--source-root", + }, + 30_000, + ); + + test.each(["commit", "range"] as const)( + "recovers a persisted NULL-digest %s workspace after a missing blob is restored", + async (kind) => { + const { root, repository } = createRepository(); + writeSource(repository, "fixture.txt", "base\n"); + git(repository, "add", "fixture.txt"); + git(repository, "commit", "-qm", "base"); + const baseRevision = git(repository, "rev-parse", "HEAD"); + writeSource(repository, "fixture.txt", "head\n"); + git(repository, "add", "fixture.txt"); + git(repository, "commit", "-qm", "head"); + const headRevision = git(repository, "rev-parse", "HEAD"); + const blob = git(repository, "rev-parse", `${headRevision}:fixture.txt`); + const objectPath = join( repository, + ".git", + "objects", + blob.slice(0, 2), + blob.slice(2), ); - expect(finalized.status, finalized.stderr).toBe(0); - const validated = python( - "validate_scan_contract.py", - "--scan-dir", - terminalDir, - ); - expect(validated.status, validated.stderr).toBe(0); - const terminalResult = JSON.parse( - readFileSync(join(terminalDir, "scan-manifest.json"), "utf8"), - ) as { scan: { threatModel: unknown; sealedAt: string } }; - expect(terminalResult.scan.threatModel).toEqual({ - summary: markdownModel, - }); - expect(terminalResult.scan.sealedAt).toBeDefined(); - const terminalReport = readFileSync( - join(terminalDir, "report.md"), - "utf8", - ); - expect(terminalReport).toContain(markdownFact); - expect(terminalReport.match(/^#{1,2} .+$/gm)).toEqual( - report.match(/^#{1,2} .+$/gm), - ); - } finally { - await client.close(); - } - }); + const backupPath = `${objectPath}.missing`; + mkdirSync(join(repository, "nested")); + const selectedPath = join(repository, "nested", ".."); + createMcpDirectories(root); + const client = await startMcp(root); + const owner = `missing-blob-${kind}-owner`; + const selection = { + targetPath: selectedPath, + scope: ".", + mode: "diff", + diffTarget: + kind === "commit" + ? { kind, headRevision } + : { kind, baseRevision, headRevision }, + }; + + try { + renameSync(objectPath, backupPath); + const opened = await client.call( + "open_codex_security_workspace", + selection, + owner, + ); + renameSync(backupPath, objectPath); + const openedWorkspace = opened["workspace"] as JsonObject; + const sessionId = openedWorkspace["id"] as string; + expect(openedWorkspace["setupValidation"]).toMatchObject({ + valid: false, + }); + expect(JSON.stringify(openedWorkspace["setupValidation"])).toContain( + "Could not snapshot the selected committed changes.", + ); + expect(workspaceDiffSelection(root, sessionId)).toEqual({ + baseRevision: kind === "commit" ? null : baseRevision, + contentDigest: null, + headRevision, + }); + + const omittedDigest = await client.request("tools/call", { + name: "submit_codex_security_setup", + arguments: { ...selection, sessionId }, + _meta: { "openai/threadId": owner }, + }); + expect(omittedDigest["isError"], JSON.stringify(omittedDigest)).toBe( + true, + ); + expect(JSON.stringify(omittedDigest)).toContain( + "no longer produce the same diff", + ); + expect(workspaceDiffSelection(root, sessionId)).toEqual({ + baseRevision: kind === "commit" ? null : baseRevision, + contentDigest: null, + headRevision, + }); + + const inspected = await client.call( + "inspect_codex_security_setup", + selection, + owner, + ); + const currentDiffTarget = (inspected["setup"] as JsonObject)[ + "diffTarget" + ] as JsonObject; + expect(currentDiffTarget).toMatchObject({ + baseRevision, + headRevision, + kind, + }); + expect(currentDiffTarget["contentDigest"]).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + + const submitted = await client.call( + "submit_codex_security_setup", + { + ...selection, + diffTarget: currentDiffTarget, + sessionId, + }, + owner, + ); + expect( + ((submitted["workspace"] as JsonObject)["diffTarget"] as JsonObject)[ + "contentDigest" + ], + ).toBe(currentDiffTarget["contentDigest"]); + expect(workspaceDiffSelection(root, sessionId)).toEqual({ + baseRevision, + contentDigest: currentDiffTarget["contentDigest"] as string, + headRevision, + }); + expect(workbenchState(root)).toEqual({ + workspaces: 1, + submitted: 1, + scans: 0, + }); + + await client.call("start_codex_security_scan", { sessionId }, owner); + expect(workbenchState(root)).toEqual({ + workspaces: 1, + submitted: 1, + scans: 1, + }); + } finally { + if (existsSync(backupPath)) renameSync(backupPath, objectPath); + await client.close(); + } + }, + 30_000, + ); + + test.each(["commit", "range"] as const)( + "rejects a stale %s selection through the bundled MCP", + async (kind) => { + const { root, repository } = createRepository(); + writeSource(repository, "fixture.txt", "base\n"); + git(repository, "add", "fixture.txt"); + git(repository, "commit", "-qm", "base"); + const baseRevision = git(repository, "rev-parse", "HEAD"); + writeSource(repository, "fixture.txt", "head\n"); + git(repository, "add", "fixture.txt"); + git(repository, "commit", "-qm", "head"); + const headRevision = git(repository, "rev-parse", "HEAD"); + createMcpDirectories(root); + const client = await startMcp(root); + const owner = `stale-${kind}-owner`; + const requestedDiffTarget = + kind === "commit" + ? { kind, headRevision } + : { kind, baseRevision, headRevision }; + const selection = { + targetPath: repository, + scope: ".", + mode: "diff", + diffTarget: requestedDiffTarget, + }; + + try { + const opened = await client.call( + "open_codex_security_workspace", + selection, + owner, + ); + const workspace = opened["workspace"] as JsonObject; + const sessionId = workspace["id"] as string; + const selectedDiffTarget = workspace["diffTarget"] as JsonObject; + expect(selectedDiffTarget["kind"]).toBe(kind); + expect(selectedDiffTarget["contentDigest"]).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + expect(workbenchState(root)).toEqual({ + workspaces: 1, + submitted: 0, + scans: 0, + }); + + const selectedBlob = git( + repository, + "rev-parse", + `${headRevision}:fixture.txt`, + ); + const replacement = execFileSync( + "git", + ["hash-object", "-w", "--stdin"], + { cwd: repository, encoding: "utf8", input: "substituted\n" }, + ).trim(); + git(repository, "replace", "-f", selectedBlob, replacement); + + const inspected = await client.call( + "inspect_codex_security_setup", + selection, + owner, + ); + const currentDiffTarget = (inspected["setup"] as JsonObject)[ + "diffTarget" + ] as JsonObject; + expect(currentDiffTarget["contentDigest"]).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + expect(currentDiffTarget["contentDigest"]).not.toBe( + selectedDiffTarget["contentDigest"], + ); + const withoutContentDigest = { ...selectedDiffTarget }; + delete withoutContentDigest["contentDigest"]; + for (const diffTarget of [ + withoutContentDigest, + selectedDiffTarget, + currentDiffTarget, + ]) { + const submitted = await client.request("tools/call", { + name: "submit_codex_security_setup", + arguments: { + ...selection, + diffTarget, + sessionId, + }, + _meta: { "openai/threadId": owner }, + }); + expect(submitted["isError"], JSON.stringify(submitted)).toBe(true); + expect(JSON.stringify(submitted)).toContain( + "no longer produce the same diff", + ); + expect(workbenchState(root)).toEqual({ + workspaces: 1, + submitted: 0, + scans: 0, + }); + expect(workspaceDiffSelection(root, sessionId).contentDigest).toBe( + selectedDiffTarget["contentDigest"] as string, + ); + } + + const started = await client.request("tools/call", { + name: "start_codex_security_scan", + arguments: { sessionId }, + _meta: { "openai/threadId": owner }, + }); + expect(started["isError"], JSON.stringify(started)).toBe(true); + expect(JSON.stringify(started)).toContain( + "Save the Codex Security setup", + ); + expect(workbenchState(root)).toEqual({ + workspaces: 1, + submitted: 0, + scans: 0, + }); + + const replacementWorkspace = await client.call( + "open_codex_security_workspace", + { ...selection, diffTarget: currentDiffTarget }, + owner, + ); + const replacementSessionId = ( + replacementWorkspace["workspace"] as JsonObject + )["id"] as string; + expect(replacementSessionId).not.toBe(sessionId); + await client.call( + "submit_codex_security_setup", + { + ...selection, + diffTarget: currentDiffTarget, + sessionId: replacementSessionId, + }, + owner, + ); + expect( + workspaceDiffSelection(root, replacementSessionId).contentDigest, + ).toBe(currentDiffTarget["contentDigest"] as string); + expect(workbenchState(root)).toEqual({ + workspaces: 2, + submitted: 1, + scans: 0, + }); + await client.call( + "start_codex_security_scan", + { sessionId: replacementSessionId }, + owner, + ); + expect(workbenchState(root)).toEqual({ + workspaces: 2, + submitted: 1, + scans: 1, + }); + } finally { + await client.close(); + } + }, + 30_000, + ); }); diff --git a/sdk/typescript/tests-ts/git-batch-object-validation.test.ts b/sdk/typescript/tests-ts/git-batch-object-validation.test.ts new file mode 100644 index 000000000..41569450d --- /dev/null +++ b/sdk/typescript/tests-ts/git-batch-object-validation.test.ts @@ -0,0 +1,3561 @@ +import { spawnSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + BUNDLED_PLUGIN_VERSION, + bootstrapPlugin, + resolveCodexCommand, +} from "../src/index.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +const temporaryRoots: string[] = []; +const scripts = join(PLUGIN_ROOT, "scripts"); +const python = + process.env["PYTHON"] ?? Bun.which("python3") ?? Bun.which("python"); + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function git(repository: string, ...args: string[]): string { + const result = spawnSync("git", ["-C", repository, ...args], { + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); +} + +function commit(repository: string, message: string): void { + git( + repository, + "-c", + "user.name=synthetic-test", + "-c", + "user.email=synthetic-test@example.invalid", + "commit", + "--quiet", + "-m", + message, + ); +} + +function runPython( + source: string[], + args: string[], + state?: string, + environment: NodeJS.ProcessEnv = {}, + scriptRoot = scripts, +): string { + expect(python).not.toBeNull(); + const result = spawnSync( + python!, + [ + "-I", + "-B", + "-c", + ["import sys", "sys.path.insert(0, sys.argv[1])", ...source].join("\n"), + scriptRoot, + ...args, + ], + { + encoding: "utf8", + env: { + PATH: process.env["PATH"], + ...environment, + ...(state === undefined ? {} : { CODEX_SECURITY_STATE_DIR: state }), + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); +} + +function runPythonJson( + source: string[], + args: string[], + state?: string, + environment: NodeJS.ProcessEnv = {}, + scriptRoot = scripts, +): T { + return JSON.parse( + runPython(source, args, state, environment, scriptRoot), + ) as T; +} + +function createRepository(): { + base: string; + head: string; + repository: string; + root: string; + state: string; +} { + const root = realpathSync( + mkdtempSync(join(tmpdir(), "codex-security-git-batch-")), + ); + temporaryRoots.push(root); + const repository = join(root, "repository"); + const state = join(root, "state"); + mkdirSync(repository); + mkdirSync(state, { mode: 0o700 }); + git(repository, "init", "--quiet"); + writeFileSync(join(repository, "fixture.txt"), "base\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "base"); + const base = git(repository, "rev-parse", "HEAD"); + writeFileSync(join(repository, "fixture.txt"), "head\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "head"); + return { + base, + head: git(repository, "rev-parse", "HEAD"), + repository, + root, + state, + }; +} + +async function upgradeBundledPlugin( + root: string, + previousVersion: string, +): Promise { + const previous = join(root, "previous-plugin"); + cpSync(PLUGIN_ROOT, previous, { recursive: true }); + const previousManifestPath = join(previous, ".codex-plugin", "plugin.json"); + const previousManifest = JSON.parse( + readFileSync(previousManifestPath, "utf8"), + ) as { version: string }; + previousManifest.version = previousVersion; + writeFileSync(previousManifestPath, JSON.stringify(previousManifest)); + writeFileSync( + join(previous, ".mcp.json"), + JSON.stringify({ mcpServers: { "codex-security": { env_vars: [] } } }), + ); + + const home = join(root, "codex-home"); + mkdirSync(home, { mode: 0o700 }); + writeFileSync( + join(home, "config.toml"), + 'cli_auth_credentials_store = "file"\n\n[features]\nplugins = true\n', + ); + const command = resolveCodexCommand(); + const environment = { + ...process.env, + CODEX_HOME: home, + OPENAI_API_KEY: undefined, + CODEX_API_KEY: undefined, + }; + const login = spawnSync(command.command, ["login", "--with-api-key"], { + env: environment, + input: "synthetic-key\n", + encoding: "utf8", + windowsHide: true, + }); + expect(login.status, login.stderr).toBe(0); + const options = { codexCommand: command, environment }; + + expect((await bootstrapPlugin(home, previous, options)).version).toBe( + previousVersion, + ); + const upgraded = await bootstrapPlugin(home, PLUGIN_ROOT, options); + expect(upgraded.version).not.toBe(previousVersion); + expect(upgraded.version).toBe(BUNDLED_PLUGIN_VERSION); + const installedMcp = JSON.parse( + readFileSync(join(upgraded.installedRoot, ".mcp.json"), "utf8"), + ) as { mcpServers: Record }; + expect(installedMcp.mcpServers["codex-security"]?.env_vars).toContain( + "CODEX_SAFETY_IDENTIFIER", + ); + return upgraded.installedRoot; +} + +function batchRecord( + objectId: string, + type: string, + payload: Buffer, + declaredSize = payload.length, +): Buffer { + return Buffer.concat([ + Buffer.from(`${objectId} ${type} ${declaredSize}\n`), + payload, + Buffer.from("\n"), + ]); +} + +function validateBatch(requests: Buffer, output: Buffer) { + return runPythonJson<{ accepted: boolean; error?: string }>( + [ + "import io, json", + "from workbench_target import validate_git_batch_blob_stream", + "try:", + " validate_git_batch_blob_stream(io.BytesIO(bytes.fromhex(sys.argv[2])), io.BytesIO(bytes.fromhex(sys.argv[3])))", + "except ValueError as error:", + " print(json.dumps({'accepted': False, 'error': str(error)}))", + "else:", + " print(json.dumps({'accepted': True}))", + ], + [requests.toString("hex"), output.toString("hex")], + ); +} + +function committedDigest( + repository: string, + state: string | undefined, + base: string, + head: string, + environment: NodeJS.ProcessEnv = {}, + scriptRoot = scripts, +): string { + return runPython( + [ + "from pathlib import Path", + "from workbench_target import committed_diff_content_digest", + "print(committed_diff_content_digest(Path(sys.argv[2]), sys.argv[3], sys.argv[4]))", + ], + [repository, base, head], + state, + environment, + scriptRoot, + ); +} + +function committedObjectIdentity( + repository: string, + base: string, + head: string, + environment: NodeJS.ProcessEnv = {}, +): string { + return runPython( + [ + "from pathlib import Path", + "from workbench_target import committed_diff_object_identity", + "print(committed_diff_object_identity(Path(sys.argv[2]), sys.argv[3], sys.argv[4]))", + ], + [repository, base, head], + undefined, + environment, + ); +} + +describe("committed diff Git batch validation", () => { + const firstObject = "1".repeat(40); + const secondObject = "2".repeat(40); + const firstRecord = batchRecord( + firstObject, + "blob", + Buffer.from([0, 10, 255]), + ); + const secondRecord = batchRecord( + secondObject, + "blob", + Buffer.from("second\n"), + ); + const firstRequest = Buffer.from(`${firstObject}\0`); + const bothRequests = Buffer.from(`${firstObject}\0${secondObject}\0`); + + test("accepts exactly ordered, size-framed blob responses", () => { + expect( + validateBatch(bothRequests, Buffer.concat([firstRecord, secondRecord])), + ).toEqual({ accepted: true }); + }); + + test.each([ + ["missing", Buffer.from(`${firstObject} missing\n`), firstRequest], + [ + "non-blob", + batchRecord(firstObject, "tree", Buffer.from("tree")), + firstRequest, + ], + ["malformed size", Buffer.from(`${firstObject} blob nope\n`), firstRequest], + ["negative size", Buffer.from(`${firstObject} blob -1\n`), firstRequest], + [ + "uppercase object ID", + batchRecord("A".repeat(40), "blob", Buffer.from("blob")), + firstRequest, + ], + [ + "short object ID", + batchRecord(firstObject.slice(1), "blob", Buffer.from("blob")), + firstRequest, + ], + ["unterminated header", Buffer.from(`${firstObject} blob 3`), firstRequest], + [ + "truncated payload", + Buffer.concat([ + Buffer.from(`${firstObject} blob 4\n`), + Buffer.from("abc"), + ]), + firstRequest, + ], + [ + "missing payload terminator", + Buffer.concat([ + Buffer.from(`${firstObject} blob 3\n`), + Buffer.from("abc"), + ]), + firstRequest, + ], + ["out-of-order", Buffer.concat([secondRecord, firstRecord]), bothRequests], + ["missing response", firstRecord, bothRequests], + [ + "extra response", + Buffer.concat([firstRecord, secondRecord]), + firstRequest, + ], + [ + "trailing data", + Buffer.concat([firstRecord, Buffer.from("x")]), + firstRequest, + ], + ] as const)("rejects %s batch transcripts", (_name, output, requests) => { + expect(validateBatch(requests, output)).toMatchObject({ accepted: false }); + }); + + test("reads newline-bearing revision paths and binary blobs", () => { + const { head, repository, state } = createRepository(); + const path = "line\nname.bin"; + const payload = Buffer.from([0, 10, 255, 13, 10]); + const hashed = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: payload }, + ); + expect(hashed.status, hashed.stderr).toBe(0); + const tree = spawnSync( + "git", + ["-C", repository, "hash-object", "-t", "tree", "-w", "--stdin"], + { + encoding: "utf8", + input: Buffer.concat([ + Buffer.from(`100644 ${path}\0`), + Buffer.from(hashed.stdout.trim(), "hex"), + ]), + }, + ); + expect(tree.status, tree.stderr).toBe(0); + expect(existsSync(join(repository, path))).toBe(false); + const revision = git( + repository, + "-c", + "user.name=synthetic-test", + "-c", + "user.email=synthetic-test@example.invalid", + "commit-tree", + tree.stdout.trim(), + "-p", + head, + "-m", + "binary path", + ); + const result = runPythonJson<{ + blobs: Array; + empty: unknown[]; + }>( + [ + "import base64, json", + "from pathlib import Path", + "from workbench_target import git_blob_bytes", + "requests = [sys.argv[3], sys.argv[4]]", + "blobs = git_blob_bytes(Path(sys.argv[2]), requests)", + "print(json.dumps({'blobs': [None if blob is None else base64.b64encode(blob).decode() for blob in blobs], 'empty': git_blob_bytes(Path(sys.argv[2]), [])}))", + ], + [repository, `${revision}:${path}`, `${head}:missing\npath.bin`], + state, + ); + + expect(result).toEqual({ + blobs: [payload.toString("base64"), null], + empty: [], + }); + }); + + test("hashes modified, added, and deleted blobs while skipping gitlinks", () => { + const { repository, state } = createRepository(); + writeFileSync(join(repository, "modified.bin"), Buffer.from("old\n")); + writeFileSync(join(repository, "deleted.txt"), "deleted\n"); + git(repository, "add", "."); + commit(repository, "matrix base"); + const matrixBase = git(repository, "rev-parse", "HEAD"); + const earlierCommit = git(repository, "rev-parse", "HEAD~1"); + writeFileSync( + join(repository, "modified.bin"), + Buffer.from([0, 10, 255, 13, 10]), + ); + writeFileSync(join(repository, "added.txt"), "added\n"); + rmSync(join(repository, "deleted.txt")); + git( + repository, + "update-index", + "--add", + "--cacheinfo", + `160000,${matrixBase},linked`, + ); + git(repository, "add", "modified.bin", "added.txt", "deleted.txt"); + commit(repository, "matrix head"); + const matrixHead = git(repository, "rev-parse", "HEAD"); + git( + repository, + "update-index", + "--cacheinfo", + `160000,${earlierCommit},linked`, + ); + commit(repository, "gitlink only"); + const gitlinkHead = git(repository, "rev-parse", "HEAD"); + + const result = runPythonJson<{ + batchArguments: string[][]; + digests: string[]; + requestCounts: number[]; + }>( + [ + "import json", + "from pathlib import Path", + "import workbench_target as target", + "original = target.git_command", + "request_counts, batch_arguments = [], []", + "def tracked_git_command(repository, *args, **kwargs):", + " if args[:2] == ('cat-file', '--batch'):", + " stream = kwargs['stdin']", + " requests = stream.read()", + " stream.seek(0)", + " request_counts.append(requests.count(b'\\0'))", + " batch_arguments.append(list(args))", + " return original(repository, *args, **kwargs)", + "target.git_command = tracked_git_command", + "repository = Path(sys.argv[2])", + "digests = [target.committed_diff_content_digest(repository, base, head) for base, head in ((sys.argv[3], sys.argv[4]), (sys.argv[4], sys.argv[5]))]", + "print(json.dumps({'batchArguments': batch_arguments, 'digests': digests, 'requestCounts': request_counts}))", + ], + [repository, matrixBase, matrixHead, gitlinkHead], + state, + ); + + expect(result.requestCounts).toEqual([4]); + expect(result.batchArguments).toEqual([["cat-file", "--batch", "-z"]]); + expect(result.digests).toEqual([ + expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + ]); + }); + + test("materializes each distinct changed blob once while binding raw paths", () => { + const { repository, state } = createRepository(); + const paths = Array.from( + { length: 32 }, + (_, index) => "shared-" + index.toString().padStart(2, "0") + ".bin", + ); + const oldPayload = Buffer.alloc(64 * 1024, 0x41); + const newPayload = Buffer.alloc(64 * 1024, 0x42); + for (const path of paths) writeFileSync(join(repository, path), oldPayload); + git(repository, "add", "."); + commit(repository, "shared blob base"); + const base = git(repository, "rev-parse", "HEAD"); + for (const path of paths) writeFileSync(join(repository, path), newPayload); + git(repository, "add", "."); + commit(repository, "shared blob head"); + const head = git(repository, "rev-parse", "HEAD"); + const oldObject = git(repository, "rev-parse", base + ":" + paths[0]); + const newObject = git(repository, "rev-parse", head + ":" + paths[0]); + + const result = runPythonJson<{ + digest: string; + objectBytes: number; + rawNewOccurrences: number; + rawOldOccurrences: number; + rawPaths: string[]; + requests: string[]; + }>( + [ + "import json, os", + "from pathlib import Path", + "import workbench_target as target", + "original = target.git_command", + "capture = {}", + "old_object = os.fsencode(sys.argv[5])", + "new_object = os.fsencode(sys.argv[6])", + "def tracked_git_command(repository, *args, **kwargs):", + " if args[:2] == ('cat-file', '--batch'):", + " stream = kwargs['stdin']", + " position = stream.tell()", + " stream.seek(0)", + " capture['requests'] = [os.fsdecode(value) for value in stream.read().split(b'\\0') if value]", + " stream.seek(position)", + " result = original(repository, *args, **kwargs)", + " if '--raw' in args and '-z' in args:", + " stream = kwargs['stdout']", + " position = stream.tell()", + " stream.seek(0)", + " fields = stream.read().split(b'\\0')", + " stream.seek(position)", + " headers = fields[:-1:2]", + " capture['rawPaths'] = [os.fsdecode(value) for value in fields[1:-1:2]]", + " capture['rawOldOccurrences'] = sum(header.split()[2] == old_object for header in headers)", + " capture['rawNewOccurrences'] = sum(header.split()[3] == new_object for header in headers)", + " if args[:2] == ('cat-file', '--batch'):", + " stream = kwargs['stdout']", + " position = stream.tell()", + " stream.seek(0, os.SEEK_END)", + " capture['objectBytes'] = stream.tell()", + " stream.seek(position)", + " return result", + "target.git_command = tracked_git_command", + "capture['digest'] = target.committed_diff_content_digest(Path(sys.argv[2]), sys.argv[3], sys.argv[4])", + "print(json.dumps(capture))", + ], + [repository, base, head, oldObject, newObject], + state, + ); + + const { repository: singleRepository, state: singleState } = + createRepository(); + writeFileSync(join(singleRepository, "single.bin"), oldPayload); + git(singleRepository, "add", "."); + commit(singleRepository, "single blob base"); + const singleBase = git(singleRepository, "rev-parse", "HEAD"); + writeFileSync(join(singleRepository, "single.bin"), newPayload); + git(singleRepository, "add", "."); + commit(singleRepository, "single blob head"); + const singleHead = git(singleRepository, "rev-parse", "HEAD"); + expect(git(singleRepository, "rev-parse", singleBase + ":single.bin")).toBe( + oldObject, + ); + expect(git(singleRepository, "rev-parse", singleHead + ":single.bin")).toBe( + newObject, + ); + const singleDigest = runPythonJson<{ digest: string }>( + [ + "import json", + "from pathlib import Path", + "import workbench_target as target", + "print(json.dumps({'digest': target.committed_diff_content_digest(Path(sys.argv[2]), sys.argv[3], sys.argv[4])}))", + ], + [singleRepository, singleBase, singleHead], + singleState, + ).digest; + + expect(result.rawPaths).toEqual(paths); + expect(result.rawOldOccurrences).toBe(paths.length); + expect(result.rawNewOccurrences).toBe(paths.length); + expect(result.requests).toEqual([oldObject, newObject]); + expect(result.objectBytes).toBeLessThan(newPayload.length * 3); + expect(result.digest).not.toBe(singleDigest); + expect(result.digest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + }); + + test("preserves trusted Git selectors while forcing local-only controls", () => { + const { repository } = createRepository(); + const configParameters = "'safe.directory'='/synthetic/repository'"; + const result = runPythonJson>( + [ + "import json, subprocess", + "from pathlib import Path", + "import workbench_target as target", + "captured = {}", + "def fake_run(command, **kwargs):", + " captured.update(kwargs['env'])", + " captured['COMMAND'] = '\\0'.join(command)", + " return subprocess.CompletedProcess(command, 0, b'', b'')", + "target.subprocess.run = fake_run", + "target.git_command(Path(sys.argv[2]), 'status', text=False, local_objects_only=True)", + "names = ('GIT_DIR', 'GIT_WORK_TREE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM', 'GIT_ALLOW_PROTOCOL', 'GIT_NO_LAZY_FETCH', 'GIT_LITERAL_PATHSPECS', 'COMMAND')", + "print(json.dumps({name: captured.get(name) for name in names}))", + ], + [repository], + undefined, + { + GIT_ALLOW_PROTOCOL: "ext", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_GLOBAL: "/synthetic/global-config", + GIT_CONFIG_KEY_0: "safe.directory", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_PARAMETERS: configParameters, + GIT_CONFIG_SYSTEM: "/synthetic/system-config", + GIT_CONFIG_VALUE_0: repository, + GIT_DIR: "/synthetic/git-dir", + GIT_LITERAL_PATHSPECS: "0", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_REPLACE_REF_BASE: "refs/synthetic-replacements/", + GIT_WORK_TREE: "/synthetic/work-tree", + }, + ); + + expect(result).toMatchObject({ + GIT_DIR: null, + GIT_WORK_TREE: null, + GIT_NO_REPLACE_OBJECTS: "1", + GIT_REPLACE_REF_BASE: "refs/synthetic-replacements/", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "safe.directory", + GIT_CONFIG_VALUE_0: repository, + GIT_CONFIG_PARAMETERS: configParameters, + GIT_CONFIG_SYSTEM: "/synthetic/system-config", + GIT_CONFIG_GLOBAL: "/synthetic/global-config", + GIT_CONFIG_NOSYSTEM: "1", + GIT_ALLOW_PROTOCOL: "", + GIT_NO_LAZY_FETCH: "1", + GIT_LITERAL_PATHSPECS: "1", + }); + expect(result["COMMAND"]?.split("\0")).toContain("core.fsmonitor=false"); + }); + + test("matches trusted Git replacement views", () => { + const { base, head, repository, state } = createRepository(); + const selectedDigest = committedDigest(repository, state, base, head); + const selectedIdentity = committedObjectIdentity(repository, base, head); + const originalBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "replacement view\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + const replacementBlob = replacement.stdout.trim(); + git( + repository, + "update-ref", + `refs/synthetic-replacements/${originalBlob}`, + replacementBlob, + ); + + const replacementEnvironment = { + GIT_REPLACE_REF_BASE: "refs/synthetic-replacements/", + }; + expect( + committedDigest(repository, state, base, head, replacementEnvironment), + ).not.toBe(selectedDigest); + expect( + committedObjectIdentity(repository, base, head, replacementEnvironment), + ).not.toBe(selectedIdentity); + git(repository, "replace", "-f", originalBlob, replacementBlob); + for (const value of ["1", ""]) { + expect( + committedDigest(repository, state, base, head, { + GIT_NO_REPLACE_OBJECTS: value, + }), + ).toBe(selectedDigest); + } + }); + + test("binds raw-prefix replacement refs in SHA-1 and supported SHA-256 repositories", () => { + const { root, state } = createRepository(); + const environment = { GIT_REPLACE_REF_BASE: "refs/repl-" }; + + for (const objectFormat of ["sha1", "sha256"] as const) { + const repository = join(root, `replacement-${objectFormat}`); + const initialized = spawnSync( + "git", + ["init", "--quiet", `--object-format=${objectFormat}`, repository], + { encoding: "utf8" }, + ); + if (objectFormat === "sha256" && initialized.status !== 0) continue; + expect(initialized.status, initialized.stderr).toBe(0); + writeFileSync(join(repository, "fixture.txt"), "base\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "base"); + const base = git(repository, "rev-parse", "HEAD"); + writeFileSync(join(repository, "fixture.txt"), "head\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "head"); + const head = git(repository, "rev-parse", "HEAD"); + const originalBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const rawPrefixProbe = spawnSync( + "git", + ["-C", repository, "cat-file", "blob", originalBlob], + { + encoding: "utf8", + env: { ...process.env, ...environment }, + }, + ); + if (rawPrefixProbe.status !== 0) { + expect(rawPrefixProbe.stdout).toBe(""); + expect(rawPrefixProbe.stderr).toMatch( + /BUG: refs\.c:\d+: ref pattern must end in a trailing slash when trimming/u, + ); + continue; + } + expect(rawPrefixProbe.stdout).toBe("head\n"); + const before = [ + committedDigest(repository, state, base, head, environment), + committedObjectIdentity(repository, base, head, environment), + ]; + const firstReplacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "first raw-prefix replacement\n" }, + ); + const finalReplacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "final raw-prefix replacement\n" }, + ); + expect(firstReplacement.status, firstReplacement.stderr).toBe(0); + expect(finalReplacement.status, finalReplacement.stderr).toBe(0); + const firstBlob = firstReplacement.stdout.trim(); + const finalBlob = finalReplacement.stdout.trim(); + git(repository, "update-ref", `refs/repl-${originalBlob}`, firstBlob); + git(repository, "update-ref", `refs/repl-${firstBlob}`, finalBlob); + const selected = spawnSync( + "git", + ["-C", repository, "cat-file", "blob", originalBlob], + { + encoding: "utf8", + env: { ...process.env, ...environment }, + }, + ); + expect(selected.status, selected.stderr).toBe(0); + expect(selected.stdout).toBe("final raw-prefix replacement\n"); + + expect( + committedDigest(repository, state, base, head, environment), + ).not.toBe(before[0]); + expect( + committedObjectIdentity(repository, base, head, environment), + ).not.toBe(before[1]); + } + }); + + test("binds nested uppercase replacement refs in SHA-1 and supported SHA-256 repositories", () => { + const { root, state } = createRepository(); + const replacementRefBase = "refs/synthetic-replacements/"; + const environment = { GIT_REPLACE_REF_BASE: replacementRefBase }; + + for (const objectFormat of ["sha1", "sha256"] as const) { + const repository = join(root, `nested-replacement-${objectFormat}`); + const initialized = spawnSync( + "git", + ["init", "--quiet", `--object-format=${objectFormat}`, repository], + { encoding: "utf8" }, + ); + if (objectFormat === "sha256" && initialized.status !== 0) continue; + expect(initialized.status, initialized.stderr).toBe(0); + writeFileSync(join(repository, "fixture.txt"), "base\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "base"); + const base = git(repository, "rev-parse", "HEAD"); + writeFileSync(join(repository, "fixture.txt"), "head\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "head"); + const head = git(repository, "rev-parse", "HEAD"); + const originalBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const before = [ + committedDigest(repository, state, base, head, environment), + committedObjectIdentity(repository, base, head, environment), + ]; + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "nested uppercase replacement\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + const replacementBlob = replacement.stdout.trim(); + git( + repository, + "update-ref", + `${replacementRefBase}nested/${originalBlob.toUpperCase()}`, + replacementBlob, + ); + const selected = spawnSync( + "git", + ["-C", repository, "cat-file", "blob", originalBlob], + { + encoding: "utf8", + env: { ...process.env, ...environment }, + }, + ); + expect(selected.status, selected.stderr).toBe(0); + expect(selected.stdout).toBe("nested uppercase replacement\n"); + + expect( + committedDigest(repository, state, base, head, environment), + ).not.toBe(before[0]); + expect( + committedObjectIdentity(repository, base, head, environment), + ).not.toBe(before[1]); + } + }); + + test("freezes packed replacement refs without Windows-reserved loose paths", () => { + const { root, state } = createRepository(); + const replacementRefBase = "refs/replace/AUX/"; + const environment = { GIT_REPLACE_REF_BASE: replacementRefBase }; + + for (const objectFormat of ["sha1", "sha256"] as const) { + const repository = join(root, `packed-replacement-${objectFormat}`); + const initialized = spawnSync( + "git", + ["init", "--quiet", `--object-format=${objectFormat}`, repository], + { encoding: "utf8" }, + ); + if (objectFormat === "sha256" && initialized.status !== 0) continue; + expect(initialized.status, initialized.stderr).toBe(0); + writeFileSync(join(repository, "fixture.txt"), "base\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "base"); + const base = git(repository, "rev-parse", "HEAD"); + writeFileSync(join(repository, "fixture.txt"), "head\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "head"); + const head = git(repository, "rev-parse", "HEAD"); + const originalBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const originalDigest = committedDigest( + repository, + state, + base, + head, + environment, + ); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "packed replacement\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + const replacementBlob = replacement.stdout.trim(); + const replacementRef = `${replacementRefBase}${originalBlob}`; + const gitDirectory = git(repository, "rev-parse", "--absolute-git-dir"); + const packedRefs = join(gitDirectory, "packed-refs"); + expect(existsSync(packedRefs)).toBeFalse(); + writeFileSync( + packedRefs, + `# pack-refs with: sorted\n${replacementBlob} ${replacementRef}\n`, + ); + expect( + existsSync(join(gitDirectory, ...replacementRef.split("/"))), + ).toBeFalse(); + expect(readFileSync(packedRefs, "utf8")).toContain( + `${replacementBlob} ${replacementRef}\n`, + ); + + const selected = spawnSync( + "git", + ["-C", repository, "cat-file", "blob", originalBlob], + { + encoding: "utf8", + env: { ...process.env, ...environment }, + }, + ); + expect(selected.status, selected.stderr).toBe(0); + expect(selected.stdout).toBe("packed replacement\n"); + const expectedDigest = committedDigest( + repository, + state, + base, + head, + environment, + ); + expect(expectedDigest).not.toBe(originalDigest); + + const result = runPythonJson<{ digest: string }>( + [ + "import json", + "from pathlib import Path", + "import workbench_target as target", + "reserved = {'CON', 'PRN', 'AUX', 'NUL', *(f'COM{i}' for i in range(1, 10)), *(f'LPT{i}' for i in range(1, 10))}", + "original_mkdir = Path.mkdir", + "original_write_bytes = Path.write_bytes", + "def reject_reserved(path):", + " for part in path.parts:", + " if part.rstrip(' .').split('.', 1)[0].upper() in reserved:", + " raise OSError('synthetic Windows-reserved path')", + "def windows_mkdir(path, *args, **kwargs):", + " reject_reserved(path)", + " return original_mkdir(path, *args, **kwargs)", + "def windows_write_bytes(path, data):", + " reject_reserved(path)", + " return original_write_bytes(path, data)", + "Path.mkdir = windows_mkdir", + "Path.write_bytes = windows_write_bytes", + "try:", + " digest = target.committed_diff_content_digest(Path(sys.argv[2]), sys.argv[3], sys.argv[4])", + "finally:", + " Path.mkdir = original_mkdir", + " Path.write_bytes = original_write_bytes", + "print(json.dumps({'digest': digest}))", + ], + [repository, base, head], + state, + environment, + ); + expect(result.digest).toBe(expectedDigest); + } + }); + + test.each([ + ["a repository-local disable", false, false], + ["a repository-local enable over a global disable", true, true], + ] as const)( + "preserves %s of replacement refs in the frozen view", + (_label, localReplacementSetting, expectsReplacement) => { + const { base, head, repository, root, state } = createRepository(); + const originalDigest = committedDigest(repository, state, base, head); + const originalBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "replacement policy\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + git(repository, "replace", "-f", originalBlob, replacement.stdout.trim()); + const replacementDigest = committedDigest(repository, state, base, head); + expect(replacementDigest).not.toBe(originalDigest); + + git( + repository, + "config", + "core.useReplaceRefs", + String(localReplacementSetting), + ); + const globalConfig = join(root, "global.gitconfig"); + writeFileSync(globalConfig, "[core]\n\tuseReplaceRefs = false\n"); + const environment = expectsReplacement + ? { GIT_CONFIG_GLOBAL: globalConfig } + : {}; + + expect(committedDigest(repository, state, base, head, environment)).toBe( + expectsReplacement ? replacementDigest : originalDigest, + ); + }, + ); + + test("opens frozen committed views explicitly", () => { + const { base, head, repository, root, state } = createRepository(); + const globalConfig = join(root, "global.gitconfig"); + writeFileSync(globalConfig, "[safe]\n\tbareRepository = explicit\n"); + + expect( + committedDigest(repository, state, base, head, { + GIT_CONFIG_GLOBAL: globalConfig, + GIT_CONFIG_NOSYSTEM: "1", + }), + ).toMatch(/^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u); + }); + + test.skipIf(process.platform === "win32")( + "supports line-break-bearing quoted repository paths", + () => { + for (const separator of ["\n", "\r"]) { + const { base, head, repository, root, state } = createRepository(); + const expected = committedDigest(repository, state, base, head); + const relocated = join(root, `repository${separator}"quoted`); + renameSync(repository, relocated); + + expect(committedDigest(relocated, state, base, head)).toBe(expected); + } + }, + ); + + test("preserves the source repository's supported alternates depth", () => { + const { base, head, repository, root, state } = createRepository(); + const objectDirectory = git( + repository, + "rev-parse", + "--path-format=absolute", + "--git-path", + "objects", + ); + const alternateDirectories = Array.from({ length: 6 }, (_, index) => + join(root, `alternate-${index}`), + ); + for (const directory of alternateDirectories) { + mkdirSync(join(directory, "info"), { recursive: true }); + mkdirSync(join(directory, "pack")); + } + const deepest = alternateDirectories.at(-1)!; + for (const entry of readdirSync(objectDirectory)) { + if (entry !== "info" && entry !== "pack") { + renameSync(join(objectDirectory, entry), join(deepest, entry)); + } + } + const chain = [objectDirectory, ...alternateDirectories]; + for (let index = 0; index < chain.length - 1; index += 1) { + writeFileSync( + join(chain[index]!, "info", "alternates"), + `${chain[index + 1]}\n`, + ); + } + + git(repository, "cat-file", "-e", `${head}^{commit}`); + expect(committedDigest(repository, state, base, head)).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + }); + + test("binds a replacement chain while the source view toggles at every boundary", () => { + const { base, head, repository, state } = createRepository(); + const originalDigest = committedDigest(repository, state, base, head); + const originalIdentity = committedObjectIdentity(repository, base, head); + const originalBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const firstReplacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "first replacement\n" }, + ); + const finalReplacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "final replacement\n" }, + ); + expect(firstReplacement.status, firstReplacement.stderr).toBe(0); + expect(finalReplacement.status, finalReplacement.stderr).toBe(0); + const firstBlob = firstReplacement.stdout.trim(); + const finalBlob = finalReplacement.stdout.trim(); + git(repository, "replace", "-f", originalBlob, firstBlob); + git(repository, "replace", "-f", firstBlob, finalBlob); + + const result = runPythonJson<{ + expected: [string, string]; + final: [string, string]; + identityCalls: number; + pair: [string, string]; + privateView: boolean; + residue: string[]; + viewCount: number; + }>( + [ + "import json, os, stat, subprocess", + "from pathlib import Path", + "import workbench_target as target", + "repository, state = Path(sys.argv[2]).resolve(), Path(sys.argv[3]).resolve()", + "base, head, original_blob, first_blob, final_blob = sys.argv[4:9]", + "original_ref = f'refs/replace/{original_blob}'", + "first_ref = f'refs/replace/{first_blob}'", + "def update(ref, object_id=None):", + " command = ['git', '-C', str(repository), 'update-ref']", + " command.extend(['-d', ref] if object_id is None else [ref, object_id])", + " subprocess.run(command, check=True, stdout=subprocess.DEVNULL)", + "def set_replacements(view):", + " update(original_ref)", + " update(first_ref)", + " if view in {'first', 'chain'}:", + " update(original_ref, first_blob)", + " if view == 'chain':", + " update(first_ref, final_blob)", + "expected = target.committed_diff_content_snapshot(repository, base, head)", + "original_create = target._create_committed_diff_view", + "original_digest = target._committed_diff_content_digest", + "original_identity = target._committed_diff_object_identity", + "views, modes = [], []", + "identity_calls = 0", + "def tracked_create(source, view):", + " object_directory = original_create(source, view)", + " views.append(view)", + " modes.append(stat.S_IMODE(view.parent.stat().st_mode))", + " set_replacements('none')", + " return object_directory", + "def tracked_identity(*args, **kwargs):", + " global identity_calls", + " set_replacements('first' if identity_calls == 0 else 'none')", + " identity_calls += 1", + " result = original_identity(*args, **kwargs)", + " set_replacements('none' if identity_calls == 1 else 'first')", + " return result", + "def tracked_digest(*args, **kwargs):", + " set_replacements('none')", + " result = original_digest(*args, **kwargs)", + " set_replacements('first')", + " return result", + "target._create_committed_diff_view = tracked_create", + "target._committed_diff_content_digest = tracked_digest", + "target._committed_diff_object_identity = tracked_identity", + "try:", + " pair = target.committed_diff_content_snapshot(repository, base, head)", + "finally:", + " target._create_committed_diff_view = original_create", + " target._committed_diff_content_digest = original_digest", + " target._committed_diff_object_identity = original_identity", + " set_replacements('chain')", + "final = target.committed_diff_content_snapshot(repository, base, head)", + "def outside_source(path):", + " try:", + " path.resolve().relative_to(repository)", + " except ValueError:", + " return True", + " return False", + "private_view = all(outside_source(view) for view in views) and (os.name == 'nt' or modes == [0o700])", + "print(json.dumps({'expected': expected, 'final': final, 'identityCalls': identity_calls, 'pair': pair, 'privateView': private_view, 'residue': sorted(entry.name for entry in state.iterdir()), 'viewCount': len(views)}))", + ], + [repository, state, base, head, originalBlob, firstBlob, finalBlob], + state, + ); + + expect(result.pair).toEqual(result.expected); + expect(result.final).toEqual(result.expected); + expect(result.expected[0]).not.toBe(originalDigest); + expect(result.expected[1]).not.toBe(originalIdentity); + expect(result).toMatchObject({ + identityCalls: 2, + privateView: true, + residue: [], + viewCount: 1, + }); + }); + + test("cleans private views without mutating target Git metadata", () => { + const { base, head, repository, state } = createRepository(); + const result = runPythonJson<{ + error: string; + privateViews: boolean; + residue: string[]; + sourceUnchanged: boolean; + viewsRemoved: boolean; + }>( + [ + "import hashlib, json, subprocess", + "from pathlib import Path", + "import workbench_target as target", + "repository, state = Path(sys.argv[2]).resolve(), Path(sys.argv[3]).resolve()", + "base, head = sys.argv[4:6]", + "git_dir = Path(subprocess.check_output(['git', '-C', str(repository), 'rev-parse', '--absolute-git-dir'], text=True).strip()).resolve()", + "def fingerprint():", + " result = {}", + " for name in ('HEAD', 'config', 'index', 'packed-refs', 'shallow'):", + " path = git_dir / name", + " result[name] = None if not path.is_file() else hashlib.sha256(path.read_bytes()).hexdigest()", + " for root_name in ('refs', 'logs', 'objects'):", + " root = git_dir / root_name", + " if root.is_dir():", + " for path in sorted(entry for entry in root.rglob('*') if entry.is_file()):", + " result[path.relative_to(git_dir).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest()", + " return result", + "before = fingerprint()", + "original_create = target._create_committed_diff_view", + "views = []", + "def tracked_create(source, view):", + " views.append(view)", + " return original_create(source, view)", + "target._create_committed_diff_view = tracked_create", + "target.committed_diff_content_snapshot(repository, base, head)", + "after_success = fingerprint()", + "original_digest = target._committed_diff_content_digest", + "def unavailable(*args, **kwargs):", + " raise SystemExit('synthetic snapshot failure')", + "target._committed_diff_content_digest = unavailable", + "try:", + " target.committed_diff_content_snapshot(repository, base, head)", + "except SystemExit as exc:", + " error = str(exc)", + "finally:", + " target._create_committed_diff_view = original_create", + " target._committed_diff_content_digest = original_digest", + "after_failure = fingerprint()", + "def outside_source(path):", + " try:", + " path.resolve().relative_to(repository)", + " except ValueError:", + " return True", + " return False", + "print(json.dumps({'error': error, 'privateViews': len(views) == 2 and all(outside_source(view) for view in views), 'residue': sorted(entry.name for entry in state.iterdir()), 'sourceUnchanged': before == after_success == after_failure, 'viewsRemoved': all(not view.exists() for view in views)}))", + ], + [repository, state, base, head], + state, + ); + + expect(result).toEqual({ + error: "synthetic snapshot failure", + privateViews: true, + residue: [], + sourceUnchanged: true, + viewsRemoved: true, + }); + }); + + test.each([ + ["a non-HEAD working-tree base", "working_tree"], + ["equal resolved refs", "range"], + ] as const)( + "accepts a CLI registration with %s behind a writer lock", + (_label, selection) => { + const { base, head, repository, root, state } = createRepository(); + const requestedBase = selection === "working_tree" ? "HEAD~1" : "HEAD~0"; + const expectedBase = selection === "working_tree" ? base : head; + const scanDirectory = join(root, "compatible-cli-scan"); + mkdirSync(scanDirectory, { mode: 0o700 }); + + const result = runPythonJson<{ + beginAttempted: boolean; + counts: { scans: number; workspaces: number }; + error: string | null; + inTransaction: boolean; + persisted: { + baseRevision: string; + contentDigest: string; + headRevision: string; + kind: string; + } | null; + started: boolean; + }>( + [ + "import argparse, json, threading", + "import workbench_db as workbench", + "selection, repository, scan_directory, selected_base, selected_head = sys.argv[2:7]", + "recipe_kind = 'working_tree' if selection == 'working_tree' else 'refs'", + "recipe = {'config': {}, 'mode': 'standard', 'repository': repository, 'target': {'kind': recipe_kind, 'paths': [], 'base': selected_base, 'head': selected_head}}", + "arguments = argparse.Namespace(repository=repository, scan_dir=scan_directory, registration_json_stdin=False, recipe_json_stdin=False, recipe_json=json.dumps(recipe), archive_existing=False, archived_scan_dir=None, parent_scan_id=None)", + "begin_attempted = threading.Event()", + "connection_ready = threading.Event()", + "start_worker = threading.Event()", + "outcome = {'error': None, 'inTransaction': None, 'started': False}", + "class TrackedConnection:", + " def __init__(self, delegate):", + " self.delegate = delegate", + " def execute(self, sql, *args):", + " if sql.strip().upper() == 'BEGIN IMMEDIATE':", + " begin_attempted.set()", + " return self.delegate.execute(sql, *args)", + " def __getattr__(self, name):", + " return getattr(self.delegate, name)", + "def worker():", + " with workbench.connect() as connection:", + " connection_ready.set()", + " if not start_worker.wait(5):", + " outcome['error'] = 'worker start timed out'", + " return", + " try:", + " workbench.register_cli_scan(TrackedConnection(connection), arguments)", + " except SystemExit as error:", + " outcome['error'] = str(error)", + " else:", + " outcome['started'] = True", + " outcome['inTransaction'] = connection.in_transaction", + "thread = threading.Thread(target=worker)", + "thread.start()", + "if not connection_ready.wait(5):", + " raise RuntimeError('worker connection timed out')", + "with workbench.connect() as blocker:", + " blocker.execute('BEGIN IMMEDIATE')", + " start_worker.set()", + " if not begin_attempted.wait(5):", + " raise RuntimeError('registration did not reach writer lock')", + " blocker.commit()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('registration worker did not finish')", + "with workbench.connect() as connection:", + " counts = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + " row = connection.execute('SELECT diff_target_kind, diff_base_revision, diff_head_revision, diff_content_digest FROM workspaces').fetchone()", + " persisted = None if row is None else {'kind': row['diff_target_kind'], 'baseRevision': row['diff_base_revision'], 'headRevision': row['diff_head_revision'], 'contentDigest': row['diff_content_digest']}", + "print(json.dumps({'beginAttempted': begin_attempted.is_set(), 'counts': counts, 'persisted': persisted, **outcome}))", + ], + [selection, repository, scanDirectory, requestedBase, "HEAD"], + state, + ); + + expect(result.beginAttempted).toBe(true); + expect(result.counts).toEqual({ scans: 1, workspaces: 1 }); + expect(result.error).toBeNull(); + expect(result.inTransaction).toBe(false); + expect(result.persisted).toEqual({ + baseRevision: expectedBase, + contentDigest: expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + headRevision: head, + kind: selection, + }); + expect(result.started).toBe(true); + }, + ); + + test.each([ + ["submitted", "progress"], + ["prompt", "progress"], + ["cli", "progress"], + ["prompt", "completion"], + ] as const)( + "does not hold the SQLite writer lock during a slow %s committed snapshot while an unrelated %s is written", + (operation, writeOperation) => { + const { base, head, repository, root, state } = createRepository(); + const result = runPythonJson<{ + identityTransactions: boolean[]; + scanCount: number; + unrelatedStatus: string; + writeError: string | null; + writeSucceeded: boolean; + snapshotCalls: number; + snapshotTransactions: boolean[]; + startError: string | null; + started: boolean; + }>( + [ + "import argparse, json, os, shutil, threading, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "operation, write_operation, repository, base, head, root, plugin_root = sys.argv[2:9]", + "root_path = Path(root)", + "unrelated_scan_root = root_path / 'unrelated-scans'", + "unrelated_scan_root.mkdir(mode=0o700)", + "unrelated_args = argparse.Namespace(thread_id=f'unrelated-{operation}', target_path=repository, scope='.', mode='standard', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind=None, diff_base_revision=None, diff_head_revision=None, diff_content_digest=None, scan_root=str(unrelated_scan_root), model=None, reasoning_effort=None)", + "with workbench.connect() as connection:", + " unrelated = workbench.start_prompt_only_scan(connection, unrelated_args)", + " unrelated_scan_id = unrelated['scan']['scanId']", + "completion_args = argparse.Namespace(scan_id=unrelated_scan_id, claim_token=None, cost_json=None, thread_id=None)", + "if write_operation == 'completion':", + " with workbench.connect() as connection:", + " scan = workbench.require_scan(connection, unrelated_scan_id)", + " scan_dir = Path(scan['scan_dir'])", + " shutil.copytree(Path(plugin_root) / 'examples' / 'completed-scan', scan_dir, dirs_exist_ok=True)", + " if os.name != 'nt':", + " scan_dir.parent.chmod(0o700)", + " scan_dir.chmod(0o700)", + " manifest_path = scan_dir / 'scan-manifest.json'", + " manifest = json.loads(manifest_path.read_text())", + " manifest['scan']['id'] = unrelated_scan_id", + " manifest['scan']['target']['kind'] = workbench.expected_target_kinds(scan)[0]", + " manifest['scan'].pop('sealedAt', None)", + " manifest['scan'].pop('artifacts', None)", + " manifest_path.write_text(json.dumps(manifest))", + " for name in ('findings.json', 'coverage.json'):", + " path = scan_dir / name", + " document = json.loads(path.read_text())", + " document['scanId'] = unrelated_scan_id", + " path.write_text(json.dumps(document))", + " workbench.complete_scan(connection, completion_args, prepare_only=True)", + " prepared_completed_at = json.loads(manifest_path.read_text())['scan']['completedAt']", + " workbench.now = lambda: prepared_completed_at", + "workspace_id = str(uuid.uuid4())", + "if operation == 'submitted':", + " create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=None, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='standard', diff_target_kind=None, diff_base_revision=None, diff_head_revision=None, diff_content_digest=None)", + " save_args = argparse.Namespace(workspace_id=workspace_id, target_path=repository, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None)", + " with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " workbench.save_workspace(connection, save_args)", + "prompt_args = argparse.Namespace(thread_id=f'slow-{operation}', target_path=repository, scope='.', mode='diff', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None, scan_root=str(root_path / 'prompt-scans'), model=None, reasoning_effort=None)", + "(root_path / 'prompt-scans').mkdir(mode=0o700)", + "scan_directory = root_path / 'cli-scan'", + "scan_directory.mkdir(mode=0o700)", + "recipe = {'config': {}, 'mode': 'standard', 'repository': repository, 'target': {'kind': 'refs', 'paths': [], 'base': base, 'head': head}}", + "cli_args = argparse.Namespace(repository=repository, scan_dir=str(scan_directory), registration_json_stdin=False, recipe_json_stdin=False, recipe_json=json.dumps(recipe), archive_existing=False, archived_scan_dir=None, parent_scan_id=None)", + "submitted_args = argparse.Namespace(workspace_id=workspace_id, scan_root=str(root_path / 'submitted-scans'), model=None, reasoning_effort=None)", + "original_snapshot = workbench.committed_diff_content_snapshot", + "original_identity = workbench.committed_diff_object_identity", + "snapshot_started = threading.Event()", + "release_snapshot = threading.Event()", + "worker_connection = None", + "identity_transactions = []", + "snapshot_calls = 0", + "snapshot_transactions = []", + "def slow_snapshot(*args):", + " global snapshot_calls", + " snapshot_calls += 1", + " snapshot_transactions.append(bool(worker_connection and worker_connection.in_transaction))", + " snapshot = original_snapshot(*args)", + " if snapshot_calls == 2:", + " snapshot_started.set()", + " if not release_snapshot.wait(5):", + " raise RuntimeError('slow snapshot release timed out')", + " return snapshot", + "def counted_identity(*args):", + " identity_transactions.append(bool(worker_connection and worker_connection.in_transaction))", + " return original_identity(*args)", + "workbench.committed_diff_content_snapshot = slow_snapshot", + "workbench.committed_diff_object_identity = counted_identity", + "outcome = {'startError': None, 'started': False}", + "def worker():", + " global worker_connection", + " with workbench.connect() as connection:", + " worker_connection = connection", + " try:", + " if operation == 'submitted':", + " workbench.start_scan(connection, submitted_args)", + " elif operation == 'prompt':", + " workbench.start_prompt_only_scan(connection, prompt_args)", + " else:", + " workbench.register_cli_scan(connection, cli_args)", + " except BaseException as error:", + " outcome['startError'] = f'{type(error).__name__}: {error}'", + " else:", + " outcome['started'] = True", + "progress_args = argparse.Namespace(scan_id=unrelated_scan_id, model=None, reasoning_effort=None, preflight_issues_json_stdin=False, preflight_issues_json=None, coordinator_generation=None, claim_token=None, deep_review_pass=None, phase=None, phase_items_total=None, phase_items_completed=None, phase_progress_unit=None, review_items_total=None, review_items_completed=None, reportable_findings_count=None)", + "thread = threading.Thread(target=worker)", + "with workbench.connect() as write_connection:", + " write_connection.execute('PRAGMA busy_timeout = 200')", + " thread.start()", + " if not snapshot_started.wait(5):", + " raise RuntimeError('slow snapshot did not start')", + " try:", + " if write_operation == 'progress':", + " workbench.progress.update_progress(write_connection, progress_args, now=workbench.now, require_scan=workbench.require_scan, scan_context=workbench.scan_context)", + " else:", + " workbench.complete_scan(write_connection, completion_args, prepare_only=False)", + " except BaseException as error:", + " write_error = f'{type(error).__name__}: {error}'", + " write_succeeded = False", + " else:", + " write_error = None", + " write_succeeded = True", + "release_snapshot.set()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('start worker did not finish')", + "with workbench.connect() as connection:", + " scan_count = connection.execute('SELECT COUNT(*) FROM scans').fetchone()[0]", + " unrelated_status = workbench.require_scan(connection, unrelated_scan_id)['status']", + "print(json.dumps({'identityTransactions': identity_transactions, 'scanCount': scan_count, 'snapshotCalls': snapshot_calls, 'snapshotTransactions': snapshot_transactions, 'unrelatedStatus': unrelated_status, 'writeError': write_error, 'writeSucceeded': write_succeeded, **outcome}))", + ], + [operation, writeOperation, repository, base, head, root, PLUGIN_ROOT], + state, + ); + + expect(result).toMatchObject({ + identityTransactions: [true], + scanCount: 2, + snapshotCalls: 2, + snapshotTransactions: [false, false], + startError: null, + started: true, + unrelatedStatus: + writeOperation === "completion" ? "complete" : "running", + writeError: null, + writeSucceeded: true, + }); + }, + ); + + test.each(["working_tree", "commit"] as const)( + "rejoins an active submitted %s scan before revalidating an unavailable target", + (kind) => { + const { base, head, repository, root, state } = createRepository(); + const result = runPythonJson<{ + activeMatchesCreated: boolean; + beginAttempted: boolean; + error: string | null; + inTransaction: boolean; + scanCount: number; + started: boolean; + targetExists: boolean; + }>( + [ + "import argparse, json, threading, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "kind, repository, base, head, root = sys.argv[2:7]", + "workspace_id = str(uuid.uuid4())", + "selected_base = head if kind == 'working_tree' else base", + "create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind=kind, diff_base_revision=selected_base, diff_head_revision=head, diff_content_digest=None)", + "start_args = argparse.Namespace(workspace_id=workspace_id, scan_root=str(Path(root) / 'submitted-scans'), model=None, reasoning_effort=None)", + "with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " workbench.save_workspace(connection, create_args)", + "begin_attempted = threading.Event()", + "connection_ready = threading.Event()", + "start_worker = threading.Event()", + "outcome = {'error': None, 'inTransaction': None, 'started': False}", + "class TrackedConnection:", + " def __init__(self, delegate):", + " self.delegate = delegate", + " def execute(self, sql, *args):", + " if sql.strip().upper() == 'BEGIN IMMEDIATE':", + " begin_attempted.set()", + " return self.delegate.execute(sql, *args)", + " def __getattr__(self, name):", + " return getattr(self.delegate, name)", + "def worker():", + " with workbench.connect() as delegate:", + " connection_ready.set()", + " if not start_worker.wait(5):", + " outcome['error'] = 'worker start timed out'", + " return", + " try:", + " workbench.start_scan(TrackedConnection(delegate), start_args)", + " except BaseException as error:", + " outcome['error'] = f'{type(error).__name__}: {error}'", + " else:", + " outcome['started'] = True", + " outcome['inTransaction'] = delegate.in_transaction", + "thread = threading.Thread(target=worker)", + "thread.start()", + "if not connection_ready.wait(5):", + " raise RuntimeError('worker connection timed out')", + "with workbench.connect() as blocker:", + " blocker.execute('BEGIN IMMEDIATE')", + " start_worker.set()", + " if not begin_attempted.wait(5):", + " raise RuntimeError('scan did not reach writer lock')", + " workbench.start_scan(blocker, start_args)", + " created_scan_id = blocker.execute('SELECT active_scan_id FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()['active_scan_id']", + " Path(repository).rename(Path(root) / 'parked-repository')", + " blocker.commit()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('scan worker did not finish')", + "with workbench.connect() as connection:", + " active_scan_id = connection.execute('SELECT active_scan_id FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()['active_scan_id']", + " scan_count = connection.execute('SELECT COUNT(*) FROM scans WHERE workspace_id = ?', (workspace_id,)).fetchone()[0]", + "print(json.dumps({'activeMatchesCreated': active_scan_id == created_scan_id, 'beginAttempted': begin_attempted.is_set(), 'scanCount': scan_count, 'targetExists': Path(repository).exists(), **outcome}))", + ], + [kind, repository, base, head, root], + state, + ); + + expect(result).toEqual({ + activeMatchesCreated: true, + beginAttempted: true, + error: null, + inTransaction: false, + scanCount: 1, + started: true, + targetExists: false, + }); + }, + ); + + test.each(["commit", "range"] as const)( + "rejoins an active submitted %s scan when external inspection fails after another start", + (kind) => { + const { base, head, repository, root, state } = createRepository(); + const result = runPythonJson<{ + activeMatchesCreated: boolean; + error: string | null; + inTransaction: boolean; + scanCount: number; + started: boolean; + targetExists: boolean; + workerSnapshotCalls: number; + }>( + [ + "import argparse, json, threading, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "kind, repository, base, head, root = sys.argv[2:7]", + "workspace_id = str(uuid.uuid4())", + "create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind=kind, diff_base_revision=base, diff_head_revision=head, diff_content_digest=None)", + "start_args = argparse.Namespace(workspace_id=workspace_id, scan_root=str(Path(root) / 'submitted-scans'), model=None, reasoning_effort=None)", + "with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " workbench.save_workspace(connection, create_args)", + "inspection_started = threading.Event()", + "release_inspection = threading.Event()", + "worker_identifier = None", + "worker_snapshot_calls = 0", + "original_snapshot = workbench.committed_diff_content_snapshot", + "def delayed_snapshot(*args):", + " global worker_snapshot_calls", + " if threading.get_ident() == worker_identifier:", + " worker_snapshot_calls += 1", + " if worker_snapshot_calls == 2:", + " inspection_started.set()", + " if not release_inspection.wait(5):", + " raise RuntimeError('inspection release timed out')", + " return original_snapshot(*args)", + "workbench.committed_diff_content_snapshot = delayed_snapshot", + "outcome = {'error': None, 'inTransaction': None, 'started': False}", + "def worker():", + " global worker_identifier", + " worker_identifier = threading.get_ident()", + " with workbench.connect() as connection:", + " try:", + " workbench.start_scan(connection, start_args)", + " except BaseException as error:", + " outcome['error'] = f'{type(error).__name__}: {error}'", + " else:", + " outcome['started'] = True", + " outcome['inTransaction'] = connection.in_transaction", + "thread = threading.Thread(target=worker)", + "thread.start()", + "if not inspection_started.wait(5):", + " raise RuntimeError('worker did not begin its second snapshot')", + "with workbench.connect() as creator:", + " workbench.start_scan(creator, start_args)", + " created_scan_id = creator.execute('SELECT active_scan_id FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()['active_scan_id']", + " Path(repository).rename(Path(root) / 'parked-repository')", + "release_inspection.set()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('scan worker did not finish')", + "with workbench.connect() as connection:", + " active_scan_id = connection.execute('SELECT active_scan_id FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()['active_scan_id']", + " scan_count = connection.execute('SELECT COUNT(*) FROM scans WHERE workspace_id = ?', (workspace_id,)).fetchone()[0]", + "print(json.dumps({'activeMatchesCreated': active_scan_id == created_scan_id, 'scanCount': scan_count, 'targetExists': Path(repository).exists(), 'workerSnapshotCalls': worker_snapshot_calls, **outcome}))", + ], + [kind, repository, base, head, root], + state, + ); + + expect(result).toEqual({ + activeMatchesCreated: true, + error: null, + inTransaction: false, + scanCount: 1, + started: true, + targetExists: false, + workerSnapshotCalls: 2, + }); + }, + ); + + test("rejoins an active submitted commit scan after waiting on post-inspection contention", () => { + const { base, head, repository, root, state } = createRepository(); + const result = runPythonJson<{ + activeMatchesCreated: boolean; + beginAttempts: number; + error: string | null; + inTransaction: boolean; + restoreTimeoutReached: boolean; + scanCount: number; + started: boolean; + targetExists: boolean; + zeroTimeoutReached: boolean; + }>( + [ + "import argparse, json, threading, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "repository, base, head, root = sys.argv[2:6]", + "workspace_id = str(uuid.uuid4())", + "create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='commit', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None)", + "start_args = argparse.Namespace(workspace_id=workspace_id, scan_root=str(Path(root) / 'submitted-scans'), model=None, reasoning_effort=None)", + "with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " workbench.save_workspace(connection, create_args)", + "connection_ready = threading.Event()", + "zero_timeout_reached = threading.Event()", + "writer_ready = threading.Event()", + "restore_timeout_reached = threading.Event()", + "writer_committed = threading.Event()", + "outcome = {'beginAttempts': 0, 'error': None, 'inTransaction': None, 'started': False}", + "class TrackedConnection:", + " def __init__(self, delegate):", + " self.delegate = delegate", + " def execute(self, sql, *args):", + " normalized = sql.strip().upper()", + " if normalized == 'BEGIN IMMEDIATE':", + " outcome['beginAttempts'] += 1", + " elif normalized == 'PRAGMA BUSY_TIMEOUT = 0':", + " zero_timeout_reached.set()", + " if not writer_ready.wait(5):", + " raise RuntimeError('writer did not acquire the post-inspection lock')", + " elif normalized.startswith('PRAGMA BUSY_TIMEOUT = '):", + " restore_timeout_reached.set()", + " if not writer_committed.wait(5):", + " raise RuntimeError('writer did not commit before blocking retry')", + " return self.delegate.execute(sql, *args)", + " def __getattr__(self, name):", + " return getattr(self.delegate, name)", + "def worker():", + " with workbench.connect() as delegate:", + " connection_ready.set()", + " try:", + " workbench.start_scan(TrackedConnection(delegate), start_args)", + " except BaseException as error:", + " outcome['error'] = f'{type(error).__name__}: {error}'", + " else:", + " outcome['started'] = True", + " outcome['inTransaction'] = delegate.in_transaction", + "thread = threading.Thread(target=worker)", + "thread.start()", + "if not connection_ready.wait(5):", + " raise RuntimeError('worker connection timed out')", + "if not zero_timeout_reached.wait(5):", + " raise RuntimeError('worker did not complete its external inspection')", + "with workbench.connect() as blocker:", + " blocker.execute('BEGIN IMMEDIATE')", + " workbench.start_scan(blocker, start_args)", + " created_scan_id = blocker.execute('SELECT active_scan_id FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()['active_scan_id']", + " Path(repository).rename(Path(root) / 'parked-repository')", + " writer_ready.set()", + " if not restore_timeout_reached.wait(5):", + " raise RuntimeError('worker did not reach its blocking retry')", + " blocker.commit()", + " writer_committed.set()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('scan worker did not finish')", + "with workbench.connect() as connection:", + " active_scan_id = connection.execute('SELECT active_scan_id FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()['active_scan_id']", + " scan_count = connection.execute('SELECT COUNT(*) FROM scans WHERE workspace_id = ?', (workspace_id,)).fetchone()[0]", + "print(json.dumps({'activeMatchesCreated': active_scan_id == created_scan_id, 'restoreTimeoutReached': restore_timeout_reached.is_set(), 'scanCount': scan_count, 'targetExists': Path(repository).exists(), 'zeroTimeoutReached': zero_timeout_reached.is_set(), **outcome}))", + ], + [repository, base, head, root], + state, + ); + + expect(result).toEqual({ + activeMatchesCreated: true, + beginAttempts: 3, + error: null, + inTransaction: false, + restoreTimeoutReached: true, + scanCount: 1, + started: true, + targetExists: false, + zeroTimeoutReached: true, + }); + }); + + test.each([ + ["submitted", "before-begin"], + ["prompt", "before-begin"], + ["cli", "before-begin"], + ["submitted", "after-digest"], + ["prompt", "after-digest"], + ["cli", "after-digest"], + ] as const)( + "rejects a %s committed target when a lock-only writer finishes %s", + (operation, timing) => { + const { base, head, repository, root, state } = createRepository(); + const selectedBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "changed in completed gap\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + + const result = runPythonJson<{ + counts: { scans: number; workspaces: number }; + currentDigest: string; + error: string | null; + identityCalls: number; + identityTransactions: boolean[]; + raceCommitted: boolean; + inTransaction: boolean; + snapshotCalls: number; + snapshotTransactions: boolean[]; + started: boolean; + storedDigest: string | null; + storedMatchesCurrent: boolean | null; + }>( + [ + "import argparse, json, sqlite3, subprocess, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "operation, timing, repository, base, head, root, selected_blob, replacement_blob = sys.argv[2:10]", + "root_path = Path(root)", + "workspace_id = str(uuid.uuid4())", + "if operation == 'submitted':", + " create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=None, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='standard', diff_target_kind=None, diff_base_revision=None, diff_head_revision=None, diff_content_digest=None)", + " save_args = argparse.Namespace(workspace_id=workspace_id, target_path=repository, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None)", + " with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " workbench.save_workspace(connection, save_args)", + "prompt_args = argparse.Namespace(thread_id='completed-gap-prompt', target_path=repository, scope='.', mode='diff', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None, scan_root=str(root_path / 'prompt-scans'), model=None, reasoning_effort=None)", + "submitted_args = argparse.Namespace(workspace_id=workspace_id, scan_root=str(root_path / 'submitted-scans'), model=None, reasoning_effort=None)", + "scan_directory = root_path / 'cli-scan'", + "scan_directory.mkdir(mode=0o700)", + "recipe = {'config': {}, 'mode': 'standard', 'repository': repository, 'target': {'kind': 'refs', 'paths': [], 'base': base, 'head': head}}", + "cli_args = argparse.Namespace(repository=repository, scan_dir=str(scan_directory), registration_json_stdin=False, recipe_json_stdin=False, recipe_json=json.dumps(recipe), archive_existing=False, archived_scan_dir=None, parent_scan_id=None)", + "original_snapshot = workbench.committed_diff_content_snapshot", + "original_identity = workbench.committed_diff_object_identity", + "worker_connection = None", + "identity_calls = 0", + "identity_transactions = []", + "snapshot_calls = 0", + "snapshot_transactions = []", + "race_armed = False", + "race_committed = False", + "def commit_race():", + " global race_committed", + " if race_committed:", + " return", + " race_committed = True", + " with sqlite3.connect(workbench.database_path(), timeout=1) as writer:", + " writer.execute('BEGIN IMMEDIATE')", + " subprocess.run(['git', '-C', repository, 'replace', '-f', selected_blob, replacement_blob], check=True)", + " writer.commit()", + "class RacedConnection:", + " def __init__(self, delegate):", + " self.delegate = delegate", + " def execute(self, sql, *args):", + " global race_armed, race_committed", + " normalized = ' '.join(sql.upper().split())", + " if timing == 'before-begin' and normalized == 'PRAGMA BUSY_TIMEOUT = 0':", + " race_armed = True", + " elif normalized == 'BEGIN IMMEDIATE' and race_armed:", + " race_armed = False", + " commit_race()", + " return self.delegate.execute(sql, *args)", + " def __getattr__(self, name):", + " return getattr(self.delegate, name)", + "def raced_snapshot(*args):", + " global snapshot_calls", + " snapshot_calls += 1", + " snapshot_transactions.append(bool(worker_connection and worker_connection.in_transaction))", + " snapshot = original_snapshot(*args)", + " if timing == 'after-digest' and snapshot_calls == 2:", + " commit_race()", + " return snapshot", + "def raced_identity(*args):", + " global identity_calls", + " identity_calls += 1", + " identity_transactions.append(bool(worker_connection and worker_connection.in_transaction))", + " return original_identity(*args)", + "workbench.committed_diff_content_snapshot = raced_snapshot", + "workbench.committed_diff_object_identity = raced_identity", + "outcome = {'error': None, 'started': False}", + "with workbench.connect() as connection:", + " worker_connection = connection", + " raced_connection = RacedConnection(connection)", + " try:", + " if operation == 'submitted':", + " workbench.start_scan(raced_connection, submitted_args)", + " elif operation == 'prompt':", + " workbench.start_prompt_only_scan(raced_connection, prompt_args)", + " else:", + " workbench.register_cli_scan(raced_connection, cli_args)", + " except SystemExit as error:", + " outcome['error'] = str(error)", + " else:", + " outcome['started'] = True", + " outcome['inTransaction'] = connection.in_transaction", + "current_digest = original_snapshot(Path(repository), base, head)[0]", + "with workbench.connect() as connection:", + " counts = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + " row = connection.execute('SELECT diff_content_digest FROM scans ORDER BY created_at DESC LIMIT 1').fetchone()", + "stored_digest = None if row is None else row['diff_content_digest']", + "print(json.dumps({'counts': counts, 'currentDigest': current_digest, 'identityCalls': identity_calls, 'identityTransactions': identity_transactions, 'raceCommitted': race_committed, 'snapshotCalls': snapshot_calls, 'snapshotTransactions': snapshot_transactions, 'storedDigest': stored_digest, 'storedMatchesCurrent': None if stored_digest is None else stored_digest == current_digest, **outcome}))", + ], + [ + operation, + timing, + repository, + base, + head, + root, + selectedBlob, + replacement.stdout.trim(), + ], + state, + ); + + expect(result).toEqual({ + counts: + operation === "submitted" + ? { scans: 0, workspaces: 1 } + : { scans: 0, workspaces: 0 }, + currentDigest: expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + error: + "The selected scan target changed while the scan was starting. Try again.", + identityCalls: 1, + identityTransactions: [true], + inTransaction: false, + raceCommitted: true, + snapshotCalls: 2, + snapshotTransactions: [false, false], + started: false, + storedDigest: null, + storedMatchesCurrent: null, + }); + }, + ); + + test.each(["submitted", "prompt", "cli"] as const)( + "never accepts a stale %s digest when every snapshot is paired with a restored replacement view", + (operation) => { + const { base, head, repository, root, state } = createRepository(); + const selectedBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "repeatable replacement view\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + + const result = runPythonJson<{ + counts: { scans: number; workspaces: number }; + currentDigest: string; + error: string | null; + inTransaction: boolean; + mutationEpochs: number; + snapshotCalls: number; + snapshotTransactions: boolean[]; + started: boolean; + storedDigest: string | null; + storedMatchesCurrent: boolean | null; + }>( + [ + "import argparse, json, sqlite3, subprocess, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "operation, repository, base, head, root, selected_blob, replacement_blob = sys.argv[2:9]", + "root_path = Path(root)", + "workspace_id = str(uuid.uuid4())", + "if operation == 'submitted':", + " create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=None, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='standard', diff_target_kind=None, diff_base_revision=None, diff_head_revision=None, diff_content_digest=None)", + " save_args = argparse.Namespace(workspace_id=workspace_id, target_path=repository, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None)", + " with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " workbench.save_workspace(connection, save_args)", + "prompt_args = argparse.Namespace(thread_id='repeatable-pair-prompt', target_path=repository, scope='.', mode='diff', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None, scan_root=str(root_path / 'prompt-scans'), model=None, reasoning_effort=None)", + "submitted_args = argparse.Namespace(workspace_id=workspace_id, scan_root=str(root_path / 'submitted-scans'), model=None, reasoning_effort=None)", + "scan_directory = root_path / 'cli-scan'", + "scan_directory.mkdir(mode=0o700)", + "recipe = {'config': {}, 'mode': 'standard', 'repository': repository, 'target': {'kind': 'refs', 'paths': [], 'base': base, 'head': head}}", + "cli_args = argparse.Namespace(repository=repository, scan_dir=str(scan_directory), registration_json_stdin=False, recipe_json_stdin=False, recipe_json=json.dumps(recipe), archive_existing=False, archived_scan_dir=None, parent_scan_id=None)", + "original_snapshot = workbench.committed_diff_content_snapshot", + "worker_connection = None", + "mutation_epochs = 0", + "snapshot_calls = 0", + "snapshot_transactions = []", + "subprocess.run(['git', '-C', repository, 'replace', '-f', selected_blob, replacement_blob], check=True)", + "def mutate_replacement(action):", + " global mutation_epochs", + " mutation_epochs += 1", + " with sqlite3.connect(workbench.database_path(), timeout=1) as writer:", + " writer.execute('BEGIN IMMEDIATE')", + " command = ['git', '-C', repository, 'replace']", + " if action == 'remove':", + " command.extend(['-d', selected_blob])", + " else:", + " command.extend(['-f', selected_blob, replacement_blob])", + " subprocess.run(command, check=True, stdout=subprocess.DEVNULL)", + " writer.commit()", + "def split_snapshot(snapshot, *args):", + " global snapshot_calls", + " snapshot_calls += 1", + " snapshot_transactions.append(bool(worker_connection and worker_connection.in_transaction))", + " mutate_replacement('remove')", + " try:", + " return snapshot(*args)", + " finally:", + " mutate_replacement('restore')", + "def raced_snapshot(*args):", + " return split_snapshot(original_snapshot, *args)", + "workbench.committed_diff_content_snapshot = raced_snapshot", + "outcome = {'error': None, 'started': False}", + "with workbench.connect() as connection:", + " worker_connection = connection", + " try:", + " if operation == 'submitted':", + " workbench.start_scan(connection, submitted_args)", + " elif operation == 'prompt':", + " workbench.start_prompt_only_scan(connection, prompt_args)", + " else:", + " workbench.register_cli_scan(connection, cli_args)", + " except SystemExit as error:", + " outcome['error'] = str(error)", + " else:", + " outcome['started'] = True", + " outcome['inTransaction'] = connection.in_transaction", + "workbench.committed_diff_content_snapshot = original_snapshot", + "current_digest = original_snapshot(Path(repository), base, head)[0]", + "with workbench.connect() as connection:", + " counts = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + " row = connection.execute('SELECT diff_content_digest FROM scans ORDER BY created_at DESC LIMIT 1').fetchone()", + "stored_digest = None if row is None else row['diff_content_digest']", + "print(json.dumps({'counts': counts, 'currentDigest': current_digest, 'mutationEpochs': mutation_epochs, 'snapshotCalls': snapshot_calls, 'snapshotTransactions': snapshot_transactions, 'storedDigest': stored_digest, 'storedMatchesCurrent': None if stored_digest is None else stored_digest == current_digest, **outcome}))", + ], + [ + operation, + repository, + base, + head, + root, + selectedBlob, + replacement.stdout.trim(), + ], + state, + ); + + expect(result).toEqual({ + counts: + operation === "submitted" + ? { scans: 0, workspaces: 1 } + : { scans: 0, workspaces: 0 }, + currentDigest: expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + error: + "The selected scan target changed while the scan was starting. Try again.", + inTransaction: false, + mutationEpochs: 4, + snapshotCalls: 2, + snapshotTransactions: [false, false], + started: false, + storedDigest: null, + storedMatchesCurrent: null, + }); + }, + ); + + test.each(["working_tree", "commit", "range"] as const)( + "rejects a %s CLI registration when the target changes behind a writer lock", + (selection) => { + const { base, head, repository, root, state } = createRepository(); + writeFileSync(join(repository, "fixture.txt"), "range head\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "range head"); + const rangeHead = git(repository, "rev-parse", "HEAD"); + const selectedBase = selection === "working_tree" ? rangeHead : base; + const selectedHead = selection === "commit" ? head : rangeHead; + const selectedBlob = git( + repository, + "rev-parse", + `${selectedHead}:fixture.txt`, + ); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "changed behind lock\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + const scanDirectory = join(root, "cli-scan"); + mkdirSync(scanDirectory, { mode: 0o700 }); + + const result = runPythonJson<{ + beginAttempted: boolean; + counts: { scans: number; workspaces: number }; + error: string | null; + inTransaction: boolean; + scanDirectoryEntries: string[]; + snapshotTransactions: boolean[]; + started: boolean; + }>( + [ + "import argparse, json, subprocess, threading", + "from pathlib import Path", + "import workbench_db as workbench", + "selection, repository, scan_directory, selected_base, selected_head, selected_blob, replacement_blob = sys.argv[2:9]", + "recipe_kind = 'working_tree' if selection == 'working_tree' else 'refs'", + "recipe = {'config': {}, 'mode': 'standard', 'repository': repository, 'target': {'kind': recipe_kind, 'paths': [], 'base': selected_base, 'head': selected_head}}", + "arguments = argparse.Namespace(repository=repository, scan_dir=scan_directory, registration_json_stdin=False, recipe_json_stdin=False, recipe_json=json.dumps(recipe), archive_existing=False, archived_scan_dir=None, parent_scan_id=None)", + "snapshot_name = 'worktree_content_digest' if selection == 'working_tree' else 'committed_diff_content_snapshot'", + "original_snapshot = getattr(workbench, snapshot_name)", + "snapshot_transactions = []", + "worker_connection = None", + "def counted_snapshot(*args, **kwargs):", + " snapshot_transactions.append(bool(worker_connection and worker_connection.in_transaction))", + " return original_snapshot(*args, **kwargs)", + "setattr(workbench, snapshot_name, counted_snapshot)", + "begin_attempted = threading.Event()", + "connection_ready = threading.Event()", + "start_worker = threading.Event()", + "outcome = {'error': None, 'inTransaction': None, 'started': False}", + "class TrackedConnection:", + " def __init__(self, delegate):", + " self.delegate = delegate", + " def execute(self, sql, *args):", + " if sql.strip().upper() == 'BEGIN IMMEDIATE':", + " begin_attempted.set()", + " return self.delegate.execute(sql, *args)", + " def __getattr__(self, name):", + " return getattr(self.delegate, name)", + "def worker():", + " global worker_connection", + " with workbench.connect() as delegate:", + " worker_connection = delegate", + " connection_ready.set()", + " if not start_worker.wait(5):", + " outcome['error'] = 'worker start timed out'", + " return", + " try:", + " workbench.register_cli_scan(TrackedConnection(delegate), arguments)", + " except SystemExit as error:", + " outcome['error'] = str(error)", + " else:", + " outcome['started'] = True", + " outcome['inTransaction'] = delegate.in_transaction", + "thread = threading.Thread(target=worker)", + "thread.start()", + "if not connection_ready.wait(5):", + " raise RuntimeError('worker connection timed out')", + "with workbench.connect() as blocker:", + " blocker.execute('BEGIN IMMEDIATE')", + " start_worker.set()", + " if not begin_attempted.wait(5):", + " raise RuntimeError('registration did not reach writer lock')", + " if selection == 'working_tree':", + " (Path(repository) / 'fixture.txt').write_text('changed behind lock\\n')", + " else:", + " subprocess.run(['git', '-C', repository, 'replace', '-f', selected_blob, replacement_blob], check=True)", + " blocker.commit()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('registration worker did not finish')", + "with workbench.connect() as connection:", + " counts = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + "print(json.dumps({'beginAttempted': begin_attempted.is_set(), 'counts': counts, 'scanDirectoryEntries': sorted(entry.name for entry in Path(scan_directory).iterdir()), 'snapshotTransactions': snapshot_transactions, **outcome}))", + ], + [ + selection, + repository, + scanDirectory, + selectedBase, + selectedHead, + selectedBlob, + replacement.stdout.trim(), + ], + state, + ); + + expect(result).toEqual({ + beginAttempted: true, + counts: { scans: 0, workspaces: 0 }, + error: + selection === "working_tree" + ? "Working-tree contents changed after they were selected. Select Uncommitted changes again." + : "The committed changes selected for review no longer produce the same diff. Select the changes to review again.", + inTransaction: false, + scanDirectoryEntries: [], + snapshotTransactions: + selection === "working_tree" ? [false, true] : [false, false], + started: false, + }); + }, + ); + + test.each(["0.1.60", "0.1.74"])( + "rejects a status-zero missing blob after upgrading %s before insertion", + async (previousVersion) => { + expect(python).not.toBeNull(); + const { base, head, repository, root, state } = createRepository(); + const installedRoot = await upgradeBundledPlugin(root, previousVersion); + const blob = git(repository, "rev-parse", `${head}:fixture.txt`); + const objectPath = join( + repository, + ".git", + "objects", + blob.slice(0, 2), + blob.slice(2), + ); + const backupPath = `${objectPath}.backup`; + const scanDirectory = join(root, "scan"); + mkdirSync(scanDirectory, { mode: 0o700 }); + renameSync(objectPath, backupPath); + try { + const missing = spawnSync("git", ["cat-file", "--batch", "-z"], { + cwd: repository, + input: Buffer.from(`${blob}\0`), + }); + expect(missing.status, missing.stderr.toString()).toBe(0); + expect(missing.stdout).toEqual(Buffer.from(`${blob} missing\n`)); + } finally { + renameSync(backupPath, objectPath); + } + + const probe = runPythonJson<{ + digestTransactions: boolean[]; + error: string | null; + inserted: { scans: number; workspaces: number }; + }>( + [ + "import argparse, json", + "from pathlib import Path", + "import workbench_db as workbench", + "repository, scan_directory, base, head = sys.argv[2:6]", + "object_path, backup_path = Path(sys.argv[6]), Path(sys.argv[7])", + "recipe = {'config': {}, 'mode': 'standard', 'repository': repository, 'target': {'kind': 'refs', 'paths': [], 'base': base, 'head': head}}", + "arguments = argparse.Namespace(repository=repository, scan_dir=scan_directory, registration_json_stdin=False, recipe_json_stdin=False, recipe_json=json.dumps(recipe), archive_existing=False, archived_scan_dir=None, parent_scan_id=None)", + "original_digest = workbench.committed_diff_content_snapshot", + "digest_transactions = []", + "def tracked_digest(*args):", + " digest_transactions.append(connection.in_transaction)", + " return original_digest(*args)", + "workbench.committed_diff_content_snapshot = tracked_digest", + "original_count = workbench.directory_snapshot_regular_file_count", + "def remove_blob_during_count(path):", + " count = original_count(path)", + " object_path.rename(backup_path)", + " return count", + "workbench.directory_snapshot_regular_file_count = remove_blob_during_count", + "with workbench.connect() as connection:", + " before = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + " try:", + " workbench.register_cli_scan(connection, arguments)", + " except SystemExit as error:", + " message = str(error)", + " else:", + " message = None", + " finally:", + " if backup_path.exists():", + " backup_path.rename(object_path)", + " after = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + "print(json.dumps({'digestTransactions': digest_transactions, 'error': message, 'inserted': {table: after[table] - before[table] for table in before}}))", + ], + [repository, scanDirectory, base, head, objectPath, backupPath], + state, + {}, + join(installedRoot, "scripts"), + ); + + expect(probe).toEqual({ + digestTransactions: [false, false], + error: "Could not snapshot the selected committed changes.", + inserted: { scans: 0, workspaces: 0 }, + }); + }, + ); + + test("rejects substituted committed blob bytes during reselection", () => { + const { base, head, repository, state } = createRepository(); + const selectedDigest = committedDigest(repository, state, base, head); + const originalBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "substituted\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + git(repository, "replace", "-f", originalBlob, replacement.stdout.trim()); + expect(committedDigest(repository, state, base, head)).not.toBe( + selectedDigest, + ); + expect( + runPython( + [ + "from workbench_db import inspect_setup_values", + "try:", + " inspect_setup_values(sys.argv[2], '.', 'diff', 'range', sys.argv[3], sys.argv[4], sys.argv[5])", + "except SystemExit as error:", + " print(error)", + ], + [repository, base, head, selectedDigest], + state, + ), + ).toBe( + "The committed changes selected for review no longer produce the same diff. " + + "Select the changes to review again.", + ); + }); + + test.each([ + ["commit", "symbolic"], + ["range", "symbolic"], + ["range", "abbreviated"], + ] as const)( + "binds a failed %s snapshot to its %s revision identity", + (kind, aliasStyle) => { + const { base, head, repository, state } = createRepository(); + const selectedBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const objectPath = join( + repository, + ".git", + "objects", + selectedBlob.slice(0, 2), + selectedBlob.slice(2), + ); + const backupPath = `${objectPath}.backup`; + const nextHead = git( + repository, + "-c", + "user.name=synthetic-test", + "-c", + "user.email=synthetic-test@example.invalid", + "commit-tree", + `${head}^{tree}`, + "-p", + head, + "-m", + "different clean selection", + ); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "substituted after recovery\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + renameSync(objectPath, backupPath); + + const result = runPythonJson<{ + afterRejectedSave: Record; + differentSelection: Record; + persistedAfterFailure: Record; + sameSelectionError: string | null; + setupValidation: { error: string | null; valid: boolean }; + }>( + [ + "import argparse, json, subprocess, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "kind, alias_style, repository, base, head, next_head, selected_blob, replacement_blob = sys.argv[2:10]", + "object_path, backup_path = Path(sys.argv[10]), Path(sys.argv[11])", + "requested_base = None if kind == 'commit' else ('HEAD~1' if alias_style == 'symbolic' else base[:8])", + "requested_head = 'HEAD' if alias_style == 'symbolic' else head[:8]", + "workspace_id = str(uuid.uuid4())", + "def arguments(base_revision, head_revision):", + " return argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind=kind, diff_base_revision=base_revision, diff_head_revision=head_revision, diff_content_digest=None)", + "with workbench.connect() as connection:", + " created = workbench.create_workspace(connection, arguments(requested_base, requested_head))", + " persisted_after_failure = dict(connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest, submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + " backup_path.rename(object_path)", + " if kind == 'range' and alias_style == 'symbolic':", + " subprocess.run(['git', '-C', repository, 'replace', '-f', selected_blob, replacement_blob], check=True)", + " try:", + " workbench.save_workspace(connection, arguments(None if kind == 'commit' else base, head))", + " except SystemExit as error:", + " same_selection_error = str(error)", + " else:", + " same_selection_error = None", + " after_rejected_save = dict(connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest, submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + " if kind == 'range' and alias_style == 'symbolic':", + " subprocess.run(['git', '-C', repository, 'replace', '-d', selected_blob], check=True, stdout=subprocess.DEVNULL)", + " workbench.save_workspace(connection, arguments(None if kind == 'commit' else head, next_head))", + " different_selection = dict(connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest, submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + "print(json.dumps({'setupValidation': created['setupValidation'], 'persistedAfterFailure': persisted_after_failure, 'sameSelectionError': same_selection_error, 'afterRejectedSave': after_rejected_save, 'differentSelection': different_selection}))", + ], + [ + kind, + aliasStyle, + repository, + base, + head, + nextHead, + selectedBlob, + replacement.stdout.trim(), + objectPath, + backupPath, + ], + state, + ); + + const failedSelection = { + diff_base_revision: kind === "commit" ? null : base, + diff_content_digest: null, + diff_head_revision: head, + submitted: 0, + }; + expect(result.setupValidation).toEqual({ + error: "Could not snapshot the selected committed changes.", + valid: false, + }); + expect(result.persistedAfterFailure).toEqual(failedSelection); + expect(result.sameSelectionError).toBe( + "The committed changes selected for review no longer produce the same " + + "diff. Select the changes to review again.", + ); + expect(result.afterRejectedSave).toEqual(failedSelection); + expect(result.differentSelection).toEqual({ + diff_base_revision: head, + diff_content_digest: expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + diff_head_revision: nextHead, + submitted: 1, + }); + }, + ); + + test.each(["commit", "range"] as const)( + "allows correcting an unresolved %s selection before submission", + (kind) => { + const { base, head, repository, state } = createRepository(); + const typoRef = "refs/heads/typoed-selection"; + + const result = runPythonJson<{ + correctedSelection: Record; + persistedAfterFailure: Record; + saveError: string | null; + setupValidation: { error: string | null; valid: boolean }; + }>( + [ + "import argparse, json, uuid", + "import workbench_db as workbench", + "kind, repository, base, head, typo_ref = sys.argv[2:7]", + "workspace_id = str(uuid.uuid4())", + "def arguments(base_revision, head_revision):", + " return argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind=kind, diff_base_revision=base_revision, diff_head_revision=head_revision, diff_content_digest=None)", + "initial_base = None if kind == 'commit' else 'HEAD~1'", + "corrected_base = None if kind == 'commit' else base", + "with workbench.connect() as connection:", + " created = workbench.create_workspace(connection, arguments(initial_base, typo_ref))", + " persisted_after_failure = dict(connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest, submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + " try:", + " workbench.save_workspace(connection, arguments(corrected_base, head))", + " except SystemExit as error:", + " save_error = str(error)", + " else:", + " save_error = None", + " corrected_selection = dict(connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest, submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + "print(json.dumps({'setupValidation': created['setupValidation'], 'persistedAfterFailure': persisted_after_failure, 'saveError': save_error, 'correctedSelection': corrected_selection}))", + ], + [kind, repository, base, head, typoRef], + state, + ); + + expect(result.setupValidation).toEqual({ + error: `${kind === "commit" ? "Commit" : "Head revision"} does not resolve to a local Git commit: ${typoRef}`, + valid: false, + }); + expect(result.persistedAfterFailure).toEqual({ + diff_base_revision: kind === "commit" ? null : "HEAD~1", + diff_content_digest: null, + diff_head_revision: typoRef, + submitted: 0, + }); + expect(result.saveError).toBeNull(); + expect(result.correctedSelection).toEqual({ + diff_base_revision: base, + diff_content_digest: expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + diff_head_revision: head, + submitted: 1, + }); + }, + ); + + test("rejects legacy same-commit initialization when its retained base changes", () => { + const { base, head, repository, state } = createRepository(); + const baseTree = git(repository, "rev-parse", `${base}^{tree}`); + const headTree = git(repository, "rev-parse", `${head}^{tree}`); + const alternateParent = git( + repository, + "-c", + "user.name=synthetic-test", + "-c", + "user.email=synthetic-test@example.invalid", + "commit-tree", + baseTree, + "-m", + "alternate same-tree parent", + ); + const replacementHead = git( + repository, + "-c", + "user.name=synthetic-test", + "-c", + "user.email=synthetic-test@example.invalid", + "commit-tree", + headTree, + "-p", + alternateParent, + "-m", + "replacement head", + ); + expect(git(repository, "rev-parse", `${alternateParent}^{tree}`)).toBe( + baseTree, + ); + expect(git(repository, "rev-parse", `${replacementHead}^{tree}`)).toBe( + headTree, + ); + + const result = runPythonJson<{ + after: Record; + before: Record; + currentDigest: string; + error: string | null; + observedHead: string; + observedParent: string; + }>( + [ + "import argparse, json, subprocess, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "repository, head, replacement_head = sys.argv[2:5]", + "workspace_id = str(uuid.uuid4())", + "create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='commit', diff_base_revision=None, diff_head_revision=head, diff_content_digest=None)", + "save_args = argparse.Namespace(workspace_id=workspace_id, target_path=repository, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='commit', diff_base_revision=None, diff_head_revision=head, diff_content_digest=None)", + "with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = ?', (workspace_id,))", + " connection.commit()", + " before = dict(connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest, submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + " subprocess.run(['git', '-C', repository, 'replace', '-f', head, replacement_head], check=True)", + " observed_head = subprocess.check_output(['git', '-C', repository, 'rev-parse', head], text=True).strip()", + " observed_parent = subprocess.check_output(['git', '-C', repository, 'rev-parse', f'{head}^'], text=True).strip()", + " current_digest = workbench.committed_diff_content_snapshot(Path(repository), observed_parent, observed_head)[0]", + " save_args.diff_content_digest = current_digest", + " try:", + " workbench.save_workspace(connection, save_args)", + " except SystemExit as error:", + " message = str(error)", + " else:", + " message = None", + " after = dict(connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest, submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + "print(json.dumps({'before': before, 'after': after, 'currentDigest': current_digest, 'error': message, 'observedHead': observed_head, 'observedParent': observed_parent}))", + ], + [repository, head, replacementHead], + state, + ); + + expect(result.observedHead).toBe(head); + expect(result.observedParent).toBe(alternateParent); + expect(result.currentDigest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + expect(result.before).toEqual({ + diff_base_revision: base, + diff_content_digest: null, + diff_head_revision: head, + submitted: 0, + }); + expect(result.error).toBe( + "The committed changes selected for review no longer produce the same " + + "diff. Select the changes to review again.", + ); + expect(result.after).toEqual(result.before); + }); + + test("confines configured digest spools to one private directory", () => { + const { base, head, repository, state } = createRepository(); + const result = runPythonJson<{ + allConfined: boolean; + digest: string; + privateMode: boolean; + residue: string[]; + rootRemoved: boolean; + roots: number; + spools: number; + }>( + [ + "import json, os", + "from pathlib import Path", + "import workbench_target as target", + "temporary_directory = target.tempfile.TemporaryDirectory", + "temporary_file = target.tempfile.TemporaryFile", + "roots, modes, directories = [], [], []", + "class TrackedTemporaryDirectory:", + " def __init__(self, *args, **kwargs):", + " self.inner = temporary_directory(*args, **kwargs)", + " def __enter__(self):", + " directory = self.inner.__enter__()", + " root = Path(directory).resolve()", + " roots.append(root)", + " modes.append(root.stat().st_mode & 0o777)", + " return directory", + " def __exit__(self, *args):", + " return self.inner.__exit__(*args)", + "def tracked_temporary_file(*args, **kwargs):", + " directories.append(Path(kwargs['dir']).resolve())", + " return temporary_file(*args, **kwargs)", + "target.tempfile.TemporaryDirectory = TrackedTemporaryDirectory", + "target.tempfile.TemporaryFile = tracked_temporary_file", + "digest = target.committed_diff_content_digest(Path(sys.argv[2]), sys.argv[3], sys.argv[4])", + "configured = Path(os.environ['CODEX_SECURITY_STATE_DIR']).resolve()", + "print(json.dumps({'allConfined': len(roots) == 1 and all(directory == roots[0] for directory in directories) and roots[0].parent == configured, 'digest': digest, 'privateMode': modes == [0o700], 'residue': [entry.name for entry in configured.iterdir()], 'rootRemoved': len(roots) == 1 and not roots[0].exists(), 'roots': len(roots), 'spools': len(directories)}))", + ], + [repository, base, head], + state, + ); + + expect(result).toMatchObject({ + allConfined: true, + residue: [], + rootRemoved: true, + roots: 1, + spools: 3, + }); + if (process.platform !== "win32") { + expect(result.privateMode).toBeTrue(); + } + expect(result.digest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + }); + + test("does not require the persistent state root for setup inspection", () => { + const { base, head, repository, root } = createRepository(); + const blockedHome = join(root, "codex-home-file"); + writeFileSync(blockedHome, "not a directory\n"); + + expect( + committedDigest(repository, undefined, base, head, { + CODEX_HOME: blockedHome, + }), + ).toMatch(/^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u); + expect(existsSync(join(blockedHome, "state"))).toBe(false); + }); + + test("confines state-free digest spools to one disposable directory", () => { + const { base, head, repository, root } = createRepository(); + const temporaryRoot = join(root, "temporary"); + mkdirSync(temporaryRoot, { mode: 0o700 }); + const result = runPythonJson<{ + allConfined: boolean; + digest: string; + residue: string[]; + rootRemoved: boolean; + roots: number; + spools: number; + }>( + [ + "import json", + "from pathlib import Path", + "import workbench_target as target", + "temporary_directory = target.tempfile.TemporaryDirectory", + "temporary_file = target.tempfile.TemporaryFile", + "roots, directories = [], []", + "class TrackedTemporaryDirectory:", + " def __init__(self, *args, **kwargs):", + " self.inner = temporary_directory(*args, **kwargs)", + " def __enter__(self):", + " directory = self.inner.__enter__()", + " roots.append(Path(directory).resolve())", + " return directory", + " def __exit__(self, *args):", + " return self.inner.__exit__(*args)", + "def tracked_temporary_file(*args, **kwargs):", + " directories.append(Path(kwargs['dir']).resolve())", + " return temporary_file(*args, **kwargs)", + "target.tempfile.TemporaryDirectory = TrackedTemporaryDirectory", + "target.tempfile.TemporaryFile = tracked_temporary_file", + "digest = target.committed_diff_content_digest(Path(sys.argv[2]), sys.argv[3], sys.argv[4])", + "temporary_root = Path(sys.argv[5]).resolve()", + "print(json.dumps({'allConfined': len(roots) == 1 and all(directory == roots[0] for directory in directories) and roots[0].parent == temporary_root, 'digest': digest, 'residue': [entry.name for entry in temporary_root.iterdir() if entry.name.startswith('codex-security-committed-diff-')], 'rootRemoved': len(roots) == 1 and not roots[0].exists(), 'roots': len(roots), 'spools': len(directories)}))", + ], + [repository, base, head, temporaryRoot], + undefined, + { TEMP: temporaryRoot, TMP: temporaryRoot, TMPDIR: temporaryRoot }, + ); + + expect(result).toMatchObject({ + allConfined: true, + residue: [], + rootRemoved: true, + roots: 1, + spools: 3, + }); + expect(result.digest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + }); + + test("removes the state-free spool directory after snapshot failure", () => { + const { base, head, repository, root } = createRepository(); + const temporaryRoot = join(root, "temporary-failure"); + mkdirSync(temporaryRoot, { mode: 0o700 }); + const blob = git(repository, "rev-parse", `${head}:fixture.txt`); + const objectPath = join( + repository, + ".git", + "objects", + blob.slice(0, 2), + blob.slice(2), + ); + const backupPath = `${objectPath}.backup`; + renameSync(objectPath, backupPath); + try { + expect( + runPython( + [ + "from pathlib import Path", + "from workbench_target import committed_diff_content_digest", + "try:", + " committed_diff_content_digest(Path(sys.argv[2]), sys.argv[3], sys.argv[4])", + "except SystemExit as error:", + " print(error)", + ], + [repository, base, head], + undefined, + { TEMP: temporaryRoot, TMP: temporaryRoot, TMPDIR: temporaryRoot }, + ), + ).toBe("Could not snapshot the selected committed changes."); + } finally { + renameSync(backupPath, objectPath); + } + expect( + readdirSync(temporaryRoot).filter((entry) => + entry.startsWith("codex-security-committed-diff-"), + ), + ).toEqual([]); + }); + + test("keeps both batch helpers from launching repository promisor helpers", () => { + const { base, head, repository, root, state } = createRepository(); + const helper = join(root, "promisor-helper.mjs"); + const started = join(root, "promisor-started"); + const leaked = join(root, "promisor-environment"); + const blob = git(repository, "rev-parse", `${head}:fixture.txt`); + writeFileSync( + helper, + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(started)}, "started");`, + `if (process.env.GIT_CONFIG_VALUE_0) writeFileSync(${JSON.stringify(leaked)}, process.env.GIT_CONFIG_VALUE_0);`, + "process.exit(1);", + ].join("\n"), + { mode: 0o600 }, + ); + git(repository, "config", "extensions.partialClone", "unsafe"); + git(repository, "config", "remote.unsafe.promisor", "true"); + git(repository, "config", "protocol.ext.allow", "always"); + git( + repository, + "config", + "remote.unsafe.url", + `ext::${process.execPath.replaceAll("\\", "/")} ${helper.replaceAll("\\", "/")}`, + ); + const objectPath = join( + repository, + ".git", + "objects", + blob.slice(0, 2), + blob.slice(2), + ); + renameSync(objectPath, `${objectPath}.missing`); + + const result = runPythonJson<{ + blobMissing: boolean; + digestError: string | null; + }>( + [ + "import json", + "from pathlib import Path", + "import workbench_target as target", + "try:", + " target.committed_diff_content_digest(Path(sys.argv[2]), sys.argv[3], sys.argv[4])", + "except SystemExit as error:", + " digest_error = str(error)", + "else:", + " digest_error = None", + "blobs = target.git_blob_bytes(Path(sys.argv[2]), [sys.argv[5]])", + "print(json.dumps({'blobMissing': blobs == [None], 'digestError': digest_error}))", + ], + [repository, base, head, blob], + state, + { + GIT_ALLOW_PROTOCOL: "ext", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.extraHeader", + GIT_CONFIG_VALUE_0: "SYNTHETIC_GIT_CREDENTIAL", + }, + ); + + expect(result).toEqual({ + blobMissing: true, + digestError: "Could not snapshot the selected committed changes.", + }); + expect(existsSync(started)).toBe(false); + expect(existsSync(leaked)).toBe(false); + }); + + test("reuses the inspected committed digest when creating a workspace", () => { + const { base, head, repository, state } = createRepository(); + const result = runPythonJson<{ digestCalls: number; valid: boolean }>( + [ + "import argparse, json, uuid", + "import workbench_db as workbench", + "original_digest = workbench.committed_diff_content_snapshot", + "digest_calls = 0", + "def counted_digest(*args):", + " global digest_calls", + " digest_calls += 1", + " return original_digest(*args)", + "workbench.committed_diff_content_snapshot = counted_digest", + "arguments = argparse.Namespace(workspace_id=str(uuid.uuid4()), thread_id=None, target_path=sys.argv[2], target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=sys.argv[3], diff_head_revision=sys.argv[4], diff_content_digest=None)", + "with workbench.connect() as connection:", + " result = workbench.create_workspace(connection, arguments)", + "print(json.dumps({'digestCalls': digest_calls, 'valid': result['setupValidation']['valid']}))", + ], + [repository, base, head], + state, + ); + + expect(result).toEqual({ digestCalls: 1, valid: true }); + }); + + test("reuses the stored digest when saving the same committed selection", () => { + const { base, head, repository, state } = createRepository(); + const result = runPythonJson<{ + after: string; + before: string; + submitted: boolean; + }>( + [ + "import argparse, json, uuid", + "import workbench_db as workbench", + "workspace_id = str(uuid.uuid4())", + "create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=sys.argv[2], target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=sys.argv[3], diff_head_revision=sys.argv[4], diff_content_digest=None)", + "save_args = argparse.Namespace(workspace_id=workspace_id, target_path=sys.argv[2], target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=sys.argv[3], diff_head_revision=sys.argv[4], diff_content_digest=None)", + "with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " before = connection.execute('SELECT diff_content_digest FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()['diff_content_digest']", + " workbench.save_workspace(connection, save_args)", + " row = connection.execute('SELECT diff_content_digest, submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()", + "print(json.dumps({'before': before, 'after': row['diff_content_digest'], 'submitted': bool(row['submitted'])}))", + ], + [repository, base, head], + state, + ); + + expect(result.after).toBe(result.before); + expect(result.before).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + expect(result.submitted).toBe(true); + }); + + test.each(["create", "save"] as const)( + "returns a %s response atomically despite a competing SQLite writer", + (operation) => { + const { base, head, repository, state } = createRepository(); + const result = runPythonJson<{ + blockerAcquired: boolean; + blockerAttempted: boolean; + error: string | null; + resultId: string | null; + rowCount: number; + submitted: boolean; + }>( + [ + "import argparse, json, threading, uuid", + "import workbench_db as workbench", + "operation, repository, base, head = sys.argv[2:6]", + "workspace_id = str(uuid.uuid4())", + "blank_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=None, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='standard', diff_target_kind=None, diff_base_revision=None, diff_head_revision=None, diff_content_digest=None)", + "selected_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None)", + "if operation == 'save':", + " with workbench.connect() as setup_connection:", + " workbench.create_workspace(setup_connection, blank_args)", + "blocker_ready = threading.Event()", + "start_blocker = threading.Event()", + "blocker_attempted = threading.Event()", + "blocker_acquired = threading.Event()", + "release_blocker = threading.Event()", + "def block_writer():", + " with workbench.connect() as blocker:", + " blocker_ready.set()", + " if not start_blocker.wait(5):", + " raise RuntimeError('blocker start timed out')", + " blocker_attempted.set()", + " blocker.execute('BEGIN IMMEDIATE')", + " blocker_acquired.set()", + " if not release_blocker.wait(5):", + " raise RuntimeError('blocker release timed out')", + " blocker.rollback()", + "thread = threading.Thread(target=block_writer)", + "thread.start()", + "if not blocker_ready.wait(5):", + " raise RuntimeError('blocker connection timed out')", + "original_inspected_state = workbench.inspected_workspace_state", + "def contended_inspected_state(connection, selected_workspace_id, **kwargs):", + " start_blocker.set()", + " expected_event = blocker_attempted if connection.in_transaction else blocker_acquired", + " if not expected_event.wait(5):", + " raise RuntimeError('blocker did not reach expected state')", + " return original_inspected_state(connection, selected_workspace_id, **kwargs)", + "workbench.inspected_workspace_state = contended_inspected_state", + "with workbench.connect() as connection:", + " connection.execute('PRAGMA busy_timeout = 200')", + " try:", + " response = workbench.create_workspace(connection, selected_args) if operation == 'create' else workbench.save_workspace(connection, selected_args)", + " except BaseException as error:", + " message = f'{type(error).__name__}: {error}'", + " result_id = None", + " else:", + " message = None", + " result_id = response['id']", + "if not blocker_acquired.wait(5):", + " raise RuntimeError('blocker did not acquire the writer lock')", + "release_blocker.set()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('blocker thread did not finish')", + "with workbench.connect() as connection:", + " row_count = connection.execute('SELECT COUNT(*) FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()[0]", + " submitted = bool(connection.execute('SELECT submitted FROM workspaces WHERE id = ?', (workspace_id,)).fetchone()['submitted'])", + "print(json.dumps({'blockerAcquired': blocker_acquired.is_set(), 'blockerAttempted': blocker_attempted.is_set(), 'error': message, 'resultId': result_id, 'rowCount': row_count, 'submitted': submitted}))", + ], + [operation, repository, base, head], + state, + ); + + expect(result).toEqual({ + blockerAcquired: true, + blockerAttempted: true, + error: null, + resultId: expect.any(String), + rowCount: 1, + submitted: operation === "save", + }); + }, + ); + + test("does not repeat a failed setup digest inside SQLite", () => { + const { base, head, repository, state } = createRepository(); + const selectedDigest = committedDigest(repository, state, base, head); + const selectedBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "substituted\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + git(repository, "replace", "-f", selectedBlob, replacement.stdout.trim()); + + const result = runPythonJson<{ + digestTransactions: boolean[]; + setupValidation: { error: string | null; valid: boolean }; + }>( + [ + "import argparse, json, uuid", + "import workbench_db as workbench", + "original_digest = workbench.committed_diff_content_snapshot", + "digest_transactions = []", + "with workbench.connect() as connection:", + " def tracked_digest(*args):", + " digest_transactions.append(connection.in_transaction)", + " return original_digest(*args)", + " workbench.committed_diff_content_snapshot = tracked_digest", + " arguments = argparse.Namespace(workspace_id=str(uuid.uuid4()), thread_id=None, target_path=sys.argv[2], target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=sys.argv[3], diff_head_revision=sys.argv[4], diff_content_digest=sys.argv[5])", + " result = workbench.create_workspace(connection, arguments)", + "print(json.dumps({'digestTransactions': digest_transactions, 'setupValidation': result['setupValidation']}))", + ], + [repository, base, head, selectedDigest], + state, + ); + + expect(result).toEqual({ + digestTransactions: [false], + setupValidation: { + error: + "The committed changes selected for review no longer produce the same diff. " + + "Select the changes to review again.", + valid: false, + }, + }); + }); + + test("rolls back a create rejected during rendering", () => { + const { base, head, repository, state } = createRepository(); + writeFileSync(join(repository, "fixture.txt"), "new head\n"); + git(repository, "add", "fixture.txt"); + commit(repository, "new head"); + const newHead = git(repository, "rev-parse", "HEAD"); + const newDigest = committedDigest(repository, state, head, newHead); + + const result = runPythonJson<{ + digestCalls: number; + persisted: { + baseRevision: string; + contentDigest: string; + headRevision: string; + } | null; + error: string | null; + }>( + [ + "import argparse, json, uuid", + "import workbench_db as workbench", + "repository, base, head, new_head, new_digest = sys.argv[2:7]", + "original_digest = workbench.committed_diff_content_snapshot", + "digest_calls = 0", + "def counted_digest(*args):", + " global digest_calls", + " digest_calls += 1", + " return original_digest(*args)", + "workbench.committed_diff_content_snapshot = counted_digest", + "original_inspected_state = workbench.inspected_workspace_state", + "def raced_inspected_state(connection, workspace_id, **kwargs):", + " connection.execute('UPDATE workspaces SET diff_base_revision = ?, diff_head_revision = ?, diff_content_digest = ?, updated_at = ? WHERE id = ?', (head, new_head, new_digest, '2099-01-01T00:00:00Z', workspace_id))", + " return original_inspected_state(connection, workspace_id, **kwargs)", + "workbench.inspected_workspace_state = raced_inspected_state", + "arguments = argparse.Namespace(workspace_id=str(uuid.uuid4()), thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None)", + "with workbench.connect() as connection:", + " try:", + " workbench.create_workspace(connection, arguments)", + " except SystemExit as error:", + " message = str(error)", + " else:", + " message = None", + " persisted = connection.execute('SELECT diff_base_revision, diff_head_revision, diff_content_digest FROM workspaces WHERE id = ?', (arguments.workspace_id,)).fetchone()", + "persisted_json = None if persisted is None else {'baseRevision': persisted['diff_base_revision'], 'headRevision': persisted['diff_head_revision'], 'contentDigest': persisted['diff_content_digest']}", + "print(json.dumps({'digestCalls': digest_calls, 'error': message, 'persisted': persisted_json}))", + ], + [repository, base, head, newHead, newDigest], + state, + ); + + expect(result).toEqual({ + digestCalls: 1, + error: + "Codex Security setup changed while it was being saved. Try again.", + persisted: null, + }); + }); + + test("rolls back a save rejected during rendering", () => { + const { base, head, repository, state } = createRepository(); + const selectedDigest = committedDigest(repository, state, base, head); + const result = runPythonJson<{ + error: string | null; + unchanged: boolean; + }>( + [ + "import argparse, json, uuid", + "import workbench_db as workbench", + "workspace_id = str(uuid.uuid4())", + "create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=None, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='standard', diff_target_kind=None, diff_base_revision=None, diff_head_revision=None, diff_content_digest=None)", + "with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + "save_args = argparse.Namespace(workspace_id=workspace_id, target_path=sys.argv[2], target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind='range', diff_base_revision=sys.argv[3], diff_head_revision=sys.argv[4], diff_content_digest=sys.argv[5])", + "original_inspected_state = workbench.inspected_workspace_state", + "def raced_inspected_state(connection, selected_workspace_id, **kwargs):", + " connection.execute('UPDATE workspaces SET updated_at = ? WHERE id = ?', ('2099-01-01T00:00:00Z', selected_workspace_id))", + " return original_inspected_state(connection, selected_workspace_id, **kwargs)", + "workbench.inspected_workspace_state = raced_inspected_state", + "with workbench.connect() as connection:", + " before = dict(connection.execute('SELECT target_path, submitted, updated_at FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + " try:", + " workbench.save_workspace(connection, save_args)", + " except SystemExit as error:", + " message = str(error)", + " else:", + " message = None", + " after = dict(connection.execute('SELECT target_path, submitted, updated_at FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + "print(json.dumps({'error': message, 'unchanged': before == after}))", + ], + [repository, base, head, selectedDigest], + state, + ); + + expect(result).toEqual({ + error: + "Codex Security setup changed while it was being saved. Try again.", + unchanged: true, + }); + }); + + test.each(["working_tree", "commit", "range"] as const)( + "rejects a submitted %s start when the target changes behind a writer lock", + (kind) => { + const { base, head, repository, root, state } = createRepository(); + const selectedBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "changed behind lock\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + const replacementBlob = replacement.stdout.trim(); + + const result = runPythonJson<{ + after: { + activeScanId: string | null; + contentDigest: string; + submitted: number; + updatedAt: string; + }; + before: { + activeScanId: string | null; + contentDigest: string; + submitted: number; + updatedAt: string; + }; + beginAttempted: boolean; + error: string | null; + inTransaction: boolean; + scanCount: number; + snapshotCalls: number; + started: boolean; + }>( + [ + "import argparse, json, subprocess, threading, uuid", + "from pathlib import Path", + "import workbench_db as workbench", + "kind, repository, base, head, root, selected_blob, replacement_blob = sys.argv[2:9]", + "workspace_id = str(uuid.uuid4())", + "selected_base = head if kind == 'working_tree' else base", + "create_args = argparse.Namespace(workspace_id=workspace_id, thread_id=None, target_path=repository, target_title=None, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind=kind, diff_base_revision=selected_base, diff_head_revision=head, diff_content_digest=None)", + "save_args = argparse.Namespace(workspace_id=workspace_id, target_path=repository, target_summary=None, user_context=None, user_context_stdin=False, scope='.', mode='diff', diff_target_kind=kind, diff_base_revision=selected_base, diff_head_revision=head, diff_content_digest=None)", + "start_args = argparse.Namespace(workspace_id=workspace_id, scan_root=str(Path(root) / 'submitted-scans'), model=None, reasoning_effort=None)", + "with workbench.connect() as connection:", + " workbench.create_workspace(connection, create_args)", + " workbench.save_workspace(connection, save_args)", + " before = dict(connection.execute('SELECT active_scan_id AS activeScanId, diff_content_digest AS contentDigest, submitted, updated_at AS updatedAt FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + "snapshot_name = 'worktree_content_digest' if kind == 'working_tree' else 'committed_diff_content_snapshot'", + "original_snapshot = getattr(workbench, snapshot_name)", + "snapshot_calls = 0", + "def counted_snapshot(*args, **kwargs):", + " global snapshot_calls", + " snapshot_calls += 1", + " return original_snapshot(*args, **kwargs)", + "setattr(workbench, snapshot_name, counted_snapshot)", + "begin_attempted = threading.Event()", + "connection_ready = threading.Event()", + "start_worker = threading.Event()", + "outcome = {'error': None, 'inTransaction': None, 'started': False}", + "class TrackedConnection:", + " def __init__(self, delegate):", + " self.delegate = delegate", + " def execute(self, sql, *args):", + " if sql.strip().upper() == 'BEGIN IMMEDIATE':", + " begin_attempted.set()", + " return self.delegate.execute(sql, *args)", + " def __getattr__(self, name):", + " return getattr(self.delegate, name)", + "def worker():", + " with workbench.connect() as delegate:", + " connection_ready.set()", + " if not start_worker.wait(5):", + " outcome['error'] = 'worker start timed out'", + " return", + " try:", + " workbench.start_scan(TrackedConnection(delegate), start_args)", + " except SystemExit as error:", + " outcome['error'] = str(error)", + " else:", + " outcome['started'] = True", + " outcome['inTransaction'] = delegate.in_transaction", + "thread = threading.Thread(target=worker)", + "thread.start()", + "if not connection_ready.wait(5):", + " raise RuntimeError('worker connection timed out')", + "with workbench.connect() as blocker:", + " blocker.execute('BEGIN IMMEDIATE')", + " start_worker.set()", + " if not begin_attempted.wait(5):", + " raise RuntimeError('scan did not reach writer lock')", + " if kind == 'working_tree':", + " (Path(repository) / 'fixture.txt').write_text('changed behind lock\\n')", + " else:", + " subprocess.run(['git', '-C', repository, 'replace', '-f', selected_blob, replacement_blob], check=True)", + " blocker.commit()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('scan worker did not finish')", + "with workbench.connect() as connection:", + " after = dict(connection.execute('SELECT active_scan_id AS activeScanId, diff_content_digest AS contentDigest, submitted, updated_at AS updatedAt FROM workspaces WHERE id = ?', (workspace_id,)).fetchone())", + " scan_count = connection.execute('SELECT COUNT(*) FROM scans WHERE workspace_id = ?', (workspace_id,)).fetchone()[0]", + "print(json.dumps({'after': after, 'before': before, 'beginAttempted': begin_attempted.is_set(), 'scanCount': scan_count, 'snapshotCalls': snapshot_calls, **outcome}))", + ], + [kind, repository, base, head, root, selectedBlob, replacementBlob], + state, + ); + + expect(result).toMatchObject({ + beginAttempted: true, + error: + kind === "working_tree" + ? "Working-tree contents changed after they were selected. Select Uncommitted changes again." + : "The committed changes selected for review no longer produce the same diff. Select the changes to review again.", + inTransaction: false, + scanCount: 0, + snapshotCalls: 2, + started: false, + }); + expect(result.after).toEqual(result.before); + expect(result.before).toMatchObject({ + activeScanId: null, + submitted: 1, + }); + expect(result.before.contentDigest).toMatch( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ); + }, + ); + + test.each([ + ["create", "working_tree"], + ["rejoin", "working_tree"], + ["create", "commit"], + ["rejoin", "commit"], + ] as const)( + "rejects a %s %s start when the target changes behind a writer lock", + (action, kind) => { + const { base, head, repository, root, state } = createRepository(); + const result = runPythonJson<{ + beginAttempted: boolean; + counts: { scans: number; workspaces: number }; + disposition: string | null; + error: string | null; + snapshotCalls: number; + }>( + [ + "import argparse, json, shutil, threading", + "from pathlib import Path", + "import workbench_db as workbench", + "action, kind, repository, base, head, root = sys.argv[2:8]", + "scan_root = Path(root) / 'locked-scans'", + "scan_root.mkdir(mode=0o700)", + "def arguments():", + " return argparse.Namespace(thread_id=f'locked-{action}-{kind}', target_path=repository, scope='.', mode='diff', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind=kind, diff_base_revision=head if kind == 'working_tree' else base, diff_head_revision=head, diff_content_digest=None, scan_root=str(scan_root), model=None, reasoning_effort=None)", + "if action == 'rejoin':", + " with workbench.connect() as connection:", + " workbench.start_prompt_only_scan(connection, arguments())", + "original_require_diff_target = workbench.require_diff_target_snapshot", + "snapshot_calls = 0", + "def counted_require_diff_target(*args):", + " global snapshot_calls", + " snapshot_calls += 1", + " return original_require_diff_target(*args)", + "workbench.require_diff_target_snapshot = counted_require_diff_target", + "begin_attempted = threading.Event()", + "connection_ready = threading.Event()", + "start_worker = threading.Event()", + "outcome = {'disposition': None, 'error': None}", + "class TrackedConnection:", + " def __init__(self, delegate):", + " self.delegate = delegate", + " def execute(self, sql, *args):", + " if sql.strip().upper() == 'BEGIN IMMEDIATE':", + " begin_attempted.set()", + " return self.delegate.execute(sql, *args)", + " def __getattr__(self, name):", + " return getattr(self.delegate, name)", + "def worker():", + " with workbench.connect() as delegate:", + " connection_ready.set()", + " if not start_worker.wait(5):", + " outcome['error'] = 'worker start timed out'", + " return", + " try:", + " started = workbench.start_prompt_only_scan(TrackedConnection(delegate), arguments())", + " except SystemExit as error:", + " outcome['error'] = str(error)", + " else:", + " outcome['disposition'] = started['startDisposition']", + "thread = threading.Thread(target=worker)", + "thread.start()", + "if not connection_ready.wait(5):", + " raise RuntimeError('worker connection timed out')", + "with workbench.connect() as blocker:", + " blocker.execute('BEGIN IMMEDIATE')", + " start_worker.set()", + " if not begin_attempted.wait(5):", + " raise RuntimeError('scan did not reach writer lock')", + " target = Path(repository)", + " if kind == 'working_tree':", + " (target / 'fixture.txt').write_text('changed behind lock\\n')", + " else:", + " parked = Path(root) / 'parked-repository'", + " target.rename(parked)", + " shutil.copytree(parked, target)", + " blocker.commit()", + "thread.join(10)", + "if thread.is_alive():", + " raise RuntimeError('scan worker did not finish')", + "with workbench.connect() as connection:", + " counts = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + "print(json.dumps({'beginAttempted': begin_attempted.is_set(), 'counts': counts, 'snapshotCalls': snapshot_calls, **outcome}))", + ], + [action, kind, repository, base, head, root], + state, + ); + + expect(result).toEqual({ + beginAttempted: true, + counts: { + scans: action === "rejoin" ? 1 : 0, + workspaces: action === "rejoin" ? 1 : 0, + }, + disposition: null, + error: + "The selected scan target changed while the scan was starting. Try again.", + snapshotCalls: 2, + }); + }, + ); + + test.each(["commit", "range"] as const)( + "rejoins a legacy active %s scan only when both stored digests are null", + (kind) => { + const { base, head, repository, root, state } = createRepository(); + const result = runPythonJson<{ + disposition: string; + scanCount: number; + scanDigest: string | null; + sameScan: boolean; + workspaceCount: number; + workspaceDigest: string | null; + }>( + [ + "import argparse, json", + "from pathlib import Path", + "import workbench_db as workbench", + "kind, repository, base, head, scan_root = sys.argv[2:7]", + "Path(scan_root).mkdir(mode=0o700)", + "def arguments():", + " return argparse.Namespace(thread_id=f'legacy-{kind}', target_path=repository, scope='.', mode='diff', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind=kind, diff_base_revision=base, diff_head_revision=head, diff_content_digest=None, scan_root=scan_root, model=None, reasoning_effort=None)", + "with workbench.connect() as connection:", + " first = workbench.start_prompt_only_scan(connection, arguments())", + " first_scan_id = first['scan']['scanId']", + " workspace_id = connection.execute('SELECT workspace_id FROM scans WHERE id = ?', (first_scan_id,)).fetchone()['workspace_id']", + " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = ?', (workspace_id,))", + " connection.execute('UPDATE scans SET diff_content_digest = NULL WHERE id = ?', (first_scan_id,))", + " connection.commit()", + " joined = workbench.start_prompt_only_scan(connection, arguments())", + " row = connection.execute('SELECT workspaces.diff_content_digest AS workspace_digest, scans.diff_content_digest AS scan_digest FROM scans JOIN workspaces ON workspaces.id = scans.workspace_id WHERE scans.id = ?', (first_scan_id,)).fetchone()", + " counts = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + "print(json.dumps({'disposition': joined['startDisposition'], 'sameScan': joined['scan']['scanId'] == first_scan_id, 'workspaceDigest': row['workspace_digest'], 'scanDigest': row['scan_digest'], 'workspaceCount': counts['workspaces'], 'scanCount': counts['scans']}))", + ], + [kind, repository, base, head, join(root, "legacy-scans")], + state, + ); + + expect(result).toEqual({ + disposition: "joined", + sameScan: true, + workspaceDigest: null, + scanDigest: null, + workspaceCount: 1, + scanCount: 1, + }); + }, + ); + + test.each(["commit", "range"] as const)( + "does not rejoin a legacy active %s scan with an explicit digest", + (kind) => { + const { base, head, repository, root, state } = createRepository(); + const selectedDigest = committedDigest(repository, state, base, head); + const result = runPythonJson<{ + createdScanDigest: string | null; + createdWorkspaceDigest: string | null; + disposition: string; + legacyScanDigest: string | null; + legacyWorkspaceDigest: string | null; + sameScan: boolean; + scanCount: number; + workspaceCount: number; + }>( + [ + "import argparse, json", + "from pathlib import Path", + "import workbench_db as workbench", + "kind, repository, base, head, selected_digest, scan_root = sys.argv[2:8]", + "Path(scan_root).mkdir(mode=0o700)", + "def arguments(content_digest):", + " return argparse.Namespace(thread_id=f'explicit-legacy-{kind}', target_path=repository, scope='.', mode='diff', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind=kind, diff_base_revision=base, diff_head_revision=head, diff_content_digest=content_digest, scan_root=scan_root, model=None, reasoning_effort=None)", + "with workbench.connect() as connection:", + " legacy = workbench.start_prompt_only_scan(connection, arguments(None))", + " legacy_scan_id = legacy['scan']['scanId']", + " legacy_workspace_id = connection.execute('SELECT workspace_id FROM scans WHERE id = ?', (legacy_scan_id,)).fetchone()['workspace_id']", + " connection.execute('UPDATE workspaces SET diff_content_digest = NULL WHERE id = ?', (legacy_workspace_id,))", + " connection.execute('UPDATE scans SET diff_content_digest = NULL WHERE id = ?', (legacy_scan_id,))", + " connection.commit()", + " started = workbench.start_prompt_only_scan(connection, arguments(selected_digest))", + " created_scan_id = started['scan']['scanId']", + " created = connection.execute('SELECT workspaces.diff_content_digest AS workspace_digest, scans.diff_content_digest AS scan_digest FROM scans JOIN workspaces ON workspaces.id = scans.workspace_id WHERE scans.id = ?', (created_scan_id,)).fetchone()", + " legacy_row = connection.execute('SELECT workspaces.diff_content_digest AS workspace_digest, scans.diff_content_digest AS scan_digest FROM scans JOIN workspaces ON workspaces.id = scans.workspace_id WHERE scans.id = ?', (legacy_scan_id,)).fetchone()", + " counts = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + "print(json.dumps({'disposition': started['startDisposition'], 'sameScan': created_scan_id == legacy_scan_id, 'createdWorkspaceDigest': created['workspace_digest'], 'createdScanDigest': created['scan_digest'], 'legacyWorkspaceDigest': legacy_row['workspace_digest'], 'legacyScanDigest': legacy_row['scan_digest'], 'workspaceCount': counts['workspaces'], 'scanCount': counts['scans']}))", + ], + [ + kind, + repository, + base, + head, + selectedDigest, + join(root, "explicit-legacy-scans"), + ], + state, + ); + + expect(result).toEqual({ + disposition: "created", + sameScan: false, + createdWorkspaceDigest: selectedDigest, + createdScanDigest: selectedDigest, + legacyWorkspaceDigest: null, + legacyScanDigest: null, + workspaceCount: 2, + scanCount: 2, + }); + }, + ); + + test("prefers an exact digested active scan over a newer legacy row", () => { + const { base, head, repository, root, state } = createRepository(); + const result = runPythonJson<{ + disposition: string; + exactSelected: boolean; + legacyScanDigest: string | null; + legacyWorkspaceDigest: string | null; + scanCount: number; + workspaceCount: number; + }>( + [ + "import argparse, json", + "from pathlib import Path", + "import workbench_db as workbench", + "repository, base, head, scan_root = sys.argv[2:6]", + "Path(scan_root).mkdir(mode=0o700)", + "thread_id = 'exact-digest-preference'", + "def arguments():", + " return argparse.Namespace(thread_id=thread_id, target_path=repository, scope='.', mode='diff', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind='range', diff_base_revision=base, diff_head_revision=head, diff_content_digest=None, scan_root=scan_root, model=None, reasoning_effort=None)", + "with workbench.connect() as connection:", + " legacy = workbench.start_prompt_only_scan(connection, arguments())", + " legacy_scan_id = legacy['scan']['scanId']", + " legacy_workspace_id = connection.execute('SELECT workspace_id FROM scans WHERE id = ?', (legacy_scan_id,)).fetchone()['workspace_id']", + " connection.execute('UPDATE workspaces SET thread_id = ?, diff_content_digest = NULL WHERE id = ?', ('temporary-owner', legacy_workspace_id))", + " connection.execute('UPDATE scans SET diff_content_digest = NULL WHERE id = ?', (legacy_scan_id,))", + " connection.commit()", + " exact = workbench.start_prompt_only_scan(connection, arguments())", + " exact_scan_id = exact['scan']['scanId']", + " connection.execute('UPDATE workspaces SET thread_id = ? WHERE id = ?', (thread_id, legacy_workspace_id))", + " connection.execute(\"UPDATE scans SET updated_at = '2099-01-01T00:00:00Z' WHERE id = ?\", (legacy_scan_id,))", + " connection.commit()", + " joined = workbench.start_prompt_only_scan(connection, arguments())", + " legacy_row = connection.execute('SELECT workspaces.diff_content_digest AS workspace_digest, scans.diff_content_digest AS scan_digest FROM scans JOIN workspaces ON workspaces.id = scans.workspace_id WHERE scans.id = ?', (legacy_scan_id,)).fetchone()", + " counts = {table: connection.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] for table in ('workspaces', 'scans')}", + "print(json.dumps({'disposition': joined['startDisposition'], 'exactSelected': joined['scan']['scanId'] == exact_scan_id, 'legacyWorkspaceDigest': legacy_row['workspace_digest'], 'legacyScanDigest': legacy_row['scan_digest'], 'workspaceCount': counts['workspaces'], 'scanCount': counts['scans']}))", + ], + [repository, base, head, join(root, "preference-scans")], + state, + ); + + expect(result).toEqual({ + disposition: "joined", + exactSelected: true, + legacyWorkspaceDigest: null, + legacyScanDigest: null, + workspaceCount: 2, + scanCount: 2, + }); + }); + + test.each(["commit", "range"] as const)( + "hashes outside SQLite once per %s completion phase and warns on finalization drift", + (kind) => { + const { base, head, repository, root, state } = createRepository(); + const selectedBlob = git(repository, "rev-parse", `${head}:fixture.txt`); + const replacement = spawnSync( + "git", + ["-C", repository, "hash-object", "-w", "--stdin"], + { encoding: "utf8", input: "completion replacement\n" }, + ); + expect(replacement.status, replacement.stderr).toBe(0); + const result = runPythonJson<{ + completedWarnings: string[]; + completedTargetWarnings: string[]; + genericWarning: string; + phaseBatchCounts: number[]; + transactions: boolean[]; + preparedTargetWarnings: string[]; + }>( + [ + "import argparse, json, os, shutil, subprocess", + "from pathlib import Path", + "import workbench_db as workbench", + "import workbench_target as target", + "kind, repository, base, head, scan_root, plugin_root, selected_blob, replacement_blob = sys.argv[2:10]", + "Path(scan_root).mkdir(mode=0o700)", + "start_args = argparse.Namespace(thread_id=f'completion-{kind}', target_path=repository, scope='.', mode='diff', target_summary=None, user_context=None, user_context_stdin=False, diff_target_kind=kind, diff_base_revision=base, diff_head_revision=head, diff_content_digest=None, scan_root=scan_root, model=None, reasoning_effort=None)", + "completion_args = argparse.Namespace(scan_id=None, claim_token=None, cost_json=None, thread_id=None)", + "with workbench.connect() as connection:", + " started = workbench.start_prompt_only_scan(connection, start_args)", + " scan_id = started['scan']['scanId']", + " completion_args.scan_id = scan_id", + " scan = workbench.require_scan(connection, scan_id)", + " scan_dir = Path(scan['scan_dir'])", + " shutil.copytree(Path(plugin_root) / 'examples' / 'completed-scan', scan_dir, dirs_exist_ok=True)", + " if os.name != 'nt':", + " scan_dir.parent.chmod(0o700)", + " scan_dir.chmod(0o700)", + " manifest_path = scan_dir / 'scan-manifest.json'", + " manifest = json.loads(manifest_path.read_text())", + " manifest['scan']['id'] = scan_id", + " manifest['scan']['target']['kind'] = 'git_diff'", + " manifest['scan'].pop('sealedAt', None)", + " manifest['scan'].pop('artifacts', None)", + " manifest_path.write_text(json.dumps(manifest))", + " for name in ('findings.json', 'coverage.json'):", + " path = scan_dir / name", + " document = json.loads(path.read_text())", + " document['scanId'] = scan_id", + " if name == 'coverage.json': document['inventoryStrategy'] = 'diff'", + " path.write_text(json.dumps(document))", + " original_digest = target.committed_diff_content_digest", + " def unavailable(*args): raise SystemExit('synthetic failure')", + " target.committed_diff_content_digest = unavailable", + " generic_warning = workbench.scan_target_warning(scan)", + " target.committed_diff_content_digest = original_digest", + " original_git = target.git_command", + " transactions = []", + " def tracked_git_command(path, *args, **kwargs):", + " if args[:2] == ('cat-file', '--batch'): transactions.append(connection.in_transaction)", + " return original_git(path, *args, **kwargs)", + " target.git_command = tracked_git_command", + " original_prepare = workbench._prepare_scan_finalization", + " def prepare_then_replace(*args, **kwargs):", + " prepared = original_prepare(*args, **kwargs)", + " subprocess.run(['git', '-C', repository, 'replace', '-f', selected_blob, replacement_blob], check=True, stdout=subprocess.DEVNULL)", + " return prepared", + " workbench._prepare_scan_finalization = prepare_then_replace", + " prepared = workbench.complete_scan(connection, completion_args, prepare_only=True)", + " phase_batch_counts = [len(transactions)]", + " prepared_completed_at = json.loads(manifest_path.read_text())['scan']['completedAt']", + " subprocess.run(['git', '-C', repository, 'replace', '-d', selected_blob], check=True, stdout=subprocess.DEVNULL)", + " workbench._prepare_scan_finalization = original_prepare", + " workbench.now = lambda: prepared_completed_at", + " completed = workbench.complete_scan(connection, completion_args, prepare_only=False)", + " phase_batch_counts.append(len(transactions) - phase_batch_counts[0])", + "print(json.dumps({'genericWarning': generic_warning, 'preparedTargetWarnings': prepared['targetWarnings'], 'completedTargetWarnings': completed['targetWarnings'], 'completedWarnings': completed['scan']['warnings'], 'phaseBatchCounts': phase_batch_counts, 'transactions': transactions}))", + ], + [ + kind, + repository, + base, + head, + join(root, "completion-scans"), + PLUGIN_ROOT, + selectedBlob, + replacement.stdout.trim(), + ], + state, + ); + const driftWarning = + "Committed changes changed while the scan was running; results were saved for the original snapshot."; + + expect(result).toEqual({ + genericWarning: + "The scan target became unavailable while the scan was running; results were saved for the original revision or snapshot.", + preparedTargetWarnings: [driftWarning], + completedTargetWarnings: [], + completedWarnings: expect.arrayContaining([driftWarning]), + phaseBatchCounts: [1, 1], + transactions: [false, false], + }); + }, + ); + + test("selects root commits in SHA-1 and supported SHA-256 repositories", () => { + expect(python).not.toBeNull(); + const { root, state } = createRepository(); + const emptyTrees = { + sha1: "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + sha256: + "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321", + }; + + for (const objectFormat of ["sha1", "sha256"] as const) { + const repository = join(root, `root-${objectFormat}`); + const initialized = spawnSync( + "git", + ["init", "--quiet", `--object-format=${objectFormat}`, repository], + { encoding: "utf8" }, + ); + if (objectFormat === "sha256" && initialized.status !== 0) continue; + expect(initialized.status, initialized.stderr).toBe(0); + writeFileSync(join(repository, "source.txt"), "root fixture\n"); + git(repository, "add", "source.txt"); + commit(repository, "root"); + const head = git(repository, "rev-parse", "HEAD"); + const diffTarget = runPythonJson<{ + baseRevision: string; + contentDigest: string; + headRevision: string; + kind: string; + }>( + [ + "import json", + "from workbench_db import inspect_setup_values", + "result = inspect_setup_values(sys.argv[2], '.', 'diff', 'commit', sys.argv[3], sys.argv[4], None)", + "print(json.dumps(result['diffTarget']))", + ], + [repository, emptyTrees[objectFormat], head], + state, + ); + + expect(diffTarget).toEqual({ + kind: "commit", + baseRevision: emptyTrees[objectFormat], + headRevision: head, + contentDigest: expect.stringMatching( + /^codex-security-snapshot\/v1:sha256:[a-f0-9]{64}$/u, + ), + }); + } + }); +}); diff --git a/sdk/typescript/tests-ts/prompt-only-start-timeout.test.ts b/sdk/typescript/tests-ts/prompt-only-start-timeout.test.ts index 324fd9635..155e03c21 100644 --- a/sdk/typescript/tests-ts/prompt-only-start-timeout.test.ts +++ b/sdk/typescript/tests-ts/prompt-only-start-timeout.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { loadBundledRuntime, PLUGIN_ROOT } from "./plugin-root.js"; -test("gives prompt-only scan startup the five-minute scan timeout", async () => { +test("gives long workbench operations the five-minute timeout", async () => { const runtime = await loadBundledRuntime(); const source = /async function executeWorkbench\([^\n]*\) \{[\s\S]*?\n\}/u.exec( @@ -34,7 +34,170 @@ test("gives prompt-only scan startup the five-minute scan timeout", async () => expect(await executeWorkbench("python", ["start-scan"])).toEqual({ timeout: 300_000, }); + expect(await executeWorkbench("python", ["create-workspace"])).toEqual({ + timeout: 300_000, + }); + expect(await executeWorkbench("python", ["prepare-scan-completion"])).toEqual( + { timeout: 300_000 }, + ); expect(await executeWorkbench("python", ["other-operation"])).toEqual({ timeout: 30_000, }); }); + +test("state-free inspection reuses but does not create a fallback", async () => { + const runtime = await loadBundledRuntime(); + const source = + /async function executeWorkbenchWithStateSelection\([^\n]*\) \{[\s\S]*?\n\}/u.exec( + runtime, + )?.[0]; + expect(source).toBeDefined(); + + function selector(fallback: Promise | undefined) { + let pinCalls = 0; + const executeWorkbenchWithStateSelection = new Function( + "WORKBENCH_COMMANDS_WITHOUT_DATABASE", + "CONFIGURED_WORKBENCH_STATE_DIR", + "fallbackWorkbenchStateDir", + "persistentWorkbenchStateSucceeded", + "withWorkbenchStateSelectionLock", + "executeWorkbench", + "isUnwritableSqliteOpenError", + "pinFallbackWorkbenchStateDir", + "logWorkbenchStateFallback", + `${source}\nreturn executeWorkbenchWithStateSelection;`, + )( + new Set(["inspect-target", "inspect-setup"]), + undefined, + fallback, + false, + async (operation: () => Promise) => await operation(), + async (_command: string, _args: string[], stateDir?: string) => ({ + stateDir, + }), + () => false, + async () => { + pinCalls += 1; + return "/new-fallback"; + }, + () => undefined, + ) as (command: string, args: string[]) => Promise<{ stateDir?: string }>; + return { executeWorkbenchWithStateSelection, pinCalls: () => pinCalls }; + } + + const withoutFallback = selector(undefined); + expect( + await withoutFallback.executeWorkbenchWithStateSelection("python", [ + "inspect-setup", + ]), + ).toEqual({ stateDir: undefined }); + expect(withoutFallback.pinCalls()).toBe(0); + + const withFallback = selector(Promise.resolve("/pinned-fallback")); + expect( + await withFallback.executeWorkbenchWithStateSelection("python", [ + "inspect-setup", + ]), + ).toEqual({ stateDir: "/pinned-fallback" }); + expect(withFallback.pinCalls()).toBe(0); +}); + +test("a rejected pending fallback pin does not fail state-free inspection", async () => { + const runtime = await loadBundledRuntime(); + const pinSource = + /async function pinFallbackWorkbenchStateDir\(\) \{[\s\S]*?\n\}/u.exec( + runtime, + )?.[0]; + const selectionSource = + /async function executeWorkbenchWithStateSelection\([^\n]*\) \{[\s\S]*?\n\}/u.exec( + runtime, + )?.[0]; + expect(pinSource).toBeDefined(); + expect(selectionSource).toBeDefined(); + const pathModule = /\b(import_node_path\d+)\.join\b/u.exec( + pinSource ?? "", + )?.[1]; + const fsModule = /\b(import_node_fs\d+)\.promises\b/u.exec( + pinSource ?? "", + )?.[1]; + expect(pathModule).toBeDefined(); + expect(fsModule).toBeDefined(); + + let mkdirCalls = 0; + const firstMkdir = Promise.withResolvers(); + const mkdirStarted = Promise.withResolvers(); + const bundled = new Function( + pathModule!, + fsModule!, + "scanRoot", + "WORKBENCH_COMMANDS_WITHOUT_DATABASE", + "CONFIGURED_WORKBENCH_STATE_DIR", + "fallbackWorkbenchStateDir", + "persistentWorkbenchStateSucceeded", + "withWorkbenchStateSelectionLock", + "executeWorkbench", + "isUnwritableSqliteOpenError", + "logWorkbenchStateFallback", + `${pinSource}\n${selectionSource}\nreturn { pinFallbackWorkbenchStateDir, executeWorkbenchWithStateSelection };`, + )( + { join: (left: string, right: string) => `${left}/${right}` }, + { + promises: { + mkdir: async () => { + mkdirCalls += 1; + if (mkdirCalls === 1) { + mkdirStarted.resolve(); + await firstMkdir.promise; + } + }, + }, + }, + async () => "/fallback", + new Set(["inspect-target", "inspect-setup"]), + undefined, + undefined, + false, + async (operation: () => Promise) => await operation(), + async (_command: string, _args: string[], stateDir?: string) => ({ + stateDir, + }), + () => false, + () => undefined, + ) as { + executeWorkbenchWithStateSelection: ( + command: string, + args: string[], + ) => Promise<{ stateDir?: string }>; + pinFallbackWorkbenchStateDir: () => Promise; + }; + + const pin = bundled.pinFallbackWorkbenchStateDir(); + await mkdirStarted.promise; + const inspection = bundled.executeWorkbenchWithStateSelection("python", [ + "inspect-setup", + ]); + const outcomes = Promise.allSettled([pin, inspection]); + firstMkdir.reject(new Error("synthetic fallback failure")); + const [pinOutcome, inspectionOutcome] = await outcomes; + expect(pinOutcome).toMatchObject({ + status: "rejected", + reason: expect.objectContaining({ + message: "synthetic fallback failure", + }), + }); + expect(inspectionOutcome).toEqual({ + status: "fulfilled", + value: { stateDir: undefined }, + }); + expect(mkdirCalls).toBe(1); + + await expect(bundled.pinFallbackWorkbenchStateDir()).resolves.toBe( + "/fallback/workbench-state", + ); + expect(mkdirCalls).toBe(2); + expect( + await bundled.executeWorkbenchWithStateSelection("python", [ + "inspect-setup", + ]), + ).toEqual({ stateDir: "/fallback/workbench-state" }); +});