From bc7cc8fa0e5809a846763751d6c31c1ff15ea3d7 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:00:52 +0800 Subject: [PATCH] refactor: consolidate runtime acceptance and initial state --- AGENTS.md | 4 +- CHANGELOG.md | 18 + Documentation/Architecture/Package.md | 45 +- .../Architecture/VersioningAndRelease.md | 7 +- Documentation/README.md | 4 +- Documentation/Reference/HostIntegration.md | 80 ++ Documentation/Reference/Installation.md | 8 - Documentation/Reference/Release.md | 77 ++ Documentation/Reference/StateMigration.md | 93 -- Documentation/Reference/Workflows.md | 49 +- Package.swift | 4 +- README.md | 17 +- Scripts/check-host.py | 982 ++++++++++++++++++ Sources/CodexAdapter/CodexDatabase.swift | 126 +-- Sources/CodexAdapter/CodexExecRuntime.swift | 8 +- Sources/CodexAdapter/CodexRuntimeLease.swift | 23 - .../CodexAdapter/CodexStateMigration.swift | 526 ---------- Sources/CodexMCPAdapter/CodexMCPAdapter.swift | 44 +- .../CodexAppServerProviderTests.swift | 9 + .../CodexDatabaseSchemaTests.swift | 90 ++ .../CodexDatabaseTests.swift | 25 - .../CodexExecRuntimeTests.swift | 21 +- .../CodexStateMigrationTests.swift | 457 -------- Tests/CodexAdapterTests/Fixtures/README.md | 13 + .../Fixtures/codex-schema.json | 152 +++ Tests/CodexMCPAdapterTests/CommandTests.swift | 20 - Tests/Fixtures/NativeCodex/HostToolVendor.py | 67 ++ Tests/Fixtures/NativeCodex/ModelServer.py | 115 ++ Tests/Fixtures/NativeCodex/README.md | 27 + Tests/test_host_acceptance.py | 244 +++++ 30 files changed, 2037 insertions(+), 1318 deletions(-) delete mode 100644 Documentation/Reference/StateMigration.md create mode 100644 Scripts/check-host.py delete mode 100644 Sources/CodexAdapter/CodexStateMigration.swift create mode 100644 Tests/CodexAdapterTests/CodexDatabaseSchemaTests.swift delete mode 100644 Tests/CodexAdapterTests/CodexStateMigrationTests.swift create mode 100644 Tests/CodexAdapterTests/Fixtures/README.md create mode 100644 Tests/CodexAdapterTests/Fixtures/codex-schema.json create mode 100644 Tests/Fixtures/NativeCodex/HostToolVendor.py create mode 100644 Tests/Fixtures/NativeCodex/ModelServer.py create mode 100644 Tests/Fixtures/NativeCodex/README.md create mode 100644 Tests/test_host_acceptance.py diff --git a/AGENTS.md b/AGENTS.md index 8caf73f..6430c9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,9 @@ version-specific CLI exports and distinguish them from the client SDK snapshot. Do not install or bundle vendor Codex binaries. Keep generated schema provenance. Use Swift Testing. Run strict swift-format lint, swift build, swift test, schema -drift checks, and an isolated standard MCP client before handoff. Logs must never +drift checks, and an isolated standard MCP client before handoff. Use `Documentation/Reference/Release.md` for packaged runtime acceptance through +the installed host; run `Scripts/check-host.py --help` and its Python unit tests +when changing that driver. Logs must never write to the protocol stdout. Do not publish repositories or releases implicitly. ## Brand delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index 244cbac..eee2a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## Unreleased +### Added + +- Public-host runtime acceptance for the packaged adapter and installed Codex, + including native permissions, lifecycle recovery, thread handoff, authenticated + model execution and exact owned-process cleanup. +- Runtime operations, diagnostics, ownership and troubleshooting guidance in the + plugin manual. + +### Changed + +- Adapter databases initialize from one complete schema. Unknown migration + histories fail explicitly and require a new state directory. + ### Removed - Windows support; the plugin ships only for macOS. +- Offline domain-state migration command and import workflow. + +### Fixed + +- Valid upstream Exec session identifiers are accepted by release builds. diff --git a/Documentation/Architecture/Package.md b/Documentation/Architecture/Package.md index 2592912..70689ef 100644 --- a/Documentation/Architecture/Package.md +++ b/Documentation/Architecture/Package.md @@ -190,8 +190,12 @@ processes before declaring an uncoordinated owner gone. `CodexDatabase` stores approvals, runtime leases, thread ownership, reconciliation receipts, acceptance runs, worktree leases and managed-worktree receipts using their existing row and JSON representations. -It reuses GRDB transactions and lease-state compatibility handling; it does +It uses one initial domain schema and GRDB transactions; it does not store host profiles, credentials, workspace registrations or audit. +Unknown migration identifiers fail before either the domain or shared-lease +store is initialized. Use a fresh adapter state directory for an incompatible +schema. The separate shared-lease database initializes only its lease table and +index. Host-launched storage is partitioned by verified subject, profile and registered workspace, not a transient connection or channel. Reconnection retains this identity. Worktree lease facts use one transactional store per subject and @@ -233,6 +237,45 @@ retrying metadata only. Provision rollback preserves Git content when host cleanup is unconfirmed; it never represents that case as a complete rollback. See [Host integration](../Reference/HostIntegration.md) for the operator contract. +## Runtime generations and teardown + +Each workspace connection owns a lazy App Server runtime with a stable +`runtime_id`. Its current connection has a separate generation, process group, +App Server PID, supervisor PID, parent PID, timestamps, exit status, signal and +termination escalation. Concurrent first calls share startup. Notifications +and server requests belong to their exact generation; reconnecting does not +transfer ownership of earlier work. + +Shutdown joins notification/request consumers, performs bounded unsubscription, +marks pending approval records interrupted, and closes the transport. Teardown +closes stdin, waits for graceful exit, sends TERM to the exact owned process +group if necessary, then KILL after another bounded interval and records reaping. +A private supervisor watches the owner's death and applies the same cleanup. +No process is selected by executable name or a machine-wide search. + +The normal request timeout covers startup, workspace validation, the native +RPC and at most one fresh-generation read-only retry. `app/list` has a dedicated +budget for its potentially large directory snapshot and never restarts to retry +that snapshot. A timed-out generation must be retired and reaped before retry. +Concurrent closes share retirement, including back-pressured stdin. Runtime +lifecycle, connection state, process state, active-request count, last request +failure and terminal shutdown reason are independent; a request timeout does +not itself mean the runtime stopped. + +Recent-thread supervision reads native state read-only, validates the canonical +workspace and rollout root, and scans a bounded tail. Snapshot-bound cursors, +page and Goal-scan bytes, record count, output bytes and elapsed scan budget are +reported. Native thread/Goal persistence remains separate from adapter ownership. + +A native Goal, an adapter acceptance run and a completed turn have distinct +lifetimes. Official Desktop, IDE, CLI and Remote connections own their own +processes and authority. The adapter cannot unsubscribe or stop those clients. +Native approvals preserve correlation, deadlines and terminal states; a lost +connection never becomes consent or permission to replay. Account/configuration, +marketplace and remote-pairing operations require the appropriate local control +scope. Retained diagnostic data is bounded and credential-redacted; native +payload preservation does not make raw output safe to publish. + ## API and verification scope The Exec and App Server runtime tests execute in this package. diff --git a/Documentation/Architecture/VersioningAndRelease.md b/Documentation/Architecture/VersioningAndRelease.md index 1c61389..3724dfa 100644 --- a/Documentation/Architecture/VersioningAndRelease.md +++ b/Documentation/Architecture/VersioningAndRelease.md @@ -32,8 +32,11 @@ immutable CI candidate artifact. Check the relocated candidate with a standard MCP client and `Scripts/check-workflow.py --adapter … --codex …`. The latter uses an isolated home and fixed loopback model for native approvals, turns, cancellation, reconnection and owned-process cleanup. It does not prove real -model authentication. Host integration uses the Computer MCP repository's fixed -installed-gateway checks against the exact adapter bytes. +model authentication. Host integration uses this repository's `Scripts/check-host.py` against the +installed Computer MCP CLI, relocated adapter bytes and installed vendor. Full +acceptance includes authenticated model execution; fixture-only evidence cannot +replace it. [Release](../Reference/Release.md#runtime-acceptance) owns the commands +and prerequisites. Accept the complete host/plugin/SDK combination before delivery. Create a formal signed tag only for the accepted commit; `release.yml` promotes the diff --git a/Documentation/README.md b/Documentation/README.md index d8191da..8c4e7ce 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -11,9 +11,7 @@ - [Host integration](Reference/HostIntegration.md): scoped callbacks, host authorization, managed registrations and failure recovery. - [Installation](Reference/Installation.md): relocatable artifacts, host settings, update/rollback/removal, and distribution boundaries. -- [State migration](Reference/StateMigration.md): offline transfer of - adapter-owned records from a Computer MCP snapshot. -- [Release](Reference/Release.md): publishing a release and notifying the +- [Release](Reference/Release.md): runtime acceptance, publishing a release and notifying the plugin catalog. The [root README](../README.md) is the public setup and command manual. diff --git a/Documentation/Reference/HostIntegration.md b/Documentation/Reference/HostIntegration.md index 0de4a94..c58588e 100644 --- a/Documentation/Reference/HostIntegration.md +++ b/Documentation/Reference/HostIntegration.md @@ -113,3 +113,83 @@ The host permits 1–1000 requested records and bounds the complete service resu Reduce `limit` when the response would exceed the byte limit. Inspection does not start Codex or a model. Real vendor/model authentication and GUI permissions are separate checks from the host callback protocol. + +Use the host's generic tool command with the exported name. The examples assume +an empty MCP registration prefix; prepend the configured prefix when present. +IDs come from the registered workspace and returned native thread: + +```sh +computer-mcp tools call codex.app.handoff.diagnose --arguments-json \ + '{"workspace_id":"","thread_id":"","observed_error":""}' +computer-mcp tools call codex.diagnostics.snapshot --arguments-json \ + '{"workspace_id":"","limit":100}' +computer-mcp tools call codex.app.thread.release --arguments-json \ + '{"workspace_id":"","thread_id":""}' +computer-mcp tools call codex.app.thread.recent --arguments-json \ + '{"workspace_id":"","thread_id":""}' +``` + +When calling indirectly with `mcp.tools.call`, put `workspace_id` beside +`server`, `tool` and `arguments`; only native plugin arguments go inside +`arguments`. Native names and exported host names need not be identical. + +The handoff diagnosis distinguishes these classifications: + +| Classification | Operator action | +| --- | --- | +| `computer_mcp_owned` | Inspect the exact reported runtime and resolve pending approval/input. Release the thread; stop the exact runtime only when all its work should end. | +| `released_persisted` | No live adapter writer is reported. Open the persisted thread in the intended client. | +| `persisted_or_external` | No live adapter owner is verified. Reclaim only when the adapter should deliberately become the writer. | +| `external_writer_or_unfinished_watchdog` | Review stale receipts and allow bounded owned-process shutdown to finish. An external owner is inferred, never signalled. | + +A successful unsubscribe alone does not prove release. Require the final +`released_persisted` classification, `externally_claimable: true` and +`computer_mcp_writer_ownership_remaining: false`. If a loaded receipt remains +once its exact runtime and process are gone, preview ownership reconciliation +and apply the reviewed digest through local control. A writer conflict in +another client must be resolved through that client's controls. Never use +`killall` or delete session files to bypass ownership. + +### Execution or permissions fail + +Inspect App Server with `codex.app.status` and Exec with `codex.exec.list` +independently. The host's provider/package doctor checks availability; it does +not prove model authentication. Verify the selected executable and native +configuration, including personal provider, MCP, Skills and hooks settings. +A slow or broken personal integration can delay startup. Diagnose credentials +in Codex's own store rather than copying them into host configuration. +An unavailable custom program fails explicitly; it never falls back to a +different executable. For a non-Git initial directory, select Exec's native +`options.skip_git_repo_check` explicitly. + +Thread and turn responses report applied native permissions. Omitted settings +inherit Codex configuration; an explicit override can change the sandbox or +approval policy. A host profile name is not evidence of effective Full Access. +A host denial prevents invocation; it never silently chooses a narrower native +mode. Full Access cannot grant host callbacks extra capabilities, workspace +access, tickets or macOS privacy permissions. + +The installed vendor must support the requested stable or explicitly enabled +experimental API. Incompatible methods fail explicitly. App Server failure +does not silently switch execution to Exec. + +### Slow history or directory requests + +Use the bounded history pages in [Workflows](Workflows.md#reading-long-threads), +or `thread.recent` for a persisted tail. Its `bounds` report page and Goal-scan +I/O, output bytes, elapsed scan and cooperative budget exhaustion; +`full_history_loaded` is false. Follow `next_before_cursor` only for older +context and restart from the newest page if the rollout identity changed. + +`app/list` has its own configured deadline and uses one connection generation; +other calls use the normal end-to-end request budget. A directory HTTP 403 +reported by `codex.app.request_failed` is an upstream response, not evidence +that local workspace content or the host was classified as hostile. It remains +a failed acceptance check. Inspect network and account availability separately. + +If Safari or interactive Codex works but the adapter's directory request fails, +check fixed macOS HTTP/HTTPS/SOCKS proxy settings and restart the owned adapter +connection. Explicit inherited proxy values are preserved; otherwise the adapter +maps fixed system proxies to conventional uppercase/lowercase child variables +with loopback bypass. Derived proxy values are not persisted or logged. PAC +scripts are not evaluated. diff --git a/Documentation/Reference/Installation.md b/Documentation/Reference/Installation.md index 0b75aad..1bb4143 100644 --- a/Documentation/Reference/Installation.md +++ b/Documentation/Reference/Installation.md @@ -121,11 +121,3 @@ when operating a disposable test host. Without it these commands target the normal App. Preserve the configured adapter state directory during an update or rollback; uninstalling a package artifact does not authorize deleting independently configured execution records or vendor state. - -## Domain-state migration - -Use the explicit [offline state migration](StateMigration.md) command to preview -and import prior Codex domain records. Installation and ordinary adapter startup -do not read or migrate the host database. The migration command has its own -snapshot, conflict, transaction and recovery requirements; it does not perform -the host-service cutover. diff --git a/Documentation/Reference/Release.md b/Documentation/Reference/Release.md index 61fab94..9276d93 100644 --- a/Documentation/Reference/Release.md +++ b/Documentation/Reference/Release.md @@ -4,6 +4,83 @@ This guide covers publishing an accepted release and notifying the official plugin catalog. [Versioning and Release](../Architecture/VersioningAndRelease.md) owns the version, acceptance and immutability rules. +## Runtime acceptance + +Use macOS with the selected installed `computer-mcp` CLI, a separately installed +vendor Codex, Python 3.11 or newer, Git and the verified release archive. The +adapter must be the executable extracted from that archive. Record the host, +plugin and vendor combination; a package doctor proves configuration and files, +not model execution. Run the ordinary checks in [Contributing](../../CONTRIBUTING.md) +first. + +```sh +python3 Scripts/check-package.py --archive --receipt \ + --destination .build/accepted-package +python3 Scripts/check-workflow.py \ + --adapter "$PWD/.build/accepted-package/bin/codex-mcp-adapter" \ + --codex /absolute/path/to/codex +python3 Scripts/check-host.py \ + --host /absolute/path/to/computer-mcp \ + --adapter "$PWD/.build/accepted-package/bin/codex-mcp-adapter" \ + --codex /absolute/path/to/codex \ + --output-directory "$PWD/.build/host-acceptance" \ + --mode full --auth-file /absolute/path/to/vendor/auth.json --model +``` + +`check-host.py` uses only the host's public `serve stdio` command and standard +MCP. It starts private host instances with explicit workspace selection, host +configuration and databases. It does not contact the normal App control socket +or an existing App Server. The model fixture and all native generations are +owned by the run. The selected output directory must be new; after inspecting +its receipt and confirming cleanup, remove its disposable state before reusing +that location. + +Full mode needs a vendor file-store login authorized for the selected model. +The supplied `auth.json` is copied with mode `0600` into the private vendor home +and deleted on exit; the original is never changed. Keychain-only credentials +are not imported. The real-model checks make bounded text-only App Server and +Exec requests, including Exec resume, using a read-only native sandbox. +Model use may incur the account's ordinary usage charges. Keep the output +private because native histories and diagnostic evidence can include account +metadata. Never publish the authentication file or raw runtime state. + +For a deterministic native check without credentials, use `--mode fixture` and +omit `--auth-file` and `--model`. This still needs the installed host and real +vendor executable. It tests catalog/diagnostic laziness, explicit Skills +directories and app-list deadlines, inherited and explicit Full Access, a native +read-only override, Git hooks, loopback and outside-workspace access, workspace +process isolation, three-connection thread/Goal handoff, vendor/adapter crash +recovery without replay, native approval, fork/review/steer/interruption, Exec +options/events/results/resume/cancellation/release, and exact thread archiving. +A separate disposable App Server protocol peer exercises native host callbacks: +workspace/recursion denial, Git dry-run index preservation, prepare without +mutation, exact single-use host tickets, a complete hooked file/Git commit, and +rejection of a pending local-approval ticket. The standalone host's configured +policy authorizes the successful ticket path; local App approval resolution is +verified by the host's generic tests, not simulated as an App UI check here. +It cannot prove real authentication or model availability. + +The driver writes `report.json` with step results, binary versions/digests, +fixture digest, authentication status and cleanup errors. It separately records +bounded protocol evidence. A nonzero exit, upstream directory failure, missing +step, changed binary digest or unconfirmed cleanup fails acceptance; no network +challenge is treated as an expected success. Failure injection verifies ancestry +before signalling an exact owned PID. Unknown write outcomes are never retried. +Thread archival accepts only success or an exact missing-rollout error for the +owned thread; unrelated errors remain failures. + +The release candidate requires the full report to pass for its exact executable +bytes, in addition to source tests and archive verification. Host plugin +installation, grants, local approval and uninstall remain the host's generic +acceptance responsibility; this driver validates the native runtime through a +public host registration. Keep those host checks with the accepted combination. + +Pure driver logic and argument validation run without a host or vendor: + +```sh +python3 -m unittest discover -s Tests -p 'test_host_acceptance.py' -v +``` + ## Publish 1. Confirm that `version` in `computer-mcp-plugin.toml` has not been released diff --git a/Documentation/Reference/StateMigration.md b/Documentation/Reference/StateMigration.md deleted file mode 100644 index d6af4a9..0000000 --- a/Documentation/Reference/StateMigration.md +++ /dev/null @@ -1,93 +0,0 @@ -# Offline state migration - -`codex-mcp-adapter migrate-state` transfers adapter-owned records from an offline -Computer MCP SQLite snapshot to a separate adapter database. It is an explicit -local administration command, not an MCP tool. It does not launch Codex, call a -model, stop a host, switch providers, or change any authorization. - -## Preconditions and scope - -Finish or release owned execution through its normal lifecycle before taking the -source snapshot. Stop the destination adapter and keep both files unchanged for -preview and apply. Use SQLite's [backup API](https://sqlite.org/backup.html) or an -appropriate checkpointed offline copy; copying the main file of a live WAL -database alone is not a complete snapshot. The command rejects `-wal`, `-shm` -and `-journal` sidecars. Their absence is not proof that all processes have -stopped: the operator or a future host cutover transaction must establish that. - -The source is opened read-only. Only these domain tables are transferred: - -- `codexApprovals`, `codexRuntimeLeases`, `codexThreadOwnership`; -- `codexOwnershipReconciliationReceipts`, `codexOrchestrationRuns`; -- `codexWorktreeLeases`, `codexManagedWorktrees`. - -Host grants (including elevation), credentials, profiles, workspace registration, -audit, plugin state and vendor `CODEX_HOME` data are not transferred. A file hash -binds the complete source snapshot, but reports do not include stored row values. -The source database is never migrated, reconciled, or deleted by this command. -Older snapshots may omit domain tables; unknown columns and malformed records are -rejected rather than silently dropping data. Indexed record identity and scope -must agree with the decoded payload. Unknown payload JSON fields are preserved. - -The destination may be absent or already initialized by this adapter. An existing -destination must contain only current adapter tables and its own migration -history, without triggers. The command refuses a host database, a symbolic-link -file, or source and destination aliases referring to the same file. - -## Preview and apply - -```sh -codex-mcp-adapter migrate-state \ - --source-snapshot /absolute/offline/gateway.sqlite \ - --destination /absolute/adapter-state/codex.sqlite -``` - -Preview writes no destination database. Review the per-table insert, identical -and conflict counts and the `plan_digest`. Apply that exact plan: - -```sh -codex-mcp-adapter migrate-state \ - --source-snapshot /absolute/offline/gateway.sqlite \ - --destination /absolute/adapter-state/codex.sqlite \ - --apply --expected-plan-digest DIGEST -``` - -`--apply` and `--expected-plan-digest` must be supplied together. Source changes, -destination record changes or a replaced destination invalidate the plan. A -conflicting identity never overwrites a destination record. The existing-database -import rechecks the plan and writes all tables in a single transaction, so even a -late uniqueness or I/O failure cannot leave a partially imported set of rows. -`can_apply` describes the preview's record-conflict result; apply still validates -current state, database constraints and filesystem availability. - -A new destination is built in a private sibling staging directory and published -without replacing an existing file only after the complete import closes. Its -parent directory must already exist. The database is created with owner-only -permissions. Repeating a fresh preview/apply is idempotent: byte-identical rows -are counted but not rewritten. Reusing a pre-import digest after the destination -has changed is deliberately rejected. Text comparison uses UTF-8 bytes rather -than Swift canonical Unicode equivalence, and invalid UTF-8 is rejected. - -The bounded operation supports a source file up to 256 MiB, 100,000 domain rows, -and 64 MiB of encoded domain values. Exceeding a bound fails explicitly; it never -copies a truncated prefix of the source. - -## Recovery and cutover boundary - -On a rejected plan or failed transaction, inspect the error and produce another -preview after resolving the cause. Do not delete conflicting destination records -or change a digest merely to force the operation through. Preserve the source -snapshot and the JSON result as migration evidence. - -This operation copies records, not live authority. It neither resurrects pending -approval requests nor claims a live thread writer, renews a lease, registers a -workspace, or restores an elevation grant. The separate host-service integration -and provider cutover must preserve their own authorization and ownership checks. -No automatic production cutover is performed here. - -Before the new adapter writes additional state, reverting to the unchanged -source remains a separate reviewed host operation. After new writes, reconcile -those records before rollback; blindly restoring the source snapshot would lose -new work. A process crash can leave a private `.codex-state-*` staging directory; -inspect and remove only that operation's owned directory, never the source or -independently configured adapter state. diff --git a/Documentation/Reference/Workflows.md b/Documentation/Reference/Workflows.md index f6bfb47..0b43e97 100644 --- a/Documentation/Reference/Workflows.md +++ b/Documentation/Reference/Workflows.md @@ -211,8 +211,49 @@ This is adapter domain ownership, not a replacement for Codex's native sandbox. An existing root `codex.sqlite` is unbound history. Diagnostics report `state_storage.unbound_history_available` without assigning it to the current -subject. Keep the source intact. A local operator must review the source and -destination subject before using the offline migration workflow against the -chosen destination database; do not import live records or treat unknown -ownership as permission to resume execution. Native read-only history continues +subject. Keep the source intact. Unknown ownership is not permission to resume execution. Native read-only history continues to use Codex's configured home and does not create execution ownership. + +## Acceptance runs and writer leases + +Official Goals use native get/set/clear methods and status values. `codex.run.*` +provides a separate durable acceptance layer: criteria, evidence, evaluation, +explicit acceptance, budgets, pause/cancel transitions, contradictions and +selected child reconciliation. It may reference a native Goal without treating +its own state as native Goal state. Turn completion can leave both active. + +`codex.worktree.leases.*` records mutation ownership, heartbeat, release and +conflicts. Receipt cleanup does not delete a worktree. Managed provisioning is +two steps: `provision.plan` validates the source repository, active parent lease, +branch, start commit, derived path, profile and lineage; `provision.perform` +consumes the reviewed revision before the five-minute plan expires. It creates +the child, requests host registration and the profile grant, and acquires the +isolated lease. Reconnect to select the newly registered child before starting +a leased turn there. + +Removal planning binds the receipt, Git common directory and HEAD. Perform +requires the current revision, explicit confirmation and the host's executing +operation ticket. The child lease must be inactive and the worktree clean with +no live child runtime. Paths must be real current-user-owned directories under +the canonical managed root. A replacement symlink or independently created +worktree is not a cleanup target. Ordinary removal preserves the branch. + +## Runtime inspection and handoff + +`codex.app.runtimes.list`, `inspect` and `history` expose owned runtime evidence; +`codex.app.runtimes.cleanup.preview` and `perform` review stale runtime receipts. +Use the discovered schema to select an exact runtime for stopping. +`codex.app.ownership.reconcile.preview` and local-only `perform` repair a +digest-bound set of loaded receipts only after the referenced runtime and its +exact process/supervisor are proven gone. This changes adapter receipts and +sends no signals. + +Release serializes against new thread/turn starts and checks every matching +owned runtime. It requires `final_classification: "released_persisted"`, +`externally_claimable: true` and `computer_mcp_writer_ownership_remaining: false`. +Repeated release is idempotent. Reclaim deliberately resumes a persisted +thread after validating its workspace ownership receipt or the official +persisted thread index; a competing writer remains an error. Live evidence +outranks durable receipts. `codex.app.handoff.diagnose` correlates the runtime, +process, connection, thread, turn, approvals, user input and durable receipts +without claiming control over an external client. diff --git a/Package.swift b/Package.swift index 807baff..b886f71 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,8 @@ let package = Package( dependencies: [ "CodexAdapter", .product(name: "ArgumentParser", package: "swift-argument-parser"), ]), - .testTarget(name: "CodexAdapterTests", dependencies: ["CodexAdapter"]), + .testTarget( + name: "CodexAdapterTests", dependencies: ["CodexAdapter"], + resources: [.copy("Fixtures")]), .testTarget(name: "CodexMCPAdapterTests", dependencies: ["CodexMCPAdapter"]), ]) diff --git a/README.md b/README.md index aa59b57..e7ba864 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ Explicit full-history reads remain available through `include_turns: true`; large histories can exceed transport, output or timeout limits. See the [long-thread workflow](Documentation/Reference/Workflows.md#reading-long-threads). +App Server, Exec, native Goals, acceptance runs, thread handoff and managed +worktrees are experimental and opt-in. Execution depends on a separately +installed, authenticated Codex version that supports the requested methods. +A completed turn does not imply Goal completion or acceptance. The adapter +cannot inspect or terminate another client's connection or processes. +Native Full Access uses the executing user's OS authority; host callbacks +still require their own grants. See [diagnosis and recovery](Documentation/Reference/HostIntegration.md#diagnostics) +and [release acceptance](Documentation/Reference/Release.md#runtime-acceptance). + ## Build and run Building requires macOS 14 or newer and Swift 6.2 or newer. @@ -123,11 +132,3 @@ JSON Pointers, and required-parameter differences. It does not run Codex. The adapter and swift-codex use the same vendor schema baseline. The installed Codex executable can differ; declaration coverage does not establish runtime support. Unsupported vendor methods fail explicitly. - -### Offline domain-state migration - -`codex-mcp-adapter migrate-state` previews a bounded, read-only source snapshot -and applies only its reviewed Codex domain records to adapter-owned persistence. -It preserves host authorization ownership and never launches a model. See -[State migration](Documentation/Reference/StateMigration.md) for snapshot, -conflict, atomicity and cutover boundaries. diff --git a/Scripts/check-host.py b/Scripts/check-host.py new file mode 100644 index 0000000..1b3e30e --- /dev/null +++ b/Scripts/check-host.py @@ -0,0 +1,982 @@ +#!/usr/bin/env python3 +"""Accept a packaged adapter through an installed Computer MCP CLI's public MCP surface. + +Fixture mode uses real native Codex with a private loopback Responses server. +Full mode additionally requires authenticated model execution. All host instances, +workspaces, runtime homes and failure injection targets are private to this run. +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import queue +import re +import signal +import subprocess +import sys +import threading +import time +import uuid + +MODEL_SOURCE = Path(__file__).resolve().parents[1] / "Tests/Fixtures/NativeCodex/ModelServer.py" +MAX_MESSAGE = 8_388_608 + + +def require(condition, detail): + if not condition: + raise AssertionError(detail) + + +def executable(value): + path = Path(value) + if not path.is_absolute() or not path.is_file() or not os.access(path, os.X_OK): + raise argparse.ArgumentTypeError("Expected an existing absolute executable file") + return path.resolve() + + +def absolute_file(value): + path = Path(value) + if not path.is_absolute() or not path.is_file(): + raise argparse.ArgumentTypeError("Expected an existing absolute file") + return path.resolve() + + +def timeout_seconds(value): + try: + number = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("Timeout must be an integer") from error + if not 1 <= number <= 900: + raise argparse.ArgumentTypeError("Timeout must be between 1 and 900 seconds") + return number + + +def parse_arguments(arguments=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", required=True, type=executable, help="Installed computer-mcp CLI") + parser.add_argument("--adapter", required=True, type=executable, help="Adapter from the verified, relocated archive") + parser.add_argument("--codex", required=True, type=executable, help="User-installed vendor Codex") + parser.add_argument("--output-directory", required=True, type=Path, help="New private directory in ignored build output") + parser.add_argument("--mode", choices=("fixture", "full"), default="full") + parser.add_argument("--auth-file", type=absolute_file, help="Full mode: existing vendor auth.json; copied privately and deleted on exit") + parser.add_argument("--model", help="Full mode: authenticated model ID") + parser.add_argument("--timeout-seconds", type=timeout_seconds, default=180) + options = parser.parse_args(arguments) + root = options.output_directory + if not root.is_absolute() or root.exists() or root.is_symlink(): + parser.error("--output-directory must be a new absolute path") + if options.mode == "full" and (not options.auth_file or not options.model or not options.model.strip()): + parser.error("Full mode requires --auth-file and --model") + if options.mode == "fixture" and (options.auth_file or options.model): + parser.error("Authentication inputs require full mode") + if options.model and any(c in options.model for c in "\0\r\n"): + parser.error("Model ID must be a single nonempty line") + return options + + +def unwrap(result, indirect=False): + for _ in range(2 if indirect else 1): + require(isinstance(result, dict), "Malformed MCP tool result") + if result.get("isError"): + raise RuntimeError(json.dumps(result, ensure_ascii=False)) + result = result["structuredContent"]["result"] + return result + + +def archive_disposition(result, thread_id): + if not isinstance(thread_id, str) or not thread_id or any(c.isspace() for c in thread_id): + return "failed" + if not result.get("isError"): + return "archived" if isinstance(result.get("structuredContent", {}).get("result"), dict) else "failed" + # A missing rollout is only terminal for this exact ID and this exact native error. + error = result.get("structuredContent", {}).get("error", {}) + detail = error.get("message") + marker = "no rollout found for thread id " + thread_id + if isinstance(detail, str) and (detail == marker or detail.endswith(": " + marker)): + return "already-absent" + return "failed" + + +def completed_turn(page, thread_id, turn_id, expected_status): + require(page["missed_events"] == 0, "Event cursor lost evidence") + for row in page["events"]: + payload = row.get("payload", {}) + params = payload.get("params", {}) + if (payload.get("method") == "turn/completed" and params.get("threadId") == thread_id + and params.get("turn", {}).get("id") == turn_id): + require(params["turn"]["status"] == expected_status, params["turn"]) + return True + return False + + +def command_exit(output): + match = re.search(r"Process exited with code (-?[0-9]+)", output) + require(match is not None, "Native command output lacks an exit status") + return int(match.group(1)) + + +def wait_until(check, timeout, label): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = check() + if value: + return value + time.sleep(0.05) + raise TimeoutError(label + "; outcome is unknown, mutations must not be replayed") + + +def exited(pid): + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + return False + + +def descendant(pid, ancestor, parent): + seen = set() + while pid != ancestor: + if not isinstance(pid, int) or pid <= 1 or pid in seen or len(seen) >= 32: + return False + seen.add(pid) + pid = parent(pid) + return bool(seen) + + +def process_parent(pid): + result = subprocess.run(["/bin/ps", "-p", str(pid), "-o", "ppid="], capture_output=True, + text=True, timeout=3, check=False) + return int(result.stdout.strip()) if result.returncode == 0 and result.stdout.strip().isdigit() else None + + +class MCPClient: + def __init__(self, process, evidence, timeout): + self.process, self.evidence, self.timeout = process, evidence, timeout + self.sequence = 0 + self.queue = queue.Queue(maxsize=1024) + self.failure = None + self.reader = threading.Thread(target=self.read, daemon=True) + self.reader.start() + + def read(self): + try: + while True: + line = self.process.stdout.readline(MAX_MESSAGE + 1) + if not line: + raise EOFError("Host MCP stdout closed") + require(len(line.encode()) <= MAX_MESSAGE, "MCP message exceeded acceptance bound") + self.queue.put_nowait(json.loads(line)) + except Exception as error: + self.failure = error + + def send(self, method, params, notification=False, timeout=None): + self.sequence += 1 + message = {"jsonrpc": "2.0", "method": method, "params": params} + if not notification: + message["id"] = self.sequence + self.process.stdin.write(json.dumps(message) + "\n") + self.process.stdin.flush() + if notification: + return + deadline = time.monotonic() + (timeout or self.timeout) + while time.monotonic() < deadline: + try: + response = self.queue.get(timeout=min(0.1, max(0.01, deadline - time.monotonic()))) + except queue.Empty: + if self.failure: + raise self.failure + continue + if response.get("id") != message["id"]: + require("method" in response and "id" not in response, "Unexpected MCP response/request") + continue + self.evidence.write(json.dumps({"request": message, "response": response}) + "\n") + self.evidence.flush() + require(self.evidence.tell() <= 32 * 1024 * 1024, "Protocol evidence exceeded acceptance bound") + require("error" not in response, response) + return response["result"] + raise TimeoutError(method + "; result unknown, no write retry") + + def initialize(self): + self.send("initialize", {"protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "codex-host-acceptance", "version": "1"}}) + self.send("notifications/initialized", {}, notification=True) + catalog, cursor, seen = {}, None, set() + for _ in range(100): + page = self.send("tools/list", {"cursor": cursor} if cursor else {}) + for tool in page["tools"]: + require(tool["name"] not in catalog, "Duplicate catalog tool") + catalog[tool["name"]] = tool + cursor = page.get("nextCursor") + if not cursor: + return catalog + require(cursor not in seen, "Cyclic tool catalog") + seen.add(cursor) + raise RuntimeError("Unbounded tool catalog") + + +APP_READ = ["status", "methods.list", "models.list", "skills.list", "apps.list", "events.read", + "thread.list", "thread.loaded.list", "thread.read", "thread.recent", "goal.get", + "approvals.list", "requests.list", "handoff.diagnose", "runtimes.history"] +APP_WRITE = ["thread.start", "thread.fork", "thread.reclaim", "thread.release", "turn.start", + "turn.steer", "turn.interrupt", "goal.set", "goal.clear", "review.start", "methods.call", + "approvals.respond", "requests.respond", "runtime.stop"] +RISKS = {**{"codex.app." + name: "read-only" for name in APP_READ}, + **{"codex.app." + name: "full-shell" for name in APP_WRITE}, + **{"codex.exec." + name: "read-only" for name in ["list", "events", "result"]}, + **{"codex.exec." + name: "full-shell" for name in ["start", "resume"]}, + "codex.exec.cancel": "destructive", "codex.exec.release": "workspace-write", + "codex.diagnostics.snapshot": "read-only", "codex.run.list": "read-only", + "codex.worktree.managed.list": "read-only", "codex.worktree.leases.list": "read-only", + "codex.worktree.leases.acquire": "workspace-write", "codex.worktree.leases.release": "workspace-write"} + + +class Host: + def __init__(self, fixture, indirect=True): + self.fixture, self.indirect = fixture, indirect + self.workspace = "primary" + fixture.generation += 1 + self.log = (fixture.root / f"host-{fixture.generation}.log").open("w") + self.evidence = (fixture.root / f"protocol-{fixture.generation}.jsonl").open("w") + self.process = subprocess.Popen([str(fixture.options.host), "serve", "stdio", "--config", + str(fixture.root / "host.toml"), "--database", str(fixture.root / "host.sqlite")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.log, + text=True, bufsize=1, env=fixture.environment, cwd=fixture.root) + fixture.hosts.append(self) + self.client = MCPClient(self.process, self.evidence, fixture.options.timeout_seconds) + self.catalog = self.client.initialize() + require(set(RISKS) <= self.catalog.keys(), {"missing_tools": sorted(set(RISKS) - self.catalog.keys())}) + require(self.catalog["mcp.tools.call"]["inputSchema"]["properties"]["workspace_id"]["type"] == "string", + "Generic dispatch lacks workspace selection") + + def raw(self, name, arguments=None, timeout=None): + name = name if name.startswith("codex.") else "codex.app." + name + arguments = arguments or {} + require(set(arguments) <= self.catalog[name]["inputSchema"]["properties"].keys(), {"tool": name, "undeclared_inputs": sorted(set(arguments) - self.catalog[name]["inputSchema"]["properties"].keys())}) + if self.indirect: + params = {"name": "mcp.tools.call", "arguments": {"server": "codex", "tool": name, + "workspace_id": self.workspace, "arguments": arguments}} + else: + params = {"name": name, "arguments": dict(arguments, workspace_id=self.workspace)} + result = self.client.send("tools/call", params, timeout=timeout) + return unwrap(result) if self.indirect else result + + def call(self, name, arguments=None, timeout=None): + return unwrap(self.raw(name, arguments, timeout)) + + def method(self, name, params=None, timeout=None): + return self.call("methods.call", {"method": name, "params": params or {}}, timeout) + + def close(self): + if self.process.poll() is None: + self.process.stdin.close() + try: + self.process.wait(timeout=15) + except subprocess.TimeoutExpired: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + raise RuntimeError("Host did not exit after MCP EOF") + self.client.reader.join(timeout=2) + self.log.close() + self.evidence.close() + require(self.process.returncode == 0, {"host_exit": self.process.returncode}) + + def processes(self): + status = self.call("status") + require(Path(status["workspace"]).resolve() == (self.fixture.root / self.workspace).resolve(), status) + pids = [status["process"][key] for key in ("process_id", "supervisor_process_id", "parent_process_id")] + require(all(isinstance(pid, int) and pid > 1 for pid in pids), status) + self.fixture.pids.update(pids) + return pids + + def release(self, thread, goal=False): + value = self.call("thread.release", {"thread_id": thread}) + require(value["final_classification"] == "released_persisted" and value["externally_claimable"] + and not value["computer_mcp_writer_ownership_remaining"], value) + if goal: + require(value["goal_preservation"] == "persisted-and-unchanged", value) + return value + + def turn(self, thread, expected="completed", approve=False, **arguments): + turn = self.call("turn.start", {"thread_id": thread, "prompt": "Run the isolated acceptance response.", + **arguments})["turn"]["id"] + self.wait_turn(thread, turn, expected, approve) + return turn + + def wait_turn(self, thread, turn, expected="completed", approve=False): + cursor, approvals = 0, 0 + def poll(): + nonlocal cursor, approvals + page = self.call("events.read", {"after_cursor": cursor, "max_results": 100}) + cursor = page["next_cursor"] + if approve: + for approval in self.call("approvals.list", {"state": "pending"})["approvals"]: + require(approval["thread_id"] == thread, "Unrelated native approval") + self.call("approvals.respond", {"approval_id": approval["id"], "response": {"decision": "accept"}}) + approvals += 1 + return completed_turn(page, thread, turn, expected) + wait_until(poll, self.fixture.options.timeout_seconds, "Native turn completion") + if approve: + require(approvals == 1, f"Expected one native approval, observed {approvals}") + + def exec_result(self, session, expected="completed"): + def poll(): + states = self.call("codex.exec.list")["sessions"] + current = next(row for row in states if row["session_id"] == session) + if current["state"] not in {"completed", "failed", "cancelled"}: + return None + value = self.call("codex.exec.result", {"session_id": session}) + require(value["state"] == expected and value["cleanup_confirmed"], value) + return value + return wait_until(poll, self.fixture.options.timeout_seconds, "Exec termination and cleanup") + + +class Fixture: + def __init__(self, options): + self.options, self.root = options, options.output_directory.resolve() + self.root.mkdir(mode=0o700, parents=True) + self.hosts, self.pids, self.threads, self.steps = [], set(), set(), [] + self.generation = 0 + self.model = None + self.model_log = None + self.home = self.root / "codex-home" + self.home.mkdir(mode=0o700) + # Private HOME also isolates Git, native skills, hooks and host configuration. + self.environment = {"HOME": str(self.root), "PATH": str(options.codex.parent) + ":/usr/bin:/bin", + "TMPDIR": str(self.root / "tmp"), "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1"} + (self.root / "tmp").mkdir(mode=0o700) + + def setup(self): + for name in ("primary", "other"): + (self.root / name).mkdir() + self.git(["init", "--quiet"], name) + for key, value in (("user.name", "Acceptance"), ("user.email", "acceptance@example.invalid"), + ("commit.gpgsign", "false"), ("core.hooksPath", str(self.root / name / ".git/hooks"))): + self.git(["config", key, value], name) + wrapper = self.root / "configured-codex" + wrapper.write_text(f'''#!{sys.executable} +import json, os, sys +keys = ("COMPUTER_MCP_HOST_CONTEXT", "COMPUTER_MCP_HOST_FD", "CODEX_THREAD_ID") +assert not any(key in os.environ for key in keys), "Parent authority leaked" +assert os.environ.get("COMPUTER_MCP_FIXTURE_VALUE") == "preserved", "Ordinary environment lost" +with open({str(self.root / 'launches.jsonl')!r}, "a") as receipt: + receipt.write(json.dumps({{"mode": sys.argv[1], "cwd": os.getcwd(), "argv": sys.argv[1:], "pid": os.getpid()}}) + "\\n") +os.execv({str(self.options.codex)!r}, [{str(self.options.codex)!r}] + sys.argv[1:]) +''') + wrapper.chmod(0o700) + self.response("initial") + (self.root / "network-token").write_text(uuid.uuid4().hex) + self.model_log = (self.root / "model.log").open("w") + self.model = subprocess.Popen([sys.executable, str(MODEL_SOURCE), str(self.root)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.model_log, + text=True, env=self.environment) + ready = queue.Queue() + threading.Thread(target=lambda: ready.put(self.model.stdout.readline(65536)), daemon=True).start() + self.port = json.loads(ready.get(timeout=10))["port"] + self.configure() + + def configure(self, sandbox="danger-full-access", approval="never", authenticated=False): + q = json.dumps + config = f'''model = {q(self.options.model if authenticated else "acceptance-fixture")} +cli_auth_credentials_store = "file" +approval_policy = {q(approval)} +sandbox_mode = {q(sandbox)} +web_search = "disabled" +''' + if not authenticated: + config += f'''model_provider = "fixture" +[model_providers.fixture] +name = "Isolated acceptance" +base_url = "http://127.0.0.1:{self.port}/v1" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +request_max_retries = 0 +stream_max_retries = 0 +''' + config += '[analytics]\nenabled = false\n[otel]\nmetrics_exporter = "none"\n' + (self.home / "config.toml").write_text(config) + (self.root / "adapter.json").write_text(json.dumps({ + "enabled": True, "executable": "configured-codex", "app_server_enabled": True, + "exec_enabled": True, "app_server_request_timeout_seconds": 30, + "app_server_app_list_timeout_seconds": 120, + })) + policies = ", ".join(q(name) + " = " + q(risk) for name, risk in RISKS.items()) + config = f'''schema_version = 1 +[runtime] +caller = "local-mcp" +profile = "acceptance" +[[profiles]] +id = "acceptance" +mode = "local-full-access" +confirmation_policy = "never" +full_shell_enabled = true +capabilities = ["mcp.tools.call", "workspace.list"] +workspaces = ["primary", "other"] +allowed_callers = ["local-mcp"] +''' + for name in ("primary", "other"): + config += f'''[[workspaces]] +id = {q(name)} +display_name = {q(name)} +path = {q(str(self.root / name))} +''' + config += f'''[[mcp.servers]] +id = "codex" +transport = "stdio" +command = {q(str(self.options.adapter))} +args = ["--config", {q(str(self.root / 'adapter.json'))}, "--state-directory", {q(str(self.root / 'adapter-state'))}] +exposure = "reexport" +prefix = "" +allowed_tools = {q(list(RISKS))} +host_services = true +tool_risks = {{ {policies} }} +startup_timeout_ms = 10000 +request_timeout_ms = {max(130, self.options.timeout_seconds) * 1000} +[mcp.servers.env] +CODEX_HOME = {q(str(self.home))} +PATH = {q(str(self.root) + os.pathsep + self.environment['PATH'])} +CODEX_THREAD_ID = "acceptance-parent-session" +COMPUTER_MCP_FIXTURE_VALUE = "preserved" +''' + (self.root / "host.toml").write_text(config) + + def git(self, args, workspace="primary"): + return subprocess.run(["/usr/bin/git", *args], cwd=self.root / workspace, env=self.environment, + text=True, capture_output=True, timeout=10, check=True).stdout + + def response(self, phase, command=None, hold=False): + temporary = self.root / "response.tmp" + temporary.write_text(json.dumps({"id": phase, "command": command, "hold_after_command": hold})) + temporary.replace(self.root / "response.json") + + def command_result(self, phase, success=True): + path = self.root / ("result-" + phase + ".json") + wait_until(path.exists, 15, "Native command receipt") + value = json.loads(path.read_text()) + require(value["type"] == "function_call_output", value) + require((command_exit(value["output"]) == 0) == success, value) + + def thread(self, host, params=None): + started = host.method("thread/start", params) + thread = started["thread"]["id"] + self.threads.add((host.workspace, thread)) + return thread, started + + def record(self, name): + self.steps.append(name) + print(json.dumps({"passed": name}), flush=True) + + def catalog_and_diagnostics(self): + host = Host(self) + require(not (self.root / "launches.jsonl").exists(), "Discovery started the vendor") + diagnostic = host.call("codex.diagnostics.snapshot", {"limit": 10}) + require(diagnostic["persistence_available"] and diagnostic["host_diagnostics_available"], diagnostic) + require(diagnostic["state_storage"]["scope"] == "authorization_subject", diagnostic) + require(not (self.root / "launches.jsonl").exists(), "Diagnostics started the vendor") + host.call("codex.run.list") + host.call("codex.worktree.managed.list") + host.call("codex.worktree.leases.list") + lease = host.call("codex.worktree.leases.acquire", {"agent_id": "acceptance"}) + host.call("codex.worktree.leases.release", {"lease_id": lease["id"], + "expected_revision": lease["revision"], "reason": "Acceptance complete"}) + require(not (self.root / "launches.jsonl").exists(), "Domain lease operations started the vendor") + host.close() + self.record("catalog-diagnostics-domain-leases-lazy-start") + + def directories(self): + host = Host(self) + started = time.monotonic() + skills = host.method("skills/list", {"cwds": [str(self.root / "primary")], "forceReload": False}, timeout=35) + require(time.monotonic() - started < 30, "skills/list exceeded normal deadline") + require(len(skills["data"]) == 1 and skills["data"][0]["cwd"] == str(self.root / "primary"), skills) + started = time.monotonic() + apps = host.method("app/list", {"limit": 1, "forceRefetch": False}, timeout=125) + require(time.monotonic() - started < 120 and len(apps["data"]) <= 1, apps) + host.processes() + host.close() + self.record("skills-explicit-directory-and-app-list-deadlines") + + def native_permissions(self): + for explicit in (False, True): + self.configure(sandbox="workspace-write" if explicit else "danger-full-access") + host = Host(self, indirect=explicit) + thread, started = self.thread(host, {"sandbox": "danger-full-access"} if explicit else {}) + require(started["sandbox"]["type"] == "dangerFullAccess", started) + phase = "explicit" if explicit else "inherited" + hook = self.root / "primary/.git/hooks/pre-commit" + hook.write_text("#!/bin/sh\n/usr/bin/touch .git/hook-ran\n") + hook.chmod(0o700) + self.response(phase, f"/usr/bin/printf '{phase}\\n' > {phase}.txt && " + f"/usr/bin/git add {phase}.txt && /usr/bin/git commit -m 'Native {phase} acceptance' && " + f"/usr/bin/curl --silent --show-error --max-time 3 http://127.0.0.1:{self.port}/probe > network.txt && " + "/usr/bin/printf 'outside' > ../outside-workspace.txt") + host.turn(thread) + self.command_result(phase) + require((self.root / "primary/.git/hook-ran").exists(), "Git hook did not execute") + require(self.git(["log", "-1", "--pretty=%s"]).strip() == f"Native {phase} acceptance", "Wrong Git commit") + require((self.root / "primary/network.txt").read_text() == (self.root / "network-token").read_text(), "Loopback denied") + require((self.root / "outside-workspace.txt").read_text() == "outside", "Explicit outside path denied") + (self.root / "primary/network.txt").unlink() + require(not self.git(["status", "--porcelain"]), "Native Git worktree not clean") + host.processes() + host.release(thread) + host.close() + self.configure() + host = Host(self) + thread, started = self.thread(host, {"sandbox": "read-only", "approvalPolicy": "never"}) + require(started["sandbox"]["type"] == "readOnly", started) + self.response("read-only", "/usr/bin/touch native-denied.txt") + host.turn(thread) + self.command_result("read-only", success=False) + require(not (self.root / "primary/native-denied.txt").exists(), "Read-only native request wrote a file") + host.processes() + host.release(thread) + host.close() + self.record("native-inherited-explicit-full-access-git-hook-loopback-outside-path-and-read-only") + + def handoff(self): + self.configure() + for indirect in (False, True): + thread, goal, processes = None, None, set() + self.response("handoff-" + str(indirect)) + for generation in range(3): + host = Host(self, indirect=indirect) + if thread: + host.call("thread.reclaim", {"thread_id": thread}) + else: + thread, _ = self.thread(host) + goal = host.call("goal.set", {"thread_id": thread, "objective": "Persist the acceptance Goal", "status": "paused"})["goal"] + host.turn(thread) + require(host.call("thread.read", {"thread_id": thread})["thread"]["id"] == thread, "Wrong persisted thread") + require(host.call("goal.get", {"thread_id": thread})["goal"] == goal, "Goal changed across handoff") + pids = host.processes() + require(pids[0] not in processes, "Native generation reused") + processes.add(pids[0]) + host.release(thread, goal=True) + host.close() + wait_until(lambda: all(exited(pid) for pid in pids), 15, "Released process exit") + history_host = Host(self, indirect=indirect) + history = history_host.call("runtimes.history")["runtimes"] + recorded = {row.get("process", {}).get("process_id") for row in history if row.get("process")} + require(processes <= recorded, "Runtime history lost process receipts") + require(all(row["state"] == "stopped" for row in history if row.get("process", {}).get("process_id") in processes), history) + history_host.close() + self.record("three-connection-thread-goal-handoff-direct-and-generic") + + def sibling_workspace(self): + self.response("siblings") + host = Host(self) + first, _ = self.thread(host) + host.turn(first) + first_pid = host.processes()[0] + host.workspace = "other" + other, _ = self.thread(host) + host.turn(other) + second_pid = host.processes()[0] + require(first_pid != second_pid, "Workspaces shared a native process") + host.workspace = "primary" + host.release(first) + require(not exited(second_pid), "Release stopped the sibling workspace") + host.workspace = "other" + require(host.call("thread.read", {"thread_id": other})["thread"]["id"] == other, "Sibling thread lost") + host.release(other) + host.close() + self.record("workspace-process-isolation-and-sibling-release") + + def recovery(self): + for crash_adapter in (False, True): + phase = "crash-adapter" if crash_adapter else "crash-vendor" + path = self.root / "primary" / (phase + ".txt") + host = Host(self) + thread, _ = self.thread(host) + goal = host.call("goal.set", {"thread_id": thread, "objective": "Preserve unfinished native work", "status": "paused"})["goal"] + self.response(phase, f"/usr/bin/printf 'once\\n' >> {phase}.txt", hold=True) + turn = host.call("turn.start", {"thread_id": thread, "prompt": "Run the isolated response."})["turn"]["id"] + self.command_result(phase) + require(path.read_text() == "once\n", "Write did not run once") + pids = host.processes() + target = pids[2 if crash_adapter else 0] + require(descendant(target, host.process.pid, process_parent), "Failure target is not owned by this host") + os.kill(target, signal.SIGKILL) + if not crash_adapter: + def retired(): + state = host.call("status") + return state["connection_state"] != "running" and state.get("last_request_failure") and all(exited(pid) for pid in pids[:2]) + wait_until(retired, 15, "Retirement after vendor crash") + host.close() + wait_until(lambda: all(exited(pid) for pid in pids), 15, "Crash watchdog cleanup") + replacement = Host(self) + replacement.call("thread.reclaim", {"thread_id": thread}) + require(replacement.call("goal.get", {"thread_id": thread})["goal"] == goal, "Crash changed Goal") + turns = replacement.call("thread.read", {"thread_id": thread, "include_turns": True})["thread"]["turns"] + recovered = next(row for row in turns if row["id"] == turn) + require(recovered["status"] not in {"inProgress", "completed"}, recovered) + require(path.read_text() == "once\n", "Unacknowledged write replayed") + self.response(phase + "-new", f"/usr/bin/printf 'new\\n' >> {phase}.txt") + require(replacement.turn(thread) != turn, "Recovery reused turn identity") + self.command_result(phase + "-new") + require(path.read_text() == "once\nnew\n", "Recovery duplicated or lost writes") + replacement.processes() + replacement.release(thread, goal=True) + replacement.close() + self.record("vendor-and-adapter-crash-recovery-without-write-replay") + + def app_lifecycle(self): + self.configure(sandbox="workspace-write", approval="on-request") + config = self.root / "adapter.json" + settings = json.loads(config.read_text()) + settings.update(sandbox="workspace-write", approval_policy="untrusted") + config.write_text(json.dumps(settings)) + host = Host(self) + thread = host.call("thread.start")["thread"]["id"] + self.threads.add(("primary", thread)) + (self.root / "primary/probe.sh").write_text("#!/bin/sh\n/usr/bin/printf 'native-approval-fixture\\n'\n") + self.response("native-approval", "/bin/sh ./probe.sh") + host.turn(thread, approve=True) + self.command_result("native-approval") + missing = host.raw("requests.respond", {"request_id": "acceptance-no-active-request", "response": {}}) + require(missing.get("isError") and "codex.app.request_unknown" in json.dumps(missing), missing) + self.response("steer", hold=True) + active = host.call("turn.start", {"thread_id": thread, "prompt": "Wait for the fixture response."})["turn"]["id"] + wait_until((self.root / "started-steer").exists, 15, "Active native turn") + host.call("turn.steer", {"thread_id": thread, "expected_turn_id": active, "prompt": "Continue waiting."}) + host.call("turn.interrupt", {"thread_id": thread, "turn_id": active}) + host.wait_turn(thread, active, "interrupted") + fork = host.call("thread.fork", {"thread_id": thread})["thread"]["id"] + require(fork != thread, "Fork returned the parent") + self.threads.add(("primary", fork)) + self.response("review", hold=True) + review = host.call("review.start", {"thread_id": thread, "target": {"type": "uncommittedChanges"}, "delivery": "inline"}) + review_thread = review["reviewThreadId"] + self.threads.add(("primary", review_thread)) + wait_until((self.root / "started-review").exists, 15, "Native review model request") + require(review["turn"]["status"] == "inProgress", review) + host.processes() + # Both threads belong to this one disposable runtime; the force operation is exact. + released = host.call("thread.release", {"thread_id": thread, "mode": "force-computer-mcp-owned-runtime-only", "interrupt_active_turn": True}) + require(released["externally_claimable"], released) + host.close() + self.configure() + self.record("native-approval-fork-review-steer-interrupt-and-unknown-input") + + def exec_lifecycle(self): + self.response("exec") + host = Host(self) + for native_options in (None, {"sandbox": "workspace-write", "approval_policy": "never"}): + arguments = {"prompt": "Return the isolated fixture response."} + if native_options: + arguments["options"] = native_options + session = host.call("codex.exec.start", arguments)["session_id"] + result = host.exec_result(session) + require("Fixture complete." in json.dumps(result), result) + upstream = result["upstream_session_id"] + require(isinstance(upstream, str) and upstream, "Exec lost native thread identity") + self.threads.add(("primary", upstream)) + page = host.call("codex.exec.events", {"session_id": session, "after_cursor": 0, "max_results": 1000}) + require(page["missed_events"] == 0 and page["events"], page) + host.call("codex.exec.release", {"session_id": session}) + launches = [json.loads(line) for line in (self.root / "launches.jsonl").read_text().splitlines()] + exec_launches = [entry for entry in launches if entry["mode"] == "exec"] + require(len(exec_launches) == 2, exec_launches) + inherited, explicit = (entry["argv"] for entry in exec_launches) + require("--sandbox" not in inherited and not any("approval_policy=" in arg for arg in inherited), inherited) + require("workspace-write" in explicit and 'approval_policy="never"' in explicit, explicit) + require(all("--ignore-user-config" not in entry["argv"] for entry in exec_launches), exec_launches) + resumed = host.call("codex.exec.resume", {"upstream_session_id": upstream, "prompt": "Return the fixture response again."})["session_id"] + result = host.exec_result(resumed) + require(result["upstream_session_id"] == upstream and "Fixture complete." in json.dumps(result), result) + host.call("codex.exec.release", {"session_id": resumed}) + self.response("exec-cancel", hold=True) + session = host.call("codex.exec.start", {"prompt": "Wait for the isolated response."})["session_id"] + wait_until((self.root / "started-exec-cancel").exists, 15, "Running Exec model request") + host.call("codex.exec.cancel", {"session_id": session}) + result = host.exec_result(session, "cancelled") + if result.get("upstream_session_id"): + self.threads.add(("primary", result["upstream_session_id"])) + events = host.call("codex.exec.events", {"session_id": session, "after_cursor": 0, "max_results": 1000}) + require(events["missed_events"] == 0 and any(row["kind"] == "session.cancelled" for row in events["events"]), events) + host.call("codex.exec.release", {"session_id": session}) + require(host.call("codex.exec.list")["sessions"] == [], "Released Exec results retained") + host.close() + self.response("cleanup") + self.record("exec-defaults-overrides-events-result-resume-cancel-release") + + def host_callbacks(self): + source = MODEL_SOURCE.with_name("HostToolVendor.py") + vendor = self.root / "host-tool-vendor" + vendor.write_text("#!" + sys.executable + "\n" + source.read_text().split("\n", 1)[1]) + vendor.chmod(0o700) + sequence = 900 + + def callback(name, arguments, success=True, request_error=None): + nonlocal sequence + sequence += 1 + request = {"id": sequence, "method": "item/tool/call", "params": { + "namespace": "computer-mcp", "tool": name, "arguments": arguments, + "callId": f"callback-{sequence}", "threadId": "callback-thread", "turnId": "callback-turn"}} + temporary = self.home / "queued.tmp" + temporary.write_text(json.dumps(request)) + temporary.replace(self.home / f"queued-{sequence}.json") + response = self.home / f"callback-{sequence}.json" + wait_until(response.exists, 15, "Native host callback") + value = json.loads(response.read_text()) + if request_error: + require(value.get("error") == request_error, value) + return value + require(value.get("result", {}).get("success") is success, value) + if not success: + return value + text = value["result"]["contentItems"][0]["text"] + return unwrap(json.loads(text)) + + for confirmation in ("never", "all-writes"): + self.configure() + config = self.root / "adapter.json" + settings = json.loads(config.read_text()) + settings["executable"] = str(vendor) + config.write_text(json.dumps(settings)) + host_config = self.root / "host.toml" + text = host_config.read_text().replace('confirmation_policy = "never"', + 'confirmation_policy = "' + confirmation + '"') + text = text.replace('capabilities = ["mcp.tools.call", "workspace.list"]', + 'capabilities = ["mcp.tools.call", "workspace.list", "policy.probe", ' + '"operations.prepare", "operations.commit", "file.read", "file.write", "git.add", "git.commit"]') + text += '\n[[cli.commands]]\nid = "git"\nexecutable = "/usr/bin/git"\n' + text += '\n[builtin]\nenabled = ["file.read", "file.write", "git.add", "git.commit"]\n' + host_config.write_text(text) + host = Host(self) + host.call("thread.loaded.list") + receipt = json.loads((self.home / "vendor.json").read_text()) + require(not receipt["inherited_host_env"] and not receipt["inherited_host_socket"], receipt) + self.pids.add(receipt["pid"]) + callback("file.read", {"workspace_id": "other", "path": "product.txt"}, success=False) + callback("codex.app.status", {}, request_error={"code": -32001, + "message": "This App Server request is not part of the supported coding contract."}) + if confirmation == "never": + (self.root / "primary/product.txt").write_text("governed-native\n") + index = self.root / "primary/.git/index" + before = index.read_bytes() + staged = self.git(["diff", "--cached", "--name-only"]) + callback("git.add", {"paths": ["product.txt"], "dry_run": True}) + require(index.read_bytes() == before and self.git(["diff", "--cached", "--name-only"]) == staged, + "Native dry-run callback changed the index") + require(host.call("approvals.list", {"state": "pending"})["approvals"] == [], + "Host dry-run created native approval") + (self.root / "primary/product.txt").unlink() + # Keep all acceptance artifacts out of this exact Git workflow's status. + (self.root / "primary/.git/info/exclude").write_text("*\n!product.txt\n") + hook = self.root / "primary/.git/hooks/pre-commit" + hook.write_text("#!/bin/sh\n/usr/bin/touch .git/callback-hook-ran\n") + hook.chmod(0o700) + steps = [("file.write", {"path": "product.txt", "content": "governed-native\n"}), + ("git.add", {"paths": ["product.txt"]}), + ("git.commit", {"message": "Governed native callback acceptance"})] + for tool, arguments in steps: + ticket = callback("operations.prepare", {"tool": tool, "arguments": arguments})["ticket_id"] + if tool == "file.write": + require(not (self.root / "primary/product.txt").exists(), "Prepare executed the write") + elif tool == "git.add": + require(not self.git(["diff", "--cached", "--name-only"]), "Prepare staged files") + else: + require(not (self.root / "primary/.git/callback-hook-ran").exists(), "Prepare committed") + commit = {"tool": tool, "arguments": arguments, "ticket_id": ticket} + callback("operations.commit", dict(commit, ticket_id="unrelated-ticket"), success=False) + callback("operations.commit", commit) + callback("operations.commit", commit, success=False) + require((self.root / "primary/.git/callback-hook-ran").exists(), "Callback Git hook did not run") + require(self.git(["show", "HEAD:product.txt"]) == "governed-native\n", "Callback commit content differs") + require(not self.git(["status", "--porcelain"]), "Callback worktree is not clean") + else: + arguments = {"path": "pending.txt", "content": "must require local approval"} + prepared = callback("operations.prepare", {"tool": "file.write", "arguments": arguments}) + require("pending_approval" in json.dumps(prepared), prepared) + callback("operations.commit", {"tool": "file.write", "arguments": arguments, + "ticket_id": prepared["ticket_id"]}, success=False) + require(not (self.root / "primary/pending.txt").exists(), "Pending host ticket was consumed") + host.processes() + host.close() + self.configure() + self.record("native-host-callback-scope-dry-run-exact-tickets-hooked-git-and-pending-approval") + + def authenticated(self): + with self.options.auth_file.open("rb") as source: + auth = source.read(1_048_577) + require(0 < len(auth) <= 1_048_576, "Vendor authentication file exceeds bound") + destination = self.home / "auth.json" + with open(destination, "x", opener=lambda path, flags: os.open(path, flags, 0o600)) as handle: + handle.write(auth.decode()) + self.configure(sandbox="read-only", authenticated=True) + host = Host(self) + # Every upstream failure remains a failure, including directory challenges. + host.call("models.list") + host.method("account/usage/read") + host.method("experimentalFeature/list", {}) + thread, _ = self.thread(host) + marker = "CMCP_AUTHENTICATED_APP_OK" + turn = host.call("turn.start", {"thread_id": thread, "prompt": f"Reply with exactly {marker}. Do not call tools or modify files."})["turn"]["id"] + host.wait_turn(thread, turn) + history = host.call("thread.read", {"thread_id": thread, "include_turns": True}) + turns = history["thread"]["turns"] + current = next(value for value in turns if value["id"] == turn) + require(any(item.get("type") == "agentMessage" and marker in item.get("text", "") for item in current["items"]), + "Authenticated App Server did not return the marker") + host.processes() + host.release(thread) + for resume in (False, True): + marker = "CMCP_AUTHENTICATED_EXEC_RESUME_OK" if resume else "CMCP_AUTHENTICATED_EXEC_OK" + arguments = {"prompt": f"Reply with exactly {marker}. Do not call tools or modify files."} + if resume: + arguments["upstream_session_id"] = upstream + session = host.call("codex.exec.resume" if resume else "codex.exec.start", arguments)["session_id"] + result = host.exec_result(session) + require(marker in (result.get("final_message") or ""), "Authenticated Exec did not return the marker") + upstream = result["upstream_session_id"] + self.threads.add(("primary", upstream)) + host.call("codex.exec.release", {"session_id": session}) + self.record("authenticated-model-app-exec-resume") + host.close() + host = Host(self) + host.method("app/list", {"forceRefetch": False, "limit": 1}, timeout=125) + host.processes() + self.record("authenticated-directories") + host.close() + destination.unlink() + self.configure() + + def archive_threads(self): + self.response("archive") + if not self.threads: + return + host = Host(self) + for workspace, thread in sorted(self.threads): + host.workspace = workspace + result = host.raw("methods.call", {"method": "thread/archive", "params": {"threadId": thread}}) + disposition = archive_disposition(result, thread) + require(disposition != "failed", {"thread": thread, "archive_result": result}) + host.processes() + host.close() + self.record("exact-workspace-thread-archive-cleanup") + + def cleanup(self): + errors = [] + for host in reversed(self.hosts): + try: + if not host.log.closed: + host.close() + except Exception as error: + errors.append(f"Host cleanup: {error}") + if self.model: + try: + self.model.stdin.close() + except Exception as error: + errors.append(f"Model stdin cleanup: {error}") + try: + try: + self.model.wait(timeout=10) + except subprocess.TimeoutExpired: + self.model.kill() + self.model.wait(timeout=5) + errors.append("Model fixture required forced exit") + if self.model.returncode != 0: + errors.append(f"Model fixture exit: {self.model.returncode}") + except Exception as error: + errors.append(f"Model fixture cleanup: {error}") + try: + if self.model_log: + self.model_log.close() + except Exception as error: + errors.append(f"Model log cleanup: {error}") + try: + launches = self.root / "launches.jsonl" + if launches.exists(): + entries = [json.loads(line) for line in launches.read_text().splitlines()] + self.pids.update(entry["pid"] for entry in entries) + roots = {str((self.root / name).resolve()) for name in ("primary", "other")} + if not all(str(Path(entry["cwd"]).resolve()) in roots for entry in entries): + errors.append("Vendor launched outside selected workspaces") + except Exception as error: + errors.append(f"Launch receipts: {error}") + try: + wait_until(lambda: all(exited(pid) for pid in self.pids), 15, "All receipted native/adapter processes exited") + except Exception as error: + errors.append(str(error)) + return errors + + +def digest(path): + value = hashlib.sha256() + with path.open("rb") as source: + while data := source.read(1024 * 1024): + value.update(data) + return value.hexdigest() + + +def run(options): + fixture = Fixture(options) + report = {"schema_version": 1, "mode": options.mode, "passed": False, + "authenticated_model_verified": False, "steps": fixture.steps, + "binaries": {key: {"path": str(getattr(options, key)), "sha256": digest(getattr(options, key))} + for key in ("host", "adapter", "codex")}, + "fixture_sha256": {source.name: digest(source) for source in + (MODEL_SOURCE, MODEL_SOURCE.with_name("HostToolVendor.py"))}} + try: + fixture.setup() + for key in ("host", "adapter", "codex"): + result = subprocess.run([str(getattr(options, key)), "--version"], text=True, capture_output=True, + timeout=10, check=True, env=fixture.environment) + report["binaries"][key]["version"] = result.stdout.strip() + fixture.catalog_and_diagnostics() + fixture.directories() + fixture.native_permissions() + fixture.handoff() + fixture.sibling_workspace() + fixture.recovery() + fixture.app_lifecycle() + fixture.exec_lifecycle() + fixture.host_callbacks() + if options.mode == "full": + fixture.authenticated() + report["authenticated_model_verified"] = True + fixture.archive_threads() + report["passed"] = True + except Exception as error: + report["failure"] = str(error) + finally: + report["cleanup_errors"] = [] + try: + (fixture.home / "auth.json").unlink(missing_ok=True) + except Exception as error: + report["cleanup_errors"].append(f"Credential cleanup: {error}") + report["authenticated_model_verified"] = "authenticated-model-app-exec-resume" in fixture.steps + try: + report["cleanup_errors"].extend(fixture.cleanup()) + except Exception as error: + report["cleanup_errors"].append(f"Fixture cleanup: {error}") + try: + (fixture.home / "auth.json").unlink(missing_ok=True) + except Exception as error: + report["cleanup_errors"].append(f"Credential cleanup: {error}") + try: + report["binary_digests_unchanged"] = all(digest(getattr(options, key)) == value["sha256"] + for key, value in report["binaries"].items()) + except Exception as error: + report["binary_digests_unchanged"] = False + report["cleanup_errors"].append(f"Binary digest verification: {error}") + report["passed"] = report["passed"] and not report["cleanup_errors"] and report["binary_digests_unchanged"] + (fixture.root / "report.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps({"passed": report["passed"], "mode": options.mode, + "authenticated_model_verified": report["authenticated_model_verified"], + "report": str(fixture.root / "report.json"), + "failure": report.get("failure"), "cleanup_errors": report["cleanup_errors"]}), flush=True) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(run(parse_arguments())) diff --git a/Sources/CodexAdapter/CodexDatabase.swift b/Sources/CodexAdapter/CodexDatabase.swift index 99fc86d..a888273 100644 --- a/Sources/CodexAdapter/CodexDatabase.swift +++ b/Sources/CodexAdapter/CodexDatabase.swift @@ -16,14 +16,15 @@ final class CodexDatabase: @unchecked Sendable { sharesWorktreeLeases = worktreeLeasePath != nil if let worktreeLeasePath { worktreeLeaseWriter = try DatabaseQueue(path: worktreeLeasePath, configuration: configuration) - var migrator = DatabaseMigrator() - migrator.registerMigration("codex-worktree-leases", migrate: Self.createWorktreeLeaseTable) - try migrator.migrate(worktreeLeaseWriter) } else { worktreeLeaseWriter = writer } + try Self.validateMigrations(Self.migrator, in: writer) + if sharesWorktreeLeases { + try Self.validateMigrations(Self.worktreeLeaseMigrator, in: worktreeLeaseWriter) + try Self.worktreeLeaseMigrator.migrate(worktreeLeaseWriter) + } try Self.migrator.migrate(writer) - try reconcileCodexRuntimeLeaseSemantics() } init(inMemory: Void) throws { @@ -32,7 +33,6 @@ final class CodexDatabase: @unchecked Sendable { worktreeLeaseWriter = writer sharesWorktreeLeases = false try Self.migrator.migrate(writer) - try reconcileCodexRuntimeLeaseSemantics() } func saveCodexApproval(_ approval: CodexApprovalRecord) throws { @@ -76,19 +76,7 @@ final class CodexDatabase: @unchecked Sendable { .order(Column("updatedAt").desc, Column("id").desc) .limit(max(1, min(limit, 5_000))) .fetchAll(database) - .map { try $0.value().reconciledStateSemantics() } - } - } - - private func reconcileCodexRuntimeLeaseSemantics() throws { - try writer.write { database in - for row in try CodexRuntimeLeaseRow.fetchAll(database) { - let current = try row.value() - let reconciled = current.reconciledStateSemantics() - if reconciled != current { - try CodexRuntimeLeaseRow(reconciled).save(database) - } - } + .map { try $0.value() } } } @@ -391,7 +379,7 @@ final class CodexDatabase: @unchecked Sendable { static var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() - migrator.registerMigration("codex-approval-broker") { database in + migrator.registerMigration("codex-initial") { database in try database.create(table: "codexApprovals") { table in table.column("id", .text).primaryKey() table.column("upstreamRequestID", .text).notNull() @@ -415,6 +403,8 @@ final class CodexDatabase: @unchecked Sendable { table.column("decision", .text) table.column("scope", .text) table.column("resolutionReason", .text) + table.column("ownerJSON", .text) + table.column("responseJSON", .text) } try database.create( index: "codexApprovals_on_workspace_state_createdAt", @@ -431,8 +421,6 @@ final class CodexDatabase: @unchecked Sendable { on: "codexApprovals", columns: ["correlationID"] ) - } - migrator.registerMigration("codex-runtime-leases") { database in try database.create(table: "codexRuntimeLeases") { table in table.column("id", .text).primaryKey() table.column("workspaceID", .text) @@ -445,8 +433,6 @@ final class CodexDatabase: @unchecked Sendable { on: "codexRuntimeLeases", columns: ["workspaceID", "state", "updatedAt"] ) - } - migrator.registerMigration("codex-thread-ownership") { database in try database.create(table: "codexThreadOwnership") { table in table.column("threadID", .text).primaryKey() table.column("workspaceID", .text) @@ -455,23 +441,19 @@ final class CodexDatabase: @unchecked Sendable { table.column("state", .text).notNull() table.column("createdAt", .datetime).notNull() table.column("updatedAt", .datetime).notNull() + table.column("ownerJSON", .text) } try database.create( index: "codexThreadOwnership_on_workspace_state_updatedAt", on: "codexThreadOwnership", columns: ["workspaceID", "state", "updatedAt"] ) - } - migrator.registerMigration("codex-ownership-reconciliation-receipts") { database in try database.create(table: "codexOwnershipReconciliationReceipts") { table in table.column("id", .text).primaryKey() table.column("planDigest", .text).notNull() table.column("appliedAt", .datetime).notNull() table.column("payloadJSON", .text).notNull() } - } - - migrator.registerMigration("codex-orchestration-runs") { database in try database.create(table: "codexOrchestrationRuns") { table in table.column("id", .text).primaryKey() table.column("workspaceID", .text).notNull() @@ -484,9 +466,7 @@ final class CodexDatabase: @unchecked Sendable { on: "codexOrchestrationRuns", columns: ["workspaceID", "state", "updatedAt"] ) - } - migrator.registerMigration("codex-worktree-leases", migrate: Self.createWorktreeLeaseTable) - migrator.registerMigration("codex-managed-worktrees") { database in + try createWorktreeLeaseTable(database) try database.create(table: "codexManagedWorktrees") { table in table.column("id", .text).primaryKey() table.column("sourceWorkspaceID", .text).notNull() @@ -501,18 +481,32 @@ final class CodexDatabase: @unchecked Sendable { columns: ["sourceWorkspaceID", "state", "updatedAt"] ) } - migrator.registerMigration("codex-native-approval-responses") { database in - try database.alter(table: "codexApprovals") { table in - table.add(column: "ownerJSON", .text) - table.add(column: "responseJSON", .text) - } - try database.alter(table: "codexThreadOwnership") { table in - table.add(column: "ownerJSON", .text) - } - } return migrator } + private static var worktreeLeaseMigrator: DatabaseMigrator { + var migrator = DatabaseMigrator() + migrator.registerMigration("codex-worktree-leases-initial", migrate: createWorktreeLeaseTable) + return migrator + } + + private static func validateMigrations( + _ migrator: DatabaseMigrator, in writer: any DatabaseWriter + ) throws { + let unknown = try writer.read { database in + try migrator.appliedIdentifiers(database).subtracting(migrator.migrations).sorted() + } + guard unknown.isEmpty else { throw IncompatibleMigrations(identifiers: unknown) } + } + + struct IncompatibleMigrations: Error, LocalizedError { + let identifiers: [String] + + var errorDescription: String? { + "Unsupported adapter database migrations: \(identifiers.joined(separator: ", ")). Use a new adapter state directory." + } + } + private static func createWorktreeLeaseTable(_ database: Database) throws { try database.create(table: "codexWorktreeLeases") { table in table.column("id", .text).primaryKey() @@ -819,55 +813,3 @@ private struct CodexWorktreeLeaseRow: Codable, FetchableRecord, PersistableRecor return try JSONDecoder().decode(CodexWorktreeLease.self, from: data) } } - -extension CodexDatabase { - /// Decode using the existing row contract without rewriting payload JSON. - /// Indexed identity and scope must agree with the payload consumed at runtime. - static func validateMigrationRow(_ row: Row, table: String) throws { - let valid: Bool - switch table { - case "codexApprovals": - _ = try CodexApprovalRecordRow(row: row).value() - valid = true - case "codexThreadOwnership": - _ = try CodexThreadOwnershipRow(row: row).value() - valid = true - case "codexRuntimeLeases": - let record = try CodexRuntimeLeaseRow(row: row) - let value = try record.value() - valid = - record.id == value.id && record.workspaceID == value.owner?.workspaceID - && record.state == value.state - case "codexOrchestrationRuns": - let record = try CodexOrchestrationRunRow(row: row) - let value = try record.value() - valid = - record.id == value.id && record.workspaceID == value.workspaceID - && record.state == value.state.rawValue - case "codexWorktreeLeases": - let record = try CodexWorktreeLeaseRow(row: row) - let value = try record.value() - valid = - record.id == value.id && record.workspaceID == value.workspaceID - && record.state == value.state.rawValue - case "codexManagedWorktrees": - let record = try CodexManagedWorktreeRow(row: row) - let value = try record.value() - valid = - record.id == value.id && record.workspaceID == value.workspaceID - && record.sourceWorkspaceID == value.sourceWorkspaceID - && record.state == value.state.rawValue - case "codexOwnershipReconciliationReceipts": - let record = try CodexThreadOwnershipReconciliationReceiptRow(row: row) - let value = try JSONDecoder().decode( - CodexThreadOwnershipReconciliationResult.self, from: Data(record.payloadJSON.utf8)) - valid = record.id == value.receiptID && record.planDigest == value.planDigest - default: - valid = false - } - guard valid else { - throw CodexDatabaseError.invalidStoredValue( - "Domain record identity or scope differs from its payload.") - } - } -} diff --git a/Sources/CodexAdapter/CodexExecRuntime.swift b/Sources/CodexAdapter/CodexExecRuntime.swift index 08b3ab5..7c86511 100644 --- a/Sources/CodexAdapter/CodexExecRuntime.swift +++ b/Sources/CodexAdapter/CodexExecRuntime.swift @@ -632,11 +632,11 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { } private static func validatedUpstreamSessionID(_ sessionID: String) throws -> String { - let allowed = CharacterSet( - charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" - ) guard !sessionID.isEmpty, sessionID.utf8.count <= 256, - sessionID.unicodeScalars.allSatisfy(allowed.contains) + sessionID.utf8.allSatisfy({ byte in + (65...90).contains(byte) || (97...122).contains(byte) || (48...57).contains(byte) + || byte == 95 || byte == 45 + }) else { throw CodexExecRuntimeError( code: "codex.exec.invalid_upstream_session_id", diff --git a/Sources/CodexAdapter/CodexRuntimeLease.swift b/Sources/CodexAdapter/CodexRuntimeLease.swift index 2b64939..951494e 100644 --- a/Sources/CodexAdapter/CodexRuntimeLease.swift +++ b/Sources/CodexAdapter/CodexRuntimeLease.swift @@ -41,29 +41,6 @@ struct CodexRuntimeLeaseRecord: Codable, Equatable, Sendable, Identifiable { (try? JSONValue.encoded(self)) ?? .object([:]) } - func reconciledStateSemantics() -> CodexRuntimeLeaseRecord { - var reconciled = self - let terminal = state == "stopped" || state == "cleaned" - if !terminal, let priorShutdownReason = shutdownReason { - reconciled.runtimeState = runtimeState ?? "running" - reconciled.connectionState = connectionState ?? state - reconciled.processState = processState ?? process?.state.rawValue ?? "absent" - reconciled.currentRequestState = currentRequestState ?? "idle" - reconciled.lastRequestFailure = - lastRequestFailure - ?? CodexRuntimeRequestFailure( - kind: - priorShutdownReason == "request_timeout" - ? "request_timeout" : "compatibility_request_failure", - message: "Recovered a request failure stored before runtime/request state separation.", - occurredAt: updatedAt, - connectionGeneration: 0, - recoverable: true - ) - reconciled.shutdownReason = nil - } - return reconciled - } } enum CodexRuntimeMaintenance { diff --git a/Sources/CodexAdapter/CodexStateMigration.swift b/Sources/CodexAdapter/CodexStateMigration.swift deleted file mode 100644 index eb8cd7c..0000000 --- a/Sources/CodexAdapter/CodexStateMigration.swift +++ /dev/null @@ -1,526 +0,0 @@ -import CryptoKit -import Foundation -import GRDB - -/// Transfers only adapter-owned domain records. This is an explicit offline -/// administration operation, not an MCP tool or a host-authority channel. -enum CodexStateMigration { - static let tables = [ - "codexApprovals", "codexRuntimeLeases", "codexThreadOwnership", - "codexOwnershipReconciliationReceipts", "codexOrchestrationRuns", - "codexWorktreeLeases", "codexManagedWorktrees", - ] - static let maximumRows = 100_000 - static let maximumPayloadBytes = 64 * 1_024 * 1_024 - - struct TableReport: Codable, Equatable, Sendable { - let name: String - let sourceRows: Int - let insertRows: Int - let identicalRows: Int - let conflictingRows: Int - } - - struct Plan: Codable, Equatable, Sendable { - let sourcePath: String - let destinationPath: String - let sourceDigest: String - let destinationDigest: String - let destinationIdentity: String? - let tables: [TableReport] - let destinationExists: Bool - var canApply: Bool { tables.allSatisfy { $0.conflictingRows == 0 } } - var insertRows: Int { tables.reduce(0) { $0 + $1.insertRows } } - var planDigest: String { get throws { try digest(self) } } - } - - struct Result: Encodable, Sendable { - let plan: Plan - let planDigest: String - let applied: Bool - let insertedRows: Int - let canApply: Bool - - init(plan: Plan, planDigest: String, applied: Bool, insertedRows: Int) { - self.plan = plan - self.planDigest = planDigest - self.applied = applied - self.insertedRows = insertedRows - self.canApply = plan.canApply - } - } - - enum Failure: Error, LocalizedError { - case invalid(String) - case changed - case conflict - - var errorDescription: String? { - switch self { - case .invalid(let detail): return "codex.state_migration.invalid: \(detail)" - case .changed: - return - "codex.state_migration.changed: Snapshot or destination changed. Review a new preview." - case .conflict: - return - "codex.state_migration.conflict: Existing records differ. No records were overwritten." - } - } - } - - static func preview(source: URL, destination: URL) throws -> Plan { - let inputs = try Inputs(source: source, destination: destination) - let reference = try referenceSchema() - let snapshot = try readSource(inputs.source, reference: reference) - let target: Snapshot - if inputs.destinationIdentity != nil { - let reader = try open(inputs.destination, readOnly: true) - defer { try? reader.close() } - target = try reader.read { try readSnapshot($0, reference: reference, isDestination: true) } - } else { - target = Snapshot(tables: reference.map { Table(schema: $0, rows: []) }) - } - try inputs.verify() - return try makePlan(inputs: inputs, source: snapshot, destination: target) - } - - static func apply(source: URL, destination: URL, expectedPlanDigest: String) throws -> Result { - guard expectedPlanDigest.utf8.count == 64, - expectedPlanDigest.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) - else { throw Failure.invalid("A reviewed SHA-256 plan digest is required.") } - let inputs = try Inputs(source: source, destination: destination) - let reference = try referenceSchema() - let snapshot = try readSource(inputs.source, reference: reference) - if inputs.destinationIdentity != nil { - let writer = try open(inputs.destination, readOnly: false) - defer { try? writer.close() } - // GRDB's write transaction locks the destination before rechecking its - // rows, so another database writer cannot slip between review and import. - return try writer.write { db in - let target = try readSnapshot(db, reference: reference, isDestination: true) - let plan = try makePlan(inputs: inputs, source: snapshot, destination: target) - try requireReviewed(plan, expected: expectedPlanDigest) - try inputs.verify(checkDestinationSidecars: false) - try insert(snapshot, excluding: target, into: db) - try verifySource(snapshot, at: inputs.source) - try inputs.verify(checkDestinationSidecars: false) - return Result( - plan: plan, planDigest: expectedPlanDigest, applied: true, insertedRows: plan.insertRows) - } - } - - let empty = Snapshot(tables: reference.map { Table(schema: $0, rows: []) }) - let plan = try makePlan(inputs: inputs, source: snapshot, destination: empty) - try requireReviewed(plan, expected: expectedPlanDigest) - // A fresh target is published only after its complete transaction closes. - // link() is no-replace, unlike rename(), so a concurrent creator wins safely. - let staging = inputs.destination.deletingLastPathComponent() - .appendingPathComponent(".codex-state-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory( - at: staging, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) - defer { try? FileManager.default.removeItem(at: staging) } - let stagedFile = staging.appendingPathComponent("codex.sqlite") - guard - FileManager.default.createFile( - atPath: stagedFile.path, contents: nil, attributes: [.posixPermissions: 0o600]) - else { throw Failure.invalid("Could not create private staging database.") } - let writer = try open(stagedFile, readOnly: false) - defer { try? writer.close() } - try CodexDatabase.migrator.migrate(writer) - try writer.write { db in try insert(snapshot, excluding: empty, into: db) } - try writer.close() - try verifySource(snapshot, at: inputs.source) - try inputs.verify() - try FileManager.default.linkItem(at: stagedFile, to: inputs.destination) - return Result( - plan: plan, planDigest: expectedPlanDigest, applied: true, insertedRows: plan.insertRows) - } - - private static func requireReviewed(_ plan: Plan, expected: String) throws { - guard try plan.planDigest == expected else { throw Failure.changed } - guard plan.canApply else { throw Failure.conflict } - } - - private struct Schema: Codable, Equatable { - let name: String - let columns: [Column] - var keyIndex: Int { columns.firstIndex { $0.primaryKey > 0 }! } - } - - private struct Column: Codable, Equatable { - let name: String - let type: String - let notNull: Bool - let primaryKey: Int - let hidden: Int - } - - private struct Table: Codable { - let schema: Schema - var rows: [[Cell]] - } - - private struct Snapshot: Codable { - let tables: [Table] - var fileDigest: String? = nil - } - - /// Store SQL values without re-encoding payload JSON or coercing identifiers, - /// dates, integer precision, NULL and empty strings into another representation. - private enum Cell: Codable, Equatable { - case null - case integer(Int64) - case real(Double) - case text(String) - case blob(Data) - - init(_ value: DatabaseValue) throws { - switch value.storage { - case .null: self = .null - case .int64(let value): self = .integer(value) - case .double(let value): - guard value.isFinite else { throw Failure.invalid("A stored real value is not finite.") } - self = .real(value) - case .string(let value): self = .text(value) - case .blob(let value): self = .blob(value) - } - } - - var databaseValue: DatabaseValue { - switch self { - case .null: return .null - case .integer(let value): return value.databaseValue - case .real(let value): return value.databaseValue - case .text(let value): return value.databaseValue - case .blob(let value): return value.databaseValue - } - } - - static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.null, .null): return true - case (.integer(let lhs), .integer(let rhs)): return lhs == rhs - case (.real(let lhs), .real(let rhs)): return lhs.bitPattern == rhs.bitPattern - case (.text(let lhs), .text(let rhs)): return lhs.utf8.elementsEqual(rhs.utf8) - case (.blob(let lhs), .blob(let rhs)): return lhs == rhs - default: return false - } - } - - var key: Data? { - guard case .text(let value) = self, !value.isEmpty else { return nil } - return Data(value.utf8) - } - } - - private struct Inputs { - let source: URL - let destination: URL - let sourceIdentity: String - let destinationIdentity: String? - - init(source: URL, destination: URL) throws { - guard source.isFileURL, destination.isFileURL else { - throw Failure.invalid("Database locations must be local files.") - } - self.source = - source.deletingLastPathComponent().resolvingSymlinksInPath() - .appendingPathComponent(source.lastPathComponent).standardizedFileURL - self.destination = - destination.deletingLastPathComponent().resolvingSymlinksInPath() - .appendingPathComponent(destination.lastPathComponent).standardizedFileURL - sourceIdentity = try identity(self.source, required: true)! - destinationIdentity = try identity(self.destination, required: false) - guard sourceIdentity != destinationIdentity else { - throw Failure.invalid("Source and destination identify the same file.") - } - try Self.requireOffline(self.source) - try Self.requireOffline(self.destination) - } - - func verify(checkDestinationSidecars: Bool = true) throws { - guard try identity(source, required: true) == sourceIdentity, - try identity(destination, required: false) == destinationIdentity - else { throw Failure.changed } - try Self.requireOffline(source) - if checkDestinationSidecars { try Self.requireOffline(destination) } - } - - static func requireOffline(_ file: URL) throws { - for suffix in ["-wal", "-shm", "-journal"] { - let sidecar = URL(fileURLWithPath: file.path + suffix) - if try identity(sidecar, required: false) != nil { - throw Failure.invalid("Use an offline, checkpointed snapshot without SQLite sidecars.") - } - } - } - } - - private static func identity(_ url: URL, required: Bool) throws -> String? { - let attributes: [FileAttributeKey: Any] - do { attributes = try FileManager.default.attributesOfItem(atPath: url.path) } catch CocoaError - .fileReadNoSuchFile - { - if required { throw Failure.invalid("The source snapshot does not exist.") } - return nil - } - guard attributes[.type] as? FileAttributeType == .typeRegular, - let device = attributes[.systemNumber] as? NSNumber, - let inode = attributes[.systemFileNumber] as? NSNumber - else { throw Failure.invalid("Database locations must be regular files, not symbolic links.") } - return "\(device):\(inode)" - } - - private static func open(_ url: URL, readOnly: Bool) throws -> DatabaseQueue { - var configuration = Configuration() - configuration.readonly = readOnly - configuration.busyMode = .timeout(1) - configuration.prepareDatabase { db in - try db.execute(sql: "PRAGMA trusted_schema = OFF") - } - return try DatabaseQueue(path: url.path, configuration: configuration) - } - - private static func referenceSchema() throws -> [Schema] { - let reference = try DatabaseQueue() - defer { try? reference.close() } - try CodexDatabase.migrator.migrate(reference) - return try reference.read { db in try tables.map { try schema($0, in: db) } } - } - - private static func schema(_ name: String, in db: Database) throws -> Schema { - let kind = try String.fetchOne( - db, sql: "SELECT type FROM sqlite_schema WHERE name = ?", arguments: [name]) - guard kind == "table" else { throw Failure.invalid("Missing domain table: \(name).") } - let columns = try Row.fetchAll(db, sql: "PRAGMA table_xinfo(\(quote(name)))").map { row in - Column( - name: row["name"], type: row["type"], notNull: row["notnull"], - primaryKey: row["pk"], hidden: row["hidden"]) - } - return Schema(name: name, columns: columns) - } - - private static func readSource(_ url: URL, reference: [Schema]) throws -> Snapshot { - let before = try fileDigest(url) - let reader = try open(url, readOnly: true) - defer { try? reader.close() } - var snapshot = try reader.read { - try readSnapshot($0, reference: reference, isDestination: false) - } - snapshot.fileDigest = before - try verifySource(snapshot, at: url) - return snapshot - } - - private static func verifySource(_ snapshot: Snapshot, at url: URL) throws { - try Inputs.requireOffline(url) - guard try fileDigest(url) == snapshot.fileDigest else { throw Failure.changed } - } - - private static func readSnapshot( - _ db: Database, reference: [Schema], isDestination: Bool - ) throws -> Snapshot { - if isDestination { - let names = try String.fetchAll( - db, sql: "SELECT name FROM sqlite_schema WHERE type = 'table'") - let allowed = Set(tables + ["grdb_migrations"]) - guard Set(names).isSubset(of: allowed), - try Int.fetchOne(db, sql: "SELECT count(*) FROM sqlite_schema WHERE type = 'trigger'") == 0 - else { - throw Failure.invalid("The destination must be an adapter-only database without triggers.") - } - guard - try CodexDatabase.migrator.appliedIdentifiers(db) == Set(CodexDatabase.migrator.migrations) - else { - throw Failure.invalid("The destination schema migration history is incompatible.") - } - } - var result: [Table] = [] - var count = 0 - var bytes = 0 - for expected in reference { - if !isDestination, try !db.tableExists(expected.name) { - result.append(Table(schema: expected, rows: [])) - continue - } - let actual = try schema(expected.name, in: db) - // The native-approval migration added nullable fields to these historical - // source tables. Project their absent values as NULL without migrating the source. - let additions: Set - switch expected.name { - case "codexApprovals": additions = ["ownerJSON", "responseJSON"] - case "codexThreadOwnership": additions = ["ownerJSON"] - default: additions = [] - } - let addedColumns = expected.columns.filter { additions.contains($0.name) } - let historicalColumns = expected.columns.filter { !additions.contains($0.name) } - let isHistoricalSource = - !isDestination && !additions.isEmpty && addedColumns.count == additions.count - && addedColumns.allSatisfy { !$0.notNull && $0.primaryKey == 0 && $0.hidden == 0 } - && actual.columns == historicalColumns - guard actual == expected || isHistoricalSource else { - throw Failure.invalid("Unsupported columns in domain table: \(expected.name).") - } - let present = Set(actual.columns.map(\.name)) - let expressions = expected.columns.map { present.contains($0.name) ? quote($0.name) : "NULL" } - let rowCount = try Int.fetchOne(db, sql: "SELECT count(*) FROM \(quote(expected.name))") ?? 0 - guard rowCount <= maximumRows - count else { - throw Failure.invalid("Domain records exceed the row bound.") - } - let byteExpression = expressions.map { - "coalesce(length(CAST(\($0) AS BLOB)), 0)" - }.joined(separator: " + ") - let storedBytes = - try Int64.fetchOne( - db, sql: "SELECT coalesce(sum(\(byteExpression)), 0) FROM \(quote(expected.name))") ?? 0 - guard storedBytes <= maximumPayloadBytes - bytes else { - throw Failure.invalid("Domain records exceed 64 MiB.") - } - // Compare raw TEXT bytes with the decoded Swift string before binding it - // back to SQLite; malformed UTF-8 must not silently become replacement characters. - let rawText = expressions.enumerated().map { index, expression in - "CASE WHEN typeof(\(expression)) = 'text' THEN CAST(\(expression) AS BLOB) END AS __migration_text_\(index)" - } - let projected = zip(expressions, expected.columns).map { "\($0) AS \(quote($1.name))" } - let columns = (projected + rawText).joined(separator: ", ") - let cursor = try Row.fetchCursor( - db, - sql: - "SELECT \(columns) FROM \(quote(expected.name)) ORDER BY \(quote(expected.columns[expected.keyIndex].name))" - ) - var rows: [[Cell]] = [] - var keys = Set() - while let row = try cursor.next() { - count += 1 - guard count <= maximumRows else { - throw Failure.invalid("Domain records exceed the row bound.") - } - let cells = try expected.columns.enumerated().map { index, column in - let raw: Data? = row["__migration_text_\(index)"] - if let raw { - guard let text = String(data: raw, encoding: .utf8) else { - throw Failure.invalid("A domain record contains invalid UTF-8.") - } - return Cell.text(text) - } - return try Cell(row[column.name] as DatabaseValue) - } - guard let key = cells[expected.keyIndex].key, keys.insert(key).inserted else { - throw Failure.invalid("Invalid or duplicate domain record identity.") - } - bytes += try JSONEncoder().encode(cells).count - guard bytes <= maximumPayloadBytes else { - throw Failure.invalid("Domain records exceed 64 MiB.") - } - do { try CodexDatabase.validateMigrationRow(row, table: expected.name) } catch { - throw Failure.invalid("Invalid stored record in \(expected.name).") - } - rows.append(cells) - } - result.append(Table(schema: expected, rows: rows)) - } - // A random SQLite file is not a source, even if it happens to be empty. - if !isDestination { - let names = try String.fetchAll( - db, sql: "SELECT name FROM sqlite_schema WHERE type = 'table'") - guard !Set(names).intersection(tables).isEmpty else { - throw Failure.invalid("The source contains no recognized Codex domain tables.") - } - } - return Snapshot(tables: result) - } - - private static func makePlan(inputs: Inputs, source: Snapshot, destination: Snapshot) throws - -> Plan - { - let reports = zip(source.tables, destination.tables).map { source, target in - let existing = Dictionary( - uniqueKeysWithValues: target.rows.map { ($0[target.schema.keyIndex].key!, $0) }) - var inserts = 0 - var same = 0 - var conflicts = 0 - for row in source.rows { - if let old = existing[row[source.schema.keyIndex].key!] { - if old == row { same += 1 } else { conflicts += 1 } - } else { - inserts += 1 - } - } - return TableReport( - name: source.schema.name, sourceRows: source.rows.count, insertRows: inserts, - identicalRows: same, conflictingRows: conflicts) - } - return Plan( - sourcePath: inputs.source.path, destinationPath: inputs.destination.path, - sourceDigest: source.fileDigest!, destinationDigest: try digest(destination), - destinationIdentity: inputs.destinationIdentity, tables: reports, - destinationExists: inputs.destinationIdentity != nil) - } - - private static func insert(_ source: Snapshot, excluding target: Snapshot, into db: Database) - throws - { - for (table, previous) in zip(source.tables, target.tables) { - let existing = Set(previous.rows.map { $0[previous.schema.keyIndex].key! }) - let columns = table.schema.columns.map { quote($0.name) }.joined(separator: ", ") - let markers = Array(repeating: "?", count: table.schema.columns.count).joined(separator: ", ") - for row in table.rows where !existing.contains(row[table.schema.keyIndex].key!) { - try db.execute( - sql: "INSERT INTO \(quote(table.schema.name)) (\(columns)) VALUES (\(markers))", - arguments: StatementArguments(row.map(\.databaseValue))) - } - } - } - - private static func quote(_ name: String) -> String { - "\"" + name.replacingOccurrences(of: "\"", with: "\"\"") + "\"" - } - - private static func digest(_ value: some Encodable) throws -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - return SHA256.hash(data: try encoder.encode(value)).map { String(format: "%02x", $0) }.joined() - } - - private static func fileDigest(_ url: URL) throws -> String { - _ = try identity(url, required: true) - let handle = try FileHandle(forReadingFrom: url) - defer { try? handle.close() } - var hash = SHA256() - var count = 0 - while let chunk = try handle.read(upToCount: 1_024 * 1_024), !chunk.isEmpty { - count += chunk.count - guard count <= 256 * 1_024 * 1_024 else { - throw Failure.invalid("Source snapshot exceeds 256 MiB.") - } - hash.update(data: chunk) - } - return hash.finalize().map { String(format: "%02x", $0) }.joined() - } -} - -extension CodexAdapterServer { - package static func migrateState( - source: URL, destination: URL, expectedPlanDigest: String? = nil - ) throws -> Data { - do { - let result: CodexStateMigration.Result - if let expectedPlanDigest { - result = try CodexStateMigration.apply( - source: source, destination: destination, expectedPlanDigest: expectedPlanDigest) - } else { - let plan = try CodexStateMigration.preview(source: source, destination: destination) - result = CodexStateMigration.Result( - plan: plan, planDigest: try plan.planDigest, applied: false, insertedRows: 0) - } - return try CanonicalJSONCoding.encoder(outputFormatting: [.prettyPrinted, .sortedKeys]) - .encode(result) - } catch let error as CodexStateMigration.Failure { - throw error - } catch { - throw CodexStateMigration.Failure.invalid( - "Database or filesystem validation failed; no record values are included in this diagnostic." - ) - } - } -} diff --git a/Sources/CodexMCPAdapter/CodexMCPAdapter.swift b/Sources/CodexMCPAdapter/CodexMCPAdapter.swift index 79a6ea0..a2b2ae6 100644 --- a/Sources/CodexMCPAdapter/CodexMCPAdapter.swift +++ b/Sources/CodexMCPAdapter/CodexMCPAdapter.swift @@ -10,7 +10,7 @@ struct CodexMCPAdapter: AsyncParsableCommand { discussion: "Without --config, serves protocol inspection. Execution settings are local JSON Codex configuration. Logs and errors go to stderr; serving uses MCP on stdin/stdout.", version: CodexAdapterBuildInfo.version, - subcommands: [CompareSchema.self, MigrateState.self]) + subcommands: [CompareSchema.self]) @Option(name: .customLong("config"), help: "Path to local Codex JSON configuration.") var configurationPath: String? @@ -51,46 +51,4 @@ struct CodexMCPAdapter: AsyncParsableCommand { } } - struct MigrateState: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "migrate-state", - abstract: "Preview or apply an offline Codex domain-state migration.", - discussion: - "Requires an offline SQLite snapshot and a stopped destination adapter. Transfers domain records, never host grants, audit, credentials or vendor state. Preview is the default; apply requires its exact digest. This command does not stop any runtime." - ) - - @Option( - name: .customLong("source-snapshot"), - help: "Absolute path to an offline, checkpointed source SQLite snapshot.") - var sourceSnapshot: String - - @Option(help: "Absolute path to a new or existing adapter-only SQLite database.") - var destination: String - - @Flag(help: "Apply the reviewed plan; otherwise only preview.") - var apply = false - - @Option( - name: .customLong("expected-plan-digest"), - help: "SHA-256 digest from a fresh migration preview.") - var expectedPlanDigest: String? - - mutating func validate() throws { - guard sourceSnapshot.hasPrefix("/"), destination.hasPrefix("/"), - !sourceSnapshot.contains("\0"), !destination.contains("\0") - else { throw ValidationError("Database paths must be absolute local paths.") } - guard apply == (expectedPlanDigest != nil) else { - throw ValidationError("--apply and --expected-plan-digest must be supplied together.") - } - } - - mutating func run() throws { - let result = try CodexAdapterServer.migrateState( - source: URL(fileURLWithPath: sourceSnapshot), - destination: URL(fileURLWithPath: destination), - expectedPlanDigest: expectedPlanDigest) - try FileHandle.standardOutput.write(contentsOf: result + Data([10])) - } - } - } diff --git a/Tests/CodexAdapterTests/CodexAppServerProviderTests.swift b/Tests/CodexAdapterTests/CodexAppServerProviderTests.swift index 657f391..c52ab31 100644 --- a/Tests/CodexAdapterTests/CodexAppServerProviderTests.swift +++ b/Tests/CodexAdapterTests/CodexAppServerProviderTests.swift @@ -18,6 +18,15 @@ struct CodexAppServerProviderTests { } } + @Test + func managedRemovalDeclaresDestructiveRiskAndAnnotation() throws { + let tool = try #require( + makeProvider().tools.first { $0.name == "codex.worktree.remove.perform" }) + #expect(tool._meta?["io.github.computer-mcp/risk"] == .string("destructive")) + #expect(tool.annotations.destructiveHint == true) + #expect(tool.annotations.readOnlyHint == false) + } + @Test func modelAndApprovalEntryPointsDeclareFullShellAcrossTheMCPCatalog() throws { let tools = makeProvider().tools diff --git a/Tests/CodexAdapterTests/CodexDatabaseSchemaTests.swift b/Tests/CodexAdapterTests/CodexDatabaseSchemaTests.swift new file mode 100644 index 0000000..22e49f4 --- /dev/null +++ b/Tests/CodexAdapterTests/CodexDatabaseSchemaTests.swift @@ -0,0 +1,90 @@ +import Foundation +import GRDB +import Testing + +@testable import CodexAdapter + +struct CodexDatabaseSchemaTests { + @Test func initialSchemaMatchesCapturedFinalSchema() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("codex.sqlite").path + _ = try CodexDatabase(path: path) + let queue = try DatabaseQueue(path: path) + let fixture = try #require( + Bundle.module.url( + forResource: "codex-schema", withExtension: "json", subdirectory: "Fixtures")) + let expected = try JSONDecoder().decode([SchemaObject].self, from: Data(contentsOf: fixture)) + #expect(try schema(queue) == expected) + #expect( + try queue.read { try CodexDatabase.migrator.appliedIdentifiers($0) } == ["codex-initial"]) + _ = try CodexDatabase(path: path) + #expect(try schema(queue) == expected) + } + + @Test(arguments: [false, true]) + func unknownMigrationIsRejectedBeforeEitherStoreChanges(sharedLeaseStore: Bool) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("codex.sqlite").path + let leases = directory.appendingPathComponent("leases.sqlite").path + let primary = try DatabaseQueue(path: path) + let shared = try DatabaseQueue(path: leases) + let target = sharedLeaseStore ? shared : primary + try target.write { database in + try database.execute( + sql: "CREATE TABLE grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY)") + try database.execute(sql: "INSERT INTO grdb_migrations VALUES ('unrecognized-version')") + try database.execute(sql: "CREATE TABLE retained (value TEXT NOT NULL UNIQUE)") + try database.execute(sql: "INSERT INTO retained VALUES ('keep')") + } + let primaryBefore = try schema(primary) + let sharedBefore = try schema(shared) + #expect(throws: CodexDatabase.IncompatibleMigrations.self) { + try CodexDatabase(path: path, worktreeLeasePath: leases) + } + #expect(try schema(primary) == primaryBefore) + #expect(try schema(shared) == sharedBefore) + #expect( + try target.read { try String.fetchOne($0, sql: "SELECT value FROM retained") } == "keep") + #expect( + try target.read { try String.fetchAll($0, sql: "SELECT identifier FROM grdb_migrations") } + == ["unrecognized-version"]) + } + + @Test func sharedLeaseStoreUsesTheSameTableAndIndexDefinitions() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("codex.sqlite").path + let leases = directory.appendingPathComponent("leases.sqlite").path + _ = try CodexDatabase(path: path, worktreeLeasePath: leases) + let primary = try schema(DatabaseQueue(path: path)).filter { $0.table == "codexWorktreeLeases" } + let shared = try schema(DatabaseQueue(path: leases)).filter { + $0.table == "codexWorktreeLeases" + } + #expect(!primary.isEmpty) + #expect(shared == primary) + _ = try CodexDatabase(path: path, worktreeLeasePath: leases) + } + + private struct SchemaObject: Codable, Equatable { + let type: String + let name: String + let table: String + let sql: String? + } + + private func schema(_ queue: DatabaseQueue) throws -> [SchemaObject] { + try queue.read { database in + try Row.fetchAll( + database, sql: "SELECT type, name, tbl_name, sql FROM sqlite_schema ORDER BY type, name" + ) + .map { row in + SchemaObject(type: row["type"], name: row["name"], table: row["tbl_name"], sql: row["sql"]) + } + } + } +} diff --git a/Tests/CodexAdapterTests/CodexDatabaseTests.swift b/Tests/CodexAdapterTests/CodexDatabaseTests.swift index 3f58715..9fe5e6b 100644 --- a/Tests/CodexAdapterTests/CodexDatabaseTests.swift +++ b/Tests/CodexAdapterTests/CodexDatabaseTests.swift @@ -181,31 +181,6 @@ struct CodexDatabaseTests { #expect(!plan.signalsSent) } - @Test - func testPreSplitRequestTimeoutIsReconciledWithoutTerminalRuntimeReason() throws { - let database = try CodexDatabase(inMemory: ()) - let now = Date(timeIntervalSince1970: 6_000) - try database.saveCodexRuntimeLease( - CodexRuntimeLeaseRecord( - id: "pre-split-runtime", - owner: nil, - workspacePath: "/tmp/workspace-1", - state: "running", - process: nil, - createdAt: now, - updatedAt: now, - shutdownReason: "request_timeout", - cleanedAt: nil - ) - ) - - let reconciled = try #require(try database.codexRuntimeLeases().first) - #expect(reconciled.runtimeState == "running") - #expect(reconciled.shutdownReason == nil) - #expect(reconciled.lastRequestFailure?.kind == "request_timeout") - #expect(reconciled.lastRequestFailure?.recoverable == true) - } - @Test func testReviewedRuntimeCleanupOnlyTransitionsDeadStaleReceipts() async throws { let database = try CodexDatabase(inMemory: ()) diff --git a/Tests/CodexAdapterTests/CodexExecRuntimeTests.swift b/Tests/CodexAdapterTests/CodexExecRuntimeTests.swift index 6d9f09d..c72da64 100644 --- a/Tests/CodexAdapterTests/CodexExecRuntimeTests.swift +++ b/Tests/CodexAdapterTests/CodexExecRuntimeTests.swift @@ -55,8 +55,12 @@ final class CodexExecRuntimeTests { == (.string(workspace.standardizedFileURL.path))) } - @Test - func testResumeUsesOpaqueSessionSelectorAndExplicitNativeDefaults() async throws { + @Test(arguments: [ + "thread_original-1", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-", + "01234567-89ab-7cde-8fab-0123456789ab", String(repeating: "a", count: 256), + ]) + func testResumeUsesOpaqueSessionSelectorAndExplicitNativeDefaults(sessionID: String) async throws + { let workspace = URL(fileURLWithPath: "/tmp/resume-workspace") let adapter = FakeCodexExecClientAdapter(handles: [ FakeCodexExecHandle( @@ -74,13 +78,13 @@ final class CodexExecRuntimeTests { ) _ = try await runtime.resume( - upstreamSessionID: "thread_original-1", + upstreamSessionID: sessionID, prompt: "Continue." ) let resumeRequests = await adapter.resumeRequests let request = try #require(resumeRequests.first) - #expect((request.selector) == (.sessionID("thread_original-1"))) + #expect((request.selector) == (.sessionID(sessionID))) #expect((request.promptInput) == (.text("Continue."))) #expect((request.outputMode) == (.jsonl)) #expect((request.options.workingDirectory) == (workspace.standardizedFileURL)) @@ -271,8 +275,11 @@ final class CodexExecRuntimeTests { > (termination["stderr"]?.stringValue?.utf8.count ?? .max)) } - @Test - func testInputValidationOccursBeforeLaunchingClient() async throws { + @Test(arguments: [ + "", String(repeating: "a", count: 257), "../escape", "a/b", "a.b", "a b", + "a\n", "a\0", "é", "线程", "a😀", + ]) + func testInputValidationOccursBeforeLaunchingClient(sessionID: String) async throws { let adapter = FakeCodexExecClientAdapter(handles: []) let runtime = LiveCodexExecRuntime( configuration: configuration(), @@ -285,7 +292,7 @@ final class CodexExecRuntimeTests { #expect(((error as? CodexExecRuntimeError)?.code) == ("codex.exec.invalid_prompt")) } await assertThrowsErrorAsync( - try await runtime.resume(upstreamSessionID: "../escape", prompt: nil) + try await runtime.resume(upstreamSessionID: sessionID, prompt: nil) ) { error in #expect( ((error as? CodexExecRuntimeError)?.code) == ("codex.exec.invalid_upstream_session_id")) diff --git a/Tests/CodexAdapterTests/CodexStateMigrationTests.swift b/Tests/CodexAdapterTests/CodexStateMigrationTests.swift deleted file mode 100644 index 8886ce3..0000000 --- a/Tests/CodexAdapterTests/CodexStateMigrationTests.swift +++ /dev/null @@ -1,457 +0,0 @@ -import Foundation -import GRDB -import Testing - -@testable import CodexAdapter - -@Suite(.serialized) -struct CodexStateMigrationTests { - @Test func previewDoesNotCreateDestinationOrMutateSource() throws { - let fixture = try Fixture() - let before = try Data(contentsOf: fixture.source) - let plan = try CodexStateMigration.preview( - source: fixture.source, destination: fixture.destination) - #expect(plan.canApply) - #expect(!plan.destinationExists) - #expect(plan.insertRows == 7) - #expect(plan.tables.allSatisfy { $0.sourceRows == 1 }) - #expect(!FileManager.default.fileExists(atPath: fixture.destination.path)) - #expect(try Data(contentsOf: fixture.source) == before) - } - - @Test func migratesOnlyDomainTablesPreservesRawValuesAndIsIdempotent() throws { - let fixture = try Fixture() - let before = try Data(contentsOf: fixture.source) - let first = try fixture.preview() - let result = try fixture.apply(first) - #expect(result.insertedRows == 7) - #expect(try Data(contentsOf: fixture.source) == before) - let reader = try DatabaseQueue(path: fixture.destination.path) - defer { try? reader.close() } - try reader.read { db throws -> Void in - #expect(try !db.tableExists("hostSecrets")) - #expect(try !db.tableExists("codexElevationGrants")) - for name in CodexStateMigration.tables { - #expect(try Int.fetchOne(db, sql: "SELECT count(*) FROM \(name)") == 1) - } - } - let source = try DatabaseQueue(path: fixture.source.path) - defer { try? source.close() } - for name in CodexStateMigration.tables { - let original = try source.read { try Row.fetchAll($0, sql: "SELECT * FROM \(name)") } - let copied = try reader.read { try Row.fetchAll($0, sql: "SELECT * FROM \(name)") } - #expect(original == copied) - } - let again = try fixture.preview() - #expect(again.tables.allSatisfy { $0.identicalRows == 1 }) - #expect(try fixture.apply(again).insertedRows == 0) - let database = try CodexDatabase(path: fixture.destination.path) - #expect(try database.codexThreadOwnership(threadID: "thread-一")?.state == .released) - #expect(try database.codexApproval(id: "approval-1")?.state == .interrupted) - #expect(try database.codexRuntimeLeases().first?.state == "stopped") - } - - @Test(arguments: [false, true]) - func historicalSourcePreservesEveryStoredColumnAndDefaultsAddedFieldsToNull( - destinationExists: Bool - ) throws { - let fixture = try Fixture() - try fixture.restoreHistoricalSchema(at: fixture.source) - if destinationExists { _ = try CodexDatabase(path: fixture.destination.path) } - let before = try Data(contentsOf: fixture.source) - let plan = try fixture.preview() - #expect(plan.canApply) - #expect(plan.destinationExists == destinationExists) - #expect(plan.insertRows == 7) - #expect(try Data(contentsOf: fixture.source) == before) - #expect(FileManager.default.fileExists(atPath: fixture.destination.path) == destinationExists) - #expect(try fixture.apply(plan).insertedRows == 7) - #expect(try Data(contentsOf: fixture.source) == before) - - let source = try DatabaseQueue(path: fixture.source.path) - defer { try? source.close() } - let destination = try DatabaseQueue(path: fixture.destination.path) - defer { try? destination.close() } - for table in CodexStateMigration.tables { - let names = try source.read { db in - try Row.fetchAll(db, sql: "PRAGMA table_info(\(table))").map { row -> String in - row["name"] - } - } - let columns = names.map { "\"\($0)\"" }.joined(separator: ", ") - let original = try source.read { try Row.fetchAll($0, sql: "SELECT * FROM \(table)") } - let copied = try destination.read { - try Row.fetchAll($0, sql: "SELECT \(columns) FROM \(table)") - } - #expect(original == copied) - } - try destination.read { db in - let approvals = try Int.fetchOne( - db, - sql: "SELECT count(*) FROM codexApprovals WHERE ownerJSON IS NULL AND responseJSON IS NULL") - let ownerships = try Int.fetchOne( - db, sql: "SELECT count(*) FROM codexThreadOwnership WHERE ownerJSON IS NULL") - #expect(approvals == 1) - #expect(ownerships == 1) - } - let again = try fixture.preview() - #expect(again.tables.allSatisfy { $0.identicalRows == 1 }) - #expect(try fixture.apply(again).insertedRows == 0) - #expect(try Data(contentsOf: fixture.source) == before) - } - - @Test(arguments: ["unknown-column", "missing-original-column", "partial-extension"]) - func historicalSourceRejectsUnrecognizedColumnLayouts(_ change: String) throws { - let fixture = try Fixture() - try fixture.restoreHistoricalSchema(at: fixture.source) - try fixture.write(fixture.source) { db in - switch change { - case "unknown-column": - try db.execute(sql: "ALTER TABLE codexApprovals ADD COLUMN future_field TEXT") - case "missing-original-column": - try db.execute(sql: "ALTER TABLE codexApprovals DROP COLUMN scope") - default: - try db.execute(sql: "ALTER TABLE codexApprovals ADD COLUMN ownerJSON TEXT") - } - } - let before = try Data(contentsOf: fixture.source) - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - #expect(!FileManager.default.fileExists(atPath: fixture.destination.path)) - #expect(try Data(contentsOf: fixture.source) == before) - } - - @Test func historicalDestinationIsNotSilentlyUpgraded() throws { - let fixture = try Fixture() - do { _ = try CodexDatabase(path: fixture.destination.path) } - try fixture.restoreHistoricalSchema(at: fixture.destination) - let before = try Data(contentsOf: fixture.destination) - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - #expect(try Data(contentsOf: fixture.destination) == before) - } - - @Test func conflictRejectsWholeImportWithoutOverwritingOrPartialRows() throws { - let fixture = try Fixture() - do { - let target = try CodexDatabase(path: fixture.destination.path) - try target.saveCodexThreadOwnership( - .init( - threadID: "thread-一", workspaceID: "other-workspace", workspacePath: "/tmp/other", - runtimeID: "different", state: .archived, createdAt: fixture.date, updatedAt: fixture.date - )) - } - let plan = try fixture.preview() - #expect(!plan.canApply) - #expect(plan.tables.first { $0.name == "codexThreadOwnership" }?.conflictingRows == 1) - let before = try Data(contentsOf: fixture.destination) - #expect(throws: CodexStateMigration.Failure.self) { try fixture.apply(plan) } - #expect(try Data(contentsOf: fixture.destination) == before) - let database = try CodexDatabase(path: fixture.destination.path) - #expect(try database.codexApprovals().isEmpty) - #expect(try database.codexThreadOwnership(threadID: "thread-一")?.runtimeID == "different") - } - - @Test func changedSourceRejectsReviewedPlanWithoutCreatingDestination() throws { - let fixture = try Fixture() - let plan = try fixture.preview() - try fixture.write(fixture.source) { db in - try db.execute(sql: "UPDATE codexThreadOwnership SET workspacePath = '/different'") - } - #expect(throws: CodexStateMigration.Failure.self) { try fixture.apply(plan) } - #expect(!FileManager.default.fileExists(atPath: fixture.destination.path)) - } - - @Test func changedDestinationRejectsReviewedPlanWithoutAddingSourceRecords() throws { - let fixture = try Fixture() - do { _ = try CodexDatabase(path: fixture.destination.path) } - let plan = try fixture.preview() - do { - let target = try CodexDatabase(path: fixture.destination.path) - try target.saveCodexThreadOwnership( - .init( - threadID: "new-local-thread", workspaceID: nil, workspacePath: "/tmp/local", - runtimeID: "local", state: .released, createdAt: fixture.date, updatedAt: fixture.date)) - } - #expect(throws: CodexStateMigration.Failure.self) { try fixture.apply(plan) } - let target = try CodexDatabase(path: fixture.destination.path) - #expect(try target.codexApprovals().isEmpty) - #expect(try target.codexThreadOwnerships().count == 1) - } - - @Test func lateConstraintFailureRollsBackEarlierTables() throws { - let fixture = try Fixture() - do { _ = try CodexDatabase(path: fixture.destination.path) } - try fixture.write(fixture.destination) { db in - // A constraint beyond the shared column contract fails after earlier - // tables have been inserted. The transaction must still roll them back. - try db.execute(sql: "CREATE UNIQUE INDEX reject_two_states ON codexThreadOwnership(state)") - try db.execute( - sql: - "INSERT INTO codexThreadOwnership (threadID, workspaceID, workspacePath, runtimeID, state, createdAt, updatedAt) VALUES (?, NULL, ?, ?, ?, ?, ?)", - arguments: ["local-thread", "/tmp/local", "local", "released", fixture.date, fixture.date]) - } - let plan = try fixture.preview() - #expect(throws: (any Error).self) { try fixture.apply(plan) } - let target = try CodexDatabase(path: fixture.destination.path) - #expect(try target.codexApprovals().isEmpty) - #expect(try target.codexRuntimeLeases().isEmpty) - #expect(try target.codexThreadOwnerships().count == 1) - } - - @Test(arguments: ["-wal", "-shm", "-journal"]) - func sidecarsRefusePotentiallyLiveSnapshots(_ suffix: String) throws { - let fixture = try Fixture() - try Data().write(to: URL(fileURLWithPath: fixture.source.path + suffix)) - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - #expect(!FileManager.default.fileExists(atPath: fixture.destination.path)) - } - - @Test func sourceDestinationAliasesAndSymbolicLinksAreRejected() throws { - let fixture = try Fixture() - #expect(throws: CodexStateMigration.Failure.self) { - try CodexStateMigration.preview(source: fixture.source, destination: fixture.source) - } - try FileManager.default.linkItem(at: fixture.source, to: fixture.destination) - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - try FileManager.default.removeItem(at: fixture.destination) - try FileManager.default.createSymbolicLink( - at: fixture.destination, withDestinationURL: fixture.source) - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - } - - @Test func destinationMustNotBeHostDatabase() throws { - let fixture = try Fixture() - try FileManager.default.copyItem(at: fixture.source, to: fixture.destination) - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - } - - @Test func unknownColumnsAndMalformedPayloadsAreRejectedWithoutDisclosingValues() throws { - let fixture = try Fixture() - try fixture.write(fixture.source) { db in - try db.execute( - sql: "UPDATE codexRuntimeLeases SET payloadJSON = ?", arguments: ["secret=fixture-only"]) - } - do { - _ = try fixture.preview() - Issue.record("Malformed domain JSON was accepted.") - } catch { - #expect(!error.localizedDescription.contains("fixture-only")) - } - let second = try Fixture() - try second.write(second.source) { db in - try db.execute(sql: "ALTER TABLE codexApprovals ADD COLUMN future_field TEXT") - } - #expect(throws: CodexStateMigration.Failure.self) { try second.preview() } - } - - @Test func indexedScopeMustMatchPayload() throws { - let fixture = try Fixture() - try fixture.write(fixture.source) { db in - try db.execute(sql: "UPDATE codexManagedWorktrees SET sourceWorkspaceID = 'foreign'") - } - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - } - - @Test func olderSnapshotMayOmitTablesButUnrelatedDatabaseIsNotAccepted() throws { - let fixture = try Fixture() - try fixture.write(fixture.source) { db in - for table in CodexStateMigration.tables where table != "codexThreadOwnership" { - try db.execute(sql: "DROP TABLE \(table)") - } - } - #expect(try fixture.preview().insertRows == 1) - try fixture.write(fixture.source) { db in try db.execute(sql: "DROP TABLE codexThreadOwnership") - } - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - } - - @Test func invalidDigestDoesNotCreateFiles() throws { - let fixture = try Fixture() - #expect(throws: CodexStateMigration.Failure.self) { - try CodexStateMigration.apply( - source: fixture.source, destination: fixture.destination, expectedPlanDigest: "bad") - } - #expect(!FileManager.default.fileExists(atPath: fixture.destination.path)) - } - - @Test func canonicallyEquivalentTextIsNotMistakenForByteIdenticalState() throws { - let fixture = try Fixture() - _ = try fixture.apply(fixture.preview()) - try fixture.write(fixture.source) { db in - try db.execute( - sql: "UPDATE codexThreadOwnership SET workspacePath = ?", arguments: ["/tmp/\u{00e9}"]) - } - try fixture.write(fixture.destination) { db in - try db.execute( - sql: "UPDATE codexThreadOwnership SET workspacePath = ?", arguments: ["/tmp/e\u{0301}"]) - } - let plan = try fixture.preview() - #expect(!plan.canApply) - #expect(plan.tables.first { $0.name == "codexThreadOwnership" }?.conflictingRows == 1) - #expect(throws: CodexStateMigration.Failure.self) { try fixture.apply(plan) } - } - - @Test func byteDistinctSQLiteKeysSurviveSwiftCanonicalEquivalence() throws { - let fixture = try Fixture() - try fixture.write(fixture.source) { db in - for key in ["\u{00e9}", "e\u{0301}"] { - try db.execute( - sql: - "INSERT INTO codexThreadOwnership (threadID, workspaceID, workspacePath, runtimeID, state, createdAt, updatedAt) VALUES (?, NULL, ?, ?, ?, ?, ?)", - arguments: [key, "/tmp/local", "runtime", "released", fixture.date, fixture.date]) - } - } - let plan = try fixture.preview() - #expect(plan.insertRows == 9) - #expect(try fixture.apply(plan).insertedRows == 9) - } - - @Test func invalidUTF8IsRejectedRatherThanReplaced() throws { - let fixture = try Fixture() - try fixture.write(fixture.source) { db in - try db.execute(sql: "UPDATE codexThreadOwnership SET workspacePath = CAST(x'ff' AS TEXT)") - } - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - } - - @Test func missingTargetMigrationHistoryAndTriggersFailClosed() throws { - let fixture = try Fixture() - do { _ = try CodexDatabase(path: fixture.destination.path) } - try fixture.write(fixture.destination) { db in - try db.execute(sql: "DELETE FROM grdb_migrations") - } - #expect(throws: CodexStateMigration.Failure.self) { try fixture.preview() } - let second = try Fixture() - do { _ = try CodexDatabase(path: second.destination.path) } - try second.write(second.destination) { db in - try db.execute( - sql: - "CREATE TRIGGER extra_side_effect AFTER INSERT ON codexApprovals BEGIN DELETE FROM codexThreadOwnership; END" - ) - } - #expect(throws: CodexStateMigration.Failure.self) { try second.preview() } - } - - @Test func encodedPreviewContainsNoStoredPayloadOrHostSecret() throws { - let fixture = try Fixture() - let data = try CodexAdapterServer.migrateState( - source: fixture.source, destination: fixture.destination) - let text = String(decoding: data, as: UTF8.self) - #expect(!text.contains("must-not-migrate")) - #expect(!text.contains("retain-me")) - #expect(!text.contains("thread-一")) - #expect(text.contains("can_apply")) - #expect(text.contains("plan_digest")) - } - - private final class Fixture { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - var source: URL { root.appendingPathComponent("host snapshot.sqlite") } - var destination: URL { root.appendingPathComponent("adapter.sqlite") } - let date = Date(timeIntervalSince1970: 1_700_000_000) - - init() throws { - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) - try seed() - } - deinit { try? FileManager.default.removeItem(at: root) } - - func preview() throws -> CodexStateMigration.Plan { - try CodexStateMigration.preview(source: source, destination: destination) - } - func apply(_ plan: CodexStateMigration.Plan) throws -> CodexStateMigration.Result { - try CodexStateMigration.apply( - source: source, destination: destination, expectedPlanDigest: plan.planDigest) - } - func write(_ file: URL, _ body: (Database) throws -> Void) throws { - let writer = try DatabaseQueue(path: file.path) - defer { try? writer.close() } - try writer.write(body) - } - - func restoreHistoricalSchema(at file: URL) throws { - try write(file) { db in - // These are the exact additive columns absent from the embedded host schema. - try db.execute(sql: "ALTER TABLE codexApprovals DROP COLUMN ownerJSON") - try db.execute(sql: "ALTER TABLE codexApprovals DROP COLUMN responseJSON") - try db.execute(sql: "ALTER TABLE codexThreadOwnership DROP COLUMN ownerJSON") - try db.execute( - sql: "DELETE FROM grdb_migrations WHERE identifier = ?", - arguments: ["codex-native-approval-responses"]) - } - } - - private func seed() throws { - do { - let database = try CodexDatabase(path: source.path) - try database.saveCodexApproval( - .init( - id: "approval-1", upstreamRequestID: "request-1", kind: .commandExecution, - risk: .readOnly, - state: .interrupted, workspaceID: "workspace", workspacePath: "/tmp/工作区", - runtimeID: "runtime", - threadID: "thread-一", turnID: nil, itemID: nil, correlationID: "correlation", - socketConnectionID: nil, - tunnelInstanceID: nil, details: .object(["text": .string("Unicode 零\0空")]), - proposedAction: .object([:]), - createdAt: date, expiresAt: date, resolvedAt: date, decision: nil, scope: "", - resolutionReason: "shutdown")) - try database.saveCodexRuntimeLease( - .init( - id: "runtime", owner: nil, workspacePath: "/tmp/工作区", state: "stopped", process: nil, - createdAt: date, updatedAt: date, shutdownReason: "closed", cleanedAt: nil)) - try database.saveCodexThreadOwnership( - .init( - threadID: "thread-一", workspaceID: "workspace", workspacePath: "/tmp/工作区", - runtimeID: "runtime", state: .released, createdAt: date, updatedAt: date)) - _ = try database.applyCodexThreadOwnershipReconciliation( - plan: .init( - schemaVersion: 1, planDigest: "digest", candidates: [], signalsSent: false, - externalStateMutated: false), now: date) - try database.saveCodexOrchestrationRun( - .init( - id: "run", workspaceID: "workspace", workspacePath: "/tmp/工作区", parentRunID: nil, - threadID: "thread-一", officialGoalLinked: true, objective: "Retain Goal linkage", - acceptedScope: ["scope"], - currentPhase: "implementation", acceptanceCriteria: [], evidence: [], - state: .budgetLimited, - activeTurnID: nil, pendingApprovalID: nil, blockers: [], nextAction: nil, - terminalReason: nil, - lastMeaningfulProgressAt: date, repositoryDigest: nil, activeCommandID: nil, - lastCommandProgressAt: nil, - repeatedPlanningCount: 0, repeatedFailureFingerprint: nil, repeatedFailureCount: 0, - turnsUsed: 1, - requiredEvidenceKinds: [], - budget: .init( - maxTurns: 20, maxDurationSeconds: 300, maxNoProgressSeconds: 60, - maxRepeatedFailures: 3), - createdAt: date, updatedAt: date, revision: 1, diagnostics: [])) - _ = try database.acquireCodexWorktreeLease( - workspaceID: "workspace", workspacePath: "/tmp/工作区", mode: .exclusive, agentID: "agent", - threadID: "thread-一", runID: "run", parentLeaseID: nil, branch: "work", ttlSeconds: 60, - now: date) - try database.saveCodexManagedWorktree( - .init( - id: "worktree", sourceWorkspaceID: "workspace", workspaceID: "child", - sourceRepositoryRoot: "/tmp/repo", - gitCommonDirectory: "/tmp/repo/.git", path: "/tmp/child", branch: "child", - startPoint: "HEAD", headOID: "oid", - agentID: "agent", threadID: nil, runID: nil, parentLeaseID: "parent", - profileID: "profile", caller: "caller", - ttlSeconds: 60, leaseID: nil, state: .removed, createdAt: date, updatedAt: date, - planExpiresAt: nil, - removedAt: date, lastError: nil, revision: 1)) - } - try write(source) { db in - try db.execute(sql: "CREATE TABLE hostSecrets (secret TEXT)") - try db.execute(sql: "INSERT INTO hostSecrets VALUES ('must-not-migrate')") - try db.execute(sql: "CREATE TABLE codexElevationGrants (secret TEXT)") - try db.execute(sql: "INSERT INTO codexElevationGrants VALUES ('never-grant-authority')") - // Unknown payload fields remain exactly as stored, not lost to Codable re-encoding. - try db.execute( - sql: - "UPDATE codexRuntimeLeases SET payloadJSON = json_set(payloadJSON, '$.futureField', 'retain-me')" - ) - } - } - } -} diff --git a/Tests/CodexAdapterTests/Fixtures/README.md b/Tests/CodexAdapterTests/Fixtures/README.md new file mode 100644 index 0000000..e2310f5 --- /dev/null +++ b/Tests/CodexAdapterTests/Fixtures/README.md @@ -0,0 +1,13 @@ +# Database schema fixture + +`codex-schema.json` is the complete ordered `sqlite_schema` projection +(`type`, `name`, `tbl_name` as `table`, `sql`) captured from a new database opened +by `CodexDatabase(path:)` at plugin commit +`b5dcc12ce58cb91342e1b844d8003675635ccb2d`. The capture applied all eight domain +migrations with the locked GRDB dependency, before the initial-schema squash. + +`CodexDatabaseSchemaTests` compares the new database directly with this fixture, +including implicit indexes, primary/unique keys, nullability and SQL definitions. +The migration ledger's table definition is included; its row identifiers are +asserted separately. The fixture owns this regression baseline and must not be +regenerated from the implementation under test to make a mismatch pass. diff --git a/Tests/CodexAdapterTests/Fixtures/codex-schema.json b/Tests/CodexAdapterTests/Fixtures/codex-schema.json new file mode 100644 index 0000000..892abe6 --- /dev/null +++ b/Tests/CodexAdapterTests/Fixtures/codex-schema.json @@ -0,0 +1,152 @@ +[ + { + "name": "codexApprovals_on_correlationID", + "sql": "CREATE INDEX \"codexApprovals_on_correlationID\" ON \"codexApprovals\"(\"correlationID\")", + "table": "codexApprovals", + "type": "index" + }, + { + "name": "codexApprovals_on_runtimeID", + "sql": "CREATE INDEX \"codexApprovals_on_runtimeID\" ON \"codexApprovals\"(\"runtimeID\")", + "table": "codexApprovals", + "type": "index" + }, + { + "name": "codexApprovals_on_workspace_state_createdAt", + "sql": "CREATE INDEX \"codexApprovals_on_workspace_state_createdAt\" ON \"codexApprovals\"(\"workspaceID\", \"state\", \"createdAt\")", + "table": "codexApprovals", + "type": "index" + }, + { + "name": "codexManagedWorktrees_on_source_state_updatedAt", + "sql": "CREATE INDEX \"codexManagedWorktrees_on_source_state_updatedAt\" ON \"codexManagedWorktrees\"(\"sourceWorkspaceID\", \"state\", \"updatedAt\")", + "table": "codexManagedWorktrees", + "type": "index" + }, + { + "name": "codexOrchestrationRuns_on_workspace_state_updatedAt", + "sql": "CREATE INDEX \"codexOrchestrationRuns_on_workspace_state_updatedAt\" ON \"codexOrchestrationRuns\"(\"workspaceID\", \"state\", \"updatedAt\")", + "table": "codexOrchestrationRuns", + "type": "index" + }, + { + "name": "codexRuntimeLeases_on_workspace_state_updatedAt", + "sql": "CREATE INDEX \"codexRuntimeLeases_on_workspace_state_updatedAt\" ON \"codexRuntimeLeases\"(\"workspaceID\", \"state\", \"updatedAt\")", + "table": "codexRuntimeLeases", + "type": "index" + }, + { + "name": "codexThreadOwnership_on_workspace_state_updatedAt", + "sql": "CREATE INDEX \"codexThreadOwnership_on_workspace_state_updatedAt\" ON \"codexThreadOwnership\"(\"workspaceID\", \"state\", \"updatedAt\")", + "table": "codexThreadOwnership", + "type": "index" + }, + { + "name": "codexWorktreeLeases_on_workspace_state_heartbeatAt", + "sql": "CREATE INDEX \"codexWorktreeLeases_on_workspace_state_heartbeatAt\" ON \"codexWorktreeLeases\"(\"workspaceID\", \"state\", \"heartbeatAt\")", + "table": "codexWorktreeLeases", + "type": "index" + }, + { + "name": "sqlite_autoindex_codexApprovals_1", + "sql": null, + "table": "codexApprovals", + "type": "index" + }, + { + "name": "sqlite_autoindex_codexManagedWorktrees_1", + "sql": null, + "table": "codexManagedWorktrees", + "type": "index" + }, + { + "name": "sqlite_autoindex_codexManagedWorktrees_2", + "sql": null, + "table": "codexManagedWorktrees", + "type": "index" + }, + { + "name": "sqlite_autoindex_codexOrchestrationRuns_1", + "sql": null, + "table": "codexOrchestrationRuns", + "type": "index" + }, + { + "name": "sqlite_autoindex_codexOwnershipReconciliationReceipts_1", + "sql": null, + "table": "codexOwnershipReconciliationReceipts", + "type": "index" + }, + { + "name": "sqlite_autoindex_codexRuntimeLeases_1", + "sql": null, + "table": "codexRuntimeLeases", + "type": "index" + }, + { + "name": "sqlite_autoindex_codexThreadOwnership_1", + "sql": null, + "table": "codexThreadOwnership", + "type": "index" + }, + { + "name": "sqlite_autoindex_codexWorktreeLeases_1", + "sql": null, + "table": "codexWorktreeLeases", + "type": "index" + }, + { + "name": "sqlite_autoindex_grdb_migrations_1", + "sql": null, + "table": "grdb_migrations", + "type": "index" + }, + { + "name": "codexApprovals", + "sql": "CREATE TABLE \"codexApprovals\" (\"id\" TEXT PRIMARY KEY, \"upstreamRequestID\" TEXT NOT NULL, \"kind\" TEXT NOT NULL, \"risk\" TEXT NOT NULL, \"state\" TEXT NOT NULL, \"workspaceID\" TEXT, \"workspacePath\" TEXT NOT NULL, \"runtimeID\" TEXT NOT NULL, \"threadID\" TEXT, \"turnID\" TEXT, \"itemID\" TEXT, \"correlationID\" TEXT NOT NULL, \"socketConnectionID\" TEXT, \"tunnelInstanceID\" TEXT, \"detailsJSON\" TEXT NOT NULL, \"proposedActionJSON\" TEXT NOT NULL, \"createdAt\" DATETIME NOT NULL, \"expiresAt\" DATETIME NOT NULL, \"resolvedAt\" DATETIME, \"decision\" TEXT, \"scope\" TEXT, \"resolutionReason\" TEXT, \"ownerJSON\" TEXT, \"responseJSON\" TEXT)", + "table": "codexApprovals", + "type": "table" + }, + { + "name": "codexManagedWorktrees", + "sql": "CREATE TABLE \"codexManagedWorktrees\" (\"id\" TEXT PRIMARY KEY, \"sourceWorkspaceID\" TEXT NOT NULL, \"workspaceID\" TEXT NOT NULL UNIQUE, \"state\" TEXT NOT NULL, \"updatedAt\" DATETIME NOT NULL, \"payloadJSON\" TEXT NOT NULL)", + "table": "codexManagedWorktrees", + "type": "table" + }, + { + "name": "codexOrchestrationRuns", + "sql": "CREATE TABLE \"codexOrchestrationRuns\" (\"id\" TEXT PRIMARY KEY, \"workspaceID\" TEXT NOT NULL, \"state\" TEXT NOT NULL, \"updatedAt\" DATETIME NOT NULL, \"payloadJSON\" TEXT NOT NULL)", + "table": "codexOrchestrationRuns", + "type": "table" + }, + { + "name": "codexOwnershipReconciliationReceipts", + "sql": "CREATE TABLE \"codexOwnershipReconciliationReceipts\" (\"id\" TEXT PRIMARY KEY, \"planDigest\" TEXT NOT NULL, \"appliedAt\" DATETIME NOT NULL, \"payloadJSON\" TEXT NOT NULL)", + "table": "codexOwnershipReconciliationReceipts", + "type": "table" + }, + { + "name": "codexRuntimeLeases", + "sql": "CREATE TABLE \"codexRuntimeLeases\" (\"id\" TEXT PRIMARY KEY, \"workspaceID\" TEXT, \"state\" TEXT NOT NULL, \"updatedAt\" DATETIME NOT NULL, \"payloadJSON\" TEXT NOT NULL)", + "table": "codexRuntimeLeases", + "type": "table" + }, + { + "name": "codexThreadOwnership", + "sql": "CREATE TABLE \"codexThreadOwnership\" (\"threadID\" TEXT PRIMARY KEY, \"workspaceID\" TEXT, \"workspacePath\" TEXT NOT NULL, \"runtimeID\" TEXT NOT NULL, \"state\" TEXT NOT NULL, \"createdAt\" DATETIME NOT NULL, \"updatedAt\" DATETIME NOT NULL, \"ownerJSON\" TEXT)", + "table": "codexThreadOwnership", + "type": "table" + }, + { + "name": "codexWorktreeLeases", + "sql": "CREATE TABLE \"codexWorktreeLeases\" (\"id\" TEXT PRIMARY KEY, \"workspaceID\" TEXT NOT NULL, \"state\" TEXT NOT NULL, \"heartbeatAt\" DATETIME NOT NULL, \"payloadJSON\" TEXT NOT NULL)", + "table": "codexWorktreeLeases", + "type": "table" + }, + { + "name": "grdb_migrations", + "sql": "CREATE TABLE grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY)", + "table": "grdb_migrations", + "type": "table" + } +] diff --git a/Tests/CodexMCPAdapterTests/CommandTests.swift b/Tests/CodexMCPAdapterTests/CommandTests.swift index ff8c48b..1e4cc08 100644 --- a/Tests/CodexMCPAdapterTests/CommandTests.swift +++ b/Tests/CodexMCPAdapterTests/CommandTests.swift @@ -35,24 +35,4 @@ struct CommandTests { #expect(CodexMCPAdapter.CompareSchema.helpMessage().contains(" ")) } - @Test func parsesOfflineMigrationWithoutStartingServer() throws { - let command = try #require( - CodexMCPAdapter.parseAsRoot([ - "migrate-state", "--source-snapshot", "/tmp/source snapshot.sqlite", "--destination", - "/tmp/target.sqlite", - ]) as? CodexMCPAdapter.MigrateState) - #expect(!command.apply) - #expect(command.expectedPlanDigest == nil) - #expect(command.sourceSnapshot == "/tmp/source snapshot.sqlite") - #expect(CodexMCPAdapter.helpMessage().contains("migrate-state")) - for extra in [["--apply"], ["--expected-plan-digest", "digest"]] { - #expect(throws: (any Error).self) { - try CodexMCPAdapter.parseAsRoot( - [ - "migrate-state", "--source-snapshot", "/tmp/source", "--destination", "/tmp/target", - ] + extra) - } - } - } - } diff --git a/Tests/Fixtures/NativeCodex/HostToolVendor.py b/Tests/Fixtures/NativeCodex/HostToolVendor.py new file mode 100644 index 0000000..4891549 --- /dev/null +++ b/Tests/Fixtures/NativeCodex/HostToolVendor.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Disposable App Server protocol peer for exact native-to-host callback acceptance.""" + +import json +import os +from pathlib import Path +import stat +import sys +import threading +import time + +root = Path(os.environ["CODEX_HOME"]) +lock = threading.Lock() + + +def save(name, value): + temporary = root / (name + ".tmp") + temporary.write_text(json.dumps(value)) + temporary.replace(root / name) + + +def emit(value): + with lock: + print(json.dumps(value), flush=True) + + +def queued_callbacks(): + while True: + for path in sorted(root.glob("queued-*.json")): + value = json.loads(path.read_text()) + path.unlink() + emit(value) + time.sleep(0.01) + + +sockets = [] +for descriptor in range(3, 10): + try: + if stat.S_ISSOCK(os.fstat(descriptor).st_mode): + sockets.append(descriptor) + except OSError: + pass +save("vendor.json", {"pid": os.getpid(), "inherited_host_socket": bool(sockets), + "inherited_host_env": any(key in os.environ for key in + ("COMPUTER_MCP_HOST_FD", "COMPUTER_MCP_HOST_CONTEXT", "CODEX_THREAD_ID"))}) +for line in sys.stdin: + request = json.loads(line) + method = request.get("method") + if method == "initialize": + result = {"codexHome": str(root), "platformFamily": "unix", "platformOs": "macos", + "userAgent": "isolated-callback-fixture"} + elif method == "initialized": + threading.Thread(target=queued_callbacks, daemon=True).start() + continue + elif method == "thread/loaded/list": + result = {"data": [], "nextCursor": None} + elif method is None and isinstance(request.get("id"), int) and request["id"] >= 900: + save("callback-" + str(request["id"]) + ".json", request) + continue + elif method == "thread/unsubscribe": + result = {"status": "unsubscribed"} + elif "id" not in request: + continue + else: + emit({"id": request["id"], "error": {"code": -32601, "message": "Fixture method not implemented"}}) + continue + emit({"id": request["id"], "result": result}) diff --git a/Tests/Fixtures/NativeCodex/ModelServer.py b/Tests/Fixtures/NativeCodex/ModelServer.py new file mode 100644 index 0000000..0082f10 --- /dev/null +++ b/Tests/Fixtures/NativeCodex/ModelServer.py @@ -0,0 +1,115 @@ +"""Loopback Responses fixture; only the native Codex process executes test commands.""" + +import http.server +import json +from pathlib import Path +import sys +import threading +import time +import uuid + +root = Path(sys.argv[1]) +consumed = set() +calls = {} +lock = threading.Lock() +finish = threading.Event() + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + if self.path != "/probe": + self.send_error(404) + return + data = (root / "network-token").read_bytes() + self.send_response(200) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_POST(self): + if self.headers.get("Authorization"): + self.send_error(400, "Authentication is not part of this fixture") + return + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= 4_194_304 or self.path != "/v1/responses": + self.send_error(400) + return + payload = json.loads(self.rfile.read(length)) + recipe = json.loads((root / "response.json").read_text()) + phase = recipe["id"] + item = { + "id": "msg_" + uuid.uuid4().hex, "type": "message", "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "Fixture complete.", "annotations": []}], + } + with lock: + for entry in payload.get("input", []): + phase_id = calls.get(entry.get("call_id")) + if entry.get("type") == "function_call_output" and phase_id is not None: + target = root / ("result-" + phase_id + ".json") + temporary = target.with_suffix(".tmp") + temporary.write_text(json.dumps(entry), encoding="utf-8") + temporary.replace(target) + if recipe.get("command") and phase not in consumed: + names = [tool.get("name", tool.get("function", {}).get("name")) for tool in payload.get("tools", [])] + if "exec_command" not in names: + self.send_error(400, "Native exec_command unavailable") + return + consumed.add(phase) + item = { + "id": "fc_" + uuid.uuid4().hex, "type": "function_call", "name": "exec_command", + "call_id": "call_" + uuid.uuid4().hex, + "arguments": json.dumps({ + "cmd": recipe["command"], "shell": "/bin/sh", "login": False, + "yield_time_ms": 10000, "max_output_tokens": 1000, + }), + } + calls[item["call_id"]] = phase + (root / ("started-" + phase)).touch() + if item["type"] == "message" and recipe.get("hold_after_command"): + # Keep the turn unfinished after the native command has returned its result. + finish.wait() + return + response = { + "id": "resp_" + uuid.uuid4().hex, "object": "response", "created_at": int(time.time()), + "model": "acceptance-fixture", "status": "completed", "output": [item], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Connection", "close") + self.end_headers() + try: + self.event("response.created", {"response": dict(response, status="in_progress", output=[])}) + self.event("response.output_item.added", {"output_index": 0, "item": item}) + if item["type"] == "message": + self.event("response.output_text.delta", { + "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": "Fixture complete.", + }) + self.event("response.output_item.done", {"output_index": 0, "item": item}) + self.event("response.completed", {"response": response}) + except (BrokenPipeError, ConnectionResetError): + pass + + def event(self, kind, fields): + data = json.dumps(dict(fields, type=kind)).encode() + self.wfile.write(b"event: " + kind.encode() + b"\ndata: " + data + b"\n\n") + self.wfile.flush() + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) +server.daemon_threads = True +worker = threading.Thread(target=server.serve_forever, daemon=True) +worker.start() +print(json.dumps({"port": server.server_port}), flush=True) +try: + for _ in sys.stdin: + pass +finally: + finish.set() + server.shutdown() + server.server_close() + worker.join(timeout=2) diff --git a/Tests/Fixtures/NativeCodex/README.md b/Tests/Fixtures/NativeCodex/README.md new file mode 100644 index 0000000..02a73ad --- /dev/null +++ b/Tests/Fixtures/NativeCodex/README.md @@ -0,0 +1,27 @@ +# Native Codex model fixture + +`ModelServer.py` serves a private loopback Responses endpoint and network token. +It never executes commands. `Scripts/check-host.py` owns its input recipes, +workspaces, network token, process and native Codex generations. Each recipe +issues at most one native `exec_command` call using a non-login shell. Exact +`function_call_output` receipts are correlated to the issued call ID and checked +against independent filesystem, Git and network observations. HTTP authentication +is rejected. The service exits on stdin EOF. + +A held response leaves a native turn unfinished after its command result, so +acceptance can exercise cancellation and recovery without replaying a write. +The driver verifies process ancestry before failure injection and waits for all +receipted processes to exit. See [Release](../../../Documentation/Reference/Release.md#runtime-acceptance) +for full and fixture-only acceptance. + +Fixture provenance: Computer MCP commit +`65921cd298783003895106b293fa6dd540e47313`, +`Tests/ComputerMCPTests/Fixtures/NativeCodex/ModelServer.py`. + +`HostToolVendor.py` is a disposable protocol peer for native `item/tool/call` +forwarding. It reads queued requests from its private vendor home and records +exact replies and descriptor/environment isolation. It exercises the real +adapter and public host MCP; it does not impersonate an authenticated model. +The driver owns the Git repository and verifies tickets, side effects and +native callback results separately. This peer adapts the `vendorSource` in the +same source commit's `CodexPluginHostIntegrationTests.swift`. diff --git a/Tests/test_host_acceptance.py b/Tests/test_host_acceptance.py new file mode 100644 index 0000000..daacae4 --- /dev/null +++ b/Tests/test_host_acceptance.py @@ -0,0 +1,244 @@ +"""Argument and evidence validation for the public-host runtime acceptance driver.""" + +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + +SCRIPT = Path(__file__).resolve().parents[1] / "Scripts/check-host.py" +spec = importlib.util.spec_from_file_location("host_acceptance", SCRIPT) +acceptance = importlib.util.module_from_spec(spec) +spec.loader.exec_module(acceptance) + + +class HostAcceptanceTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.executable = self.root / "executable with spaces" + self.executable.write_text("#!/bin/sh\nexit 99\n") + self.executable.chmod(0o700) + self.arguments = ["--host", str(self.executable), "--adapter", str(self.executable), + "--codex", str(self.executable), "--output-directory", str(self.root / "output")] + + def invalid(self, arguments): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error: + acceptance.parse_arguments(arguments) + self.assertEqual(error.exception.code, 2) + self.assertFalse((self.root / "output").exists(), "Parsing must not create runtime state") + + def test_fixture_arguments_do_not_launch_or_create_state(self): + parsed = acceptance.parse_arguments(self.arguments + ["--mode", "fixture"]) + self.assertEqual(parsed.host, self.executable) + self.assertEqual(parsed.mode, "fixture") + self.assertFalse(parsed.output_directory.exists()) + + def test_full_requires_explicit_authentication_and_model(self): + self.invalid(self.arguments) + auth = self.root / "auth.json" + auth.write_text("{}") + self.invalid(self.arguments + ["--auth-file", str(auth)]) + self.invalid(self.arguments + ["--model", "selected-model"]) + parsed = acceptance.parse_arguments(self.arguments + ["--auth-file", str(auth), "--model", "selected-model"]) + self.assertEqual(parsed.mode, "full") + self.invalid(self.arguments + ["--auth-file", str(auth), "--model", " "]) + self.invalid(self.arguments + ["--auth-file", str(auth), "--model", "bad\nmodel"]) + self.invalid(self.arguments + ["--mode", "fixture", "--auth-file", str(auth)]) + self.invalid(self.arguments + ["--mode", "fixture", "--model", "unused"]) + + def test_inputs_reject_relative_missing_directory_and_nonexecutable(self): + unavailable = self.root / "unavailable" + unavailable.write_text("not executable") + for flag in ("--host", "--adapter", "--codex"): + for path in ("relative", str(self.root / "missing"), str(self.root), str(unavailable)): + with self.subTest(flag=flag, path=path): + self.invalid(self.arguments + ["--mode", "fixture", flag, path]) + self.invalid(self.arguments + ["--auth-file", "relative", "--model", "model"]) + + def test_output_must_be_new_and_absolute_even_for_dangling_symlink(self): + link = self.root / "link" + link.symlink_to(self.root / "absent") + for path in ("relative", str(self.root), str(self.executable), str(link)): + with self.subTest(path=path): + self.invalid(self.arguments + ["--mode", "fixture", "--output-directory", path]) + + def test_timeout_is_bounded(self): + for value in ("0", "-1", "901", "nan", "1.5"): + self.invalid(self.arguments + ["--mode", "fixture", "--timeout-seconds", value]) + for value in (1, 900): + parsed = acceptance.parse_arguments(self.arguments + ["--mode", "fixture", "--timeout-seconds", str(value)]) + self.assertEqual(parsed.timeout_seconds, value) + + def test_nested_tool_errors_cannot_be_success(self): + payload = {"thread": {"id": "native"}} + result = {"isError": False, "structuredContent": {"result": payload}} + indirect = {"structuredContent": {"result": result}} + self.assertEqual(acceptance.unwrap(result), payload) + self.assertEqual(acceptance.unwrap(indirect, indirect=True), payload) + for failure in ({"isError": True, "content": []}, + {"structuredContent": {"result": {"isError": True}}}): + with self.assertRaises(RuntimeError): + acceptance.unwrap(failure, indirect="structuredContent" in failure) + + def test_archive_requires_success_or_exact_missing_rollout_identity(self): + success = {"structuredContent": {"result": {}}} + self.assertEqual(acceptance.archive_disposition(success, "thread-1"), "archived") + for detail in ("no rollout found for thread id thread-1", "native error: no rollout found for thread id thread-1"): + result = {"isError": True, "structuredContent": {"error": {"message": detail}}} + self.assertEqual(acceptance.archive_disposition(result, "thread-1"), "already-absent") + for detail in (None, "", "no rollout found for thread id thread-2", "no rollout found for thread id thread-1-extra", + "no rollout found for thread id thread-1; denied", "thread not found", "permission denied"): + result = {"isError": True, "structuredContent": {"error": {"message": detail}}} + self.assertEqual(acceptance.archive_disposition(result, "thread-1"), "failed") + for thread in ("", "thread with space", "thread\n1", None): + self.assertEqual(acceptance.archive_disposition(success, thread), "failed") + self.assertEqual(acceptance.archive_disposition({}, "thread-1"), "failed") + + def test_turn_completion_must_match_thread_turn_and_terminal_state(self): + payload = {"method": "turn/completed", "params": {"threadId": "thread", "turn": {"id": "turn", "status": "completed"}}} + page = {"missed_events": 0, "events": [{"payload": payload}]} + self.assertTrue(acceptance.completed_turn(page, "thread", "turn", "completed")) + self.assertFalse(acceptance.completed_turn(page, "sibling", "turn", "completed")) + self.assertFalse(acceptance.completed_turn(page, "thread", "other", "completed")) + with self.assertRaises(AssertionError): + acceptance.completed_turn(page, "thread", "turn", "interrupted") + page["missed_events"] = 1 + with self.assertRaises(AssertionError): + acceptance.completed_turn(page, "thread", "turn", "completed") + + def test_command_status_is_evidence_not_a_substring_success(self): + self.assertEqual(acceptance.command_exit("Process exited with code 0\nOutput: ok"), 0) + self.assertEqual(acceptance.command_exit("Process exited with code -9"), -9) + with self.assertRaises(AssertionError): + acceptance.command_exit("No command ran") + + def test_failure_injection_requires_owned_descendant(self): + parents = {5: 4, 4: 3, 3: 2, 9: 9} + self.assertTrue(acceptance.descendant(5, 3, parents.get)) + for pid in (3, 1, 9, 100, None): + self.assertFalse(acceptance.descendant(pid, 3, parents.get)) + self.assertFalse(acceptance.descendant(5, 8, parents.get)) + self.assertFalse(acceptance.descendant(100, 2, lambda value: value - 1)) + + def failed_authenticated_fixture(self): + auth = self.root / "fake-auth.json" + auth.write_text('{"fake_credential":"unit-test-only"}') + options = acceptance.parse_arguments(self.arguments + ["--auth-file", str(auth), "--model", "fake-model"]) + return acceptance.Fixture(options) + + def run_authenticated_failure(self, fixture): + copied_auth = fixture.home / "auth.json" + output = io.StringIO() + + def fail_configuration(*args, **kwargs): + self.assertEqual(copied_auth.read_text(), fixture.options.auth_file.read_text()) + raise RuntimeError("Authenticated run failed") + + with contextlib.ExitStack() as stack: + stack.enter_context(patch.object(acceptance, "Fixture", return_value=fixture)) + stack.enter_context(patch.object(acceptance.subprocess, "run", return_value=Mock(stdout="fake-version"))) + launch = stack.enter_context(patch.object(acceptance.subprocess, "Popen", + side_effect=AssertionError("Unexpected process launch"))) + for phase in ("setup", "catalog_and_diagnostics", "directories", "native_permissions", "handoff", + "sibling_workspace", "recovery", "app_lifecycle", "exec_lifecycle", "host_callbacks"): + stack.enter_context(patch.object(fixture, phase)) + configure = stack.enter_context(patch.object(fixture, "configure", side_effect=fail_configuration)) + stack.enter_context(contextlib.redirect_stdout(output)) + self.assertEqual(acceptance.run(fixture.options), 1) + configure.assert_called_once() + launch.assert_not_called() + self.assertFalse(copied_auth.exists()) + self.assertEqual(fixture.options.auth_file.read_text(), '{"fake_credential":"unit-test-only"}') + report = json.loads((fixture.root / "report.json").read_text()) + summary = json.loads(output.getvalue()) + self.assertFalse(report["passed"]) + self.assertFalse(summary["passed"]) + self.assertEqual(report["failure"], "Authenticated run failed") + self.assertEqual(summary["failure"], report["failure"]) + self.assertEqual(summary["cleanup_errors"], report["cleanup_errors"]) + return report + + def test_credential_recreated_during_process_exit_wait_is_removed(self): + fixture = self.failed_authenticated_fixture() + copied_auth = fixture.home / "auth.json" + + def refresh_credential(*args, **kwargs): + self.assertFalse(copied_auth.exists()) + copied_auth.write_text('{"fake_credential":"refreshed-unit-test-only"}') + return True + + with patch.object(acceptance, "wait_until", side_effect=refresh_credential) as wait: + report = self.run_authenticated_failure(fixture) + wait.assert_called_once() + self.assertEqual(report["cleanup_errors"], []) + + def test_malformed_launch_receipt_removes_copied_auth_and_reports_failure(self): + fixture = self.failed_authenticated_fixture() + (fixture.root / "launches.jsonl").write_text("{malformed receipt}\n") + with patch.object(acceptance, "wait_until", return_value=True) as wait: + report = self.run_authenticated_failure(fixture) + wait.assert_called_once() + self.assertEqual(len(report["cleanup_errors"]), 1) + self.assertIn("Launch receipts: Expecting property name", report["cleanup_errors"][0]) + self.assertTrue(report["binary_digests_unchanged"]) + + def test_unreadable_launch_receipt_removes_copied_auth_and_reports_failure(self): + fixture = self.failed_authenticated_fixture() + (fixture.root / "launches.jsonl").mkdir() + report = self.run_authenticated_failure(fixture) + self.assertEqual(len(report["cleanup_errors"]), 1) + self.assertIn("Launch receipts:", report["cleanup_errors"][0]) + + def test_raising_teardown_steps_run_independently_after_credential_removal(self): + fixture = self.failed_authenticated_fixture() + fixture.hosts = [Mock(log=Mock(closed=False)), Mock(log=Mock(closed=False))] + fixture.model = Mock() + fixture.model_log = Mock() + (fixture.root / "launches.jsonl").write_text(json.dumps({"pid": 123, "cwd": str(fixture.root / "primary")}) + "\n") + + def failure(message): + def raise_after_removal(*args, **kwargs): + self.assertFalse((fixture.home / "auth.json").exists()) + raise RuntimeError(message) + return raise_after_removal + + actions = [(host.close, f"host {index} failed") for index, host in enumerate(fixture.hosts)] + actions += [(fixture.model.stdin.close, "stdin failed"), (fixture.model.wait, "model wait failed"), + (fixture.model_log.close, "model log failed")] + for action, message in actions: + action.side_effect = failure(message) + with patch.object(acceptance, "wait_until", side_effect=failure("process exit check failed")) as wait: + report = self.run_authenticated_failure(fixture) + wait.assert_called_once() + self.assertEqual(fixture.pids, {123}) + self.assertEqual(len(report["cleanup_errors"]), len(actions) + 1) + for action, message in actions: + action.assert_called_once() + self.assertTrue(any(message in error for error in report["cleanup_errors"])) + self.assertIn("process exit check failed", report["cleanup_errors"]) + + def test_raising_cleanup_and_digest_check_still_emit_failure_report(self): + fixture = self.failed_authenticated_fixture() + + def teardown(): + self.assertFalse((fixture.home / "auth.json").exists()) + (fixture.home / "auth.json").write_text('{"fake_credential":"refreshed-unit-test-only"}') + self.executable.unlink() + raise RuntimeError("Fixture teardown failed") + + with patch.object(fixture, "cleanup", side_effect=teardown) as cleanup: + report = self.run_authenticated_failure(fixture) + cleanup.assert_called_once() + self.assertFalse(report["binary_digests_unchanged"]) + self.assertEqual(len(report["cleanup_errors"]), 2) + self.assertEqual(report["cleanup_errors"][0], "Fixture cleanup: Fixture teardown failed") + self.assertIn("Binary digest verification:", report["cleanup_errors"][1]) + + +if __name__ == "__main__": + unittest.main()