diff --git a/skills/autopilot/README.md b/skills/autopilot/README.md index fe588ea..54c69a6 100644 --- a/skills/autopilot/README.md +++ b/skills/autopilot/README.md @@ -21,6 +21,8 @@ changes and rollback notes. Open a PR, but do not merge or deploy it. When using Pi's process-local backend, load `runtime/dist/src/pi-extension-entry.js` through Pi's documented extension or package mechanism, then start the sealed charter with `/autopilot-start `. The owning Pi process must remain uninterrupted; losing it makes the exact attempt unknown and never triggers a replacement. +Codex offers two explicit charter modes. `codex-app-server` binds an implementation to one per-attempt app-server instance and exact ephemeral thread/turn; connection or server loss becomes unknown. `codex` retains the direct CLI fallback and POSIX process-supervised restart reattachment. App-server independent review still uses the direct read-only CLI path. + Autopilot will: 1. turn the request into explicit work items, completion predicates, budgets, and grants; diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index bdfe020..7c43447 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -7,7 +7,7 @@ disable-model-invocation: true # Autopilot -Autopilot delegates bounded coding work to a fresh Claude Code, Codex, Pi, or OpenCode execution. Pi implementations prefer the packaged process-local extension backend; other modes and the Pi fallback use their declared CLI boundaries. The runtime owns lifecycle state, Git commits, verification, remote delivery, and completion decisions. +Autopilot delegates bounded coding work to a fresh Claude Code, Codex, Pi, or OpenCode execution. Pi implementations prefer the packaged process-local extension backend. Codex charters select either the exact same-instance `codex-app-server` backend or the distinct direct `codex` CLI path. Other modes and fallbacks use their declared CLI boundaries. The runtime owns lifecycle state, Git commits, verification, remote delivery, and completion decisions. ## New-run preconditions @@ -18,6 +18,7 @@ For a new implementation run: 3. Confirm Node.js 24+, Git, and the selected harness are available. 4. Resolve how the repository selects each required build toolchain before sealing command gates. Prefer checked-in wrappers or toolchain configuration; when a gate or hook still requires a named environment selector, forward only that name and authorize it for the runtime. 5. Do not install tools, download runtimes, authenticate providers, or modify global configuration. +6. For Codex, select `codex-app-server` only when the operator accepts same-instance cooperative terminality; select `codex` for the distinct direct CLI mode. Never switch between them after admission or infer one mode's guarantees from the other. Treat “I am going to sleep,” “work overnight,” and “have this ready in the morning” as explicit unattended-run intent. Preserve that intent in `sourceText`, but do not turn it into a deadline, merge grant, deployment grant, or guarantee of completion time. diff --git a/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md b/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md index b7c40aa..2107f54 100644 --- a/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md +++ b/skills/autopilot/docs/2026-08-30-continuity-evidence-implementation-plan.md @@ -487,7 +487,7 @@ skills/autopilot/runtime/test/fault-injection.test.ts ## Phase 7: Finish documentation and packaging -**Result:** Implemented for Phases 0–6A. Phase 6B is not promoted under the recorded no-receiver decision and is not a current release blocker. The current validation baseline is 187 Node tests locally after intentional removal of 13 native-helper-specific tests and their production surface. The earlier 200-test Pi process-local, 162-test, and 137-test baselines passed at their recorded revisions on Ubuntu and Windows. +**Result:** Implemented for Phases 0–6A. Phase 6B is not promoted under the recorded no-receiver decision and is not a current release blocker. The current validation baseline is 205 Node tests locally after adding Codex app-server contract coverage. The earlier 187-test native-free, 200-test Pi process-local, 162-test, and 137-test baselines passed at their recorded revisions on Ubuntu and Windows. ### Files diff --git a/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md b/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md index 5803fe8..ec46ca2 100644 --- a/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md +++ b/skills/autopilot/docs/2026-08-31-cooperative-harness-execution-plan.md @@ -1,6 +1,6 @@ # Cooperative harness execution implementation plan -- **Status:** Implemented through PR 5: decision/promotion shutdown, execution assurance, fenced unknown-execution recovery, Pi process-local integration, and Windows native-path removal are complete; later provider investigations remain separate +- **Status:** Implemented through the Codex app-server integration: decision/promotion shutdown, execution assurance, fenced unknown-execution recovery, Pi process-local integration, Windows native-path removal, and exact same-instance Codex turns are complete; OpenCode and Claude investigations remain separate - **Date:** 2026-08-31 - **Audience:** Autopilot implementers and reviewers - **Related:** [Architecture](architecture.md), [continuity implementation plan](2026-08-30-continuity-evidence-implementation-plan.md), [durable event engine ADR](adr/0001-durable-event-engine.md) @@ -364,11 +364,20 @@ Validation: - Windows continuity loss deterministically becomes unknown. - Ubuntu and Windows typecheck, lint, formatting, full tests, package smoke, and generated-artifact checks pass. +### Codex app-server integration + +**Status:** Completed and kept distinct from the direct CLI adapter. + +Codex 0.151.0 exposes exact v2 thread and turn identities, streamed lifecycle notifications, and `turn/interrupt` over the default stdio app-server transport. Autopilot starts one app-server process per implementation attempt, uses one initialized connection, creates one ephemeral unattended workspace-write thread, and admits one turn. The subject binds an app-server instance nonce, thread ID, turn ID, and installed Codex version. Only the matching `turn/completed` notification from that uninterrupted connection is terminal. Server requests are denied, malformed or oversized protocol output fails closed, and cancellation remains pending until the same turn reports `interrupted`. + +The adapter sets the app-server process working directory to the attempt worktree but omits `thread/start.cwd`. Codex 0.151.0 persists project trust when a writable thread starts with an explicit `cwd`; omitting that field preserves the selected worktree without changing the user's Codex configuration. A live validation confirmed exact completion, exact cancellation, no tracked worktree mutation, and an unchanged `~/.codex/config.toml` digest. + +The default stdio transport cannot reconnect after coordinator loss. WebSocket transport is experimental and unsupported, and daemon bootstrap would add separately managed global state. Autopilot therefore does not attempt `thread/resume` on a replacement connection and advertises `same-harness-instance`, cooperative, single-shot assurance rather than durable reattachment. Connection, coordinator, or app-server loss becomes `EXECUTION_STATE_UNKNOWN`. Logical turn completion and interruption do not prove background-terminal or OS process-tree quiescence. Independent review remains on the direct read-only Codex CLI path, while the `codex` charter value retains the previous CLI adapter and its POSIX process supervisor. + ### Later provider work -Investigate each provider as a separate boundary after Pi is proven. +Investigate each remaining provider as a separate boundary. -- Codex: version-pin a harness-owned app-server and exact thread/turn reconciliation. - OpenCode: require exact prompt-attempt correlation and REST reconciliation around live-only events. - Claude Code: remain session-scoped until an active execution attachment surface exists. @@ -457,6 +466,7 @@ Pause and revisit the design if implementation shows any of the following: - Controlled Pi process-local tests cover exact admission, cancellation, terminal-before-shutdown precedence, reload/session invalidation, lost admission, late/mismatched result rejection, direct fallback, and runtime-core completion in one reused local repository fixture. Whole-process live fault evidence remains environment-specific and does not prove OS quiescence or provider parity. - No provider currently proves Windows process-tree quiescence through its public subagent contract. -- Codex app-server live rejoin, OpenCode disconnect reconciliation, and Claude interruption behavior were researched but not exercised for this design. +- Codex app-server 0.151.0 exact completion and interruption were exercised over one uninterrupted stdio connection. Cross-connection live rejoin is intentionally unimplemented because the default transport is not reconnectable; continuity loss remains unknown. +- OpenCode disconnect reconciliation and Claude interruption behavior remain researched but unexercised for this design. - Cooperative terminality does not prevent external effects performed by worker tools before terminal response. - The Pi entry point follows the documented package manifest at `runtime/dist/src/pi-extension-entry.js` and registers `/autopilot-start`, `/autopilot-resume`, and `/autopilot-recover`; callers must load it through Pi's normal package or extension mechanism. diff --git a/skills/autopilot/docs/README.md b/skills/autopilot/docs/README.md index 20b1acb..77f4d82 100644 --- a/skills/autopilot/docs/README.md +++ b/skills/autopilot/docs/README.md @@ -39,4 +39,4 @@ Both figures use the vendored Diagram Design default profile. The PNG exports ar ## Verification status -The runtime has 187 Node tests locally covering deterministic attempt context, predicate evidence maps, exact-tree review fixtures, local Git lifecycle behavior, crash reconciliation, intentional pause, exact-subject provider waiting, hooks, queues, stacks, sealed review-feedback amendments, GitHub and GitLab provider contracts, sibling worktrees, POSIX descendant process cancellation, native-free package inventory, and wrap-up. Controlled Pi 0.84.4 with pi-subagents 0.60.0 process-local tests cover exact admission, cancellation, extension-context loss, direct fallback, and runtime-core completion in a reused local repository; disposable exact-tree reviews also passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25; Claude Code 2.1.251 remains unverified because its identity-linked API key requires an `ANTHROPIC_WORKSPACE_ID` that is not present in the validation environment. GitHub PR creation, marker reconciliation, exact review-thread resolution, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `gh` 2.98.0. GitLab MR creation and reconciliation, exact discussion resolution, duplicate-status latest selection, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `glab` 1.115.0. +The runtime has 205 Node tests locally covering deterministic attempt context, predicate evidence maps, exact-tree review fixtures, local Git lifecycle behavior, crash reconciliation, intentional pause, exact-subject provider waiting, hooks, queues, stacks, sealed review-feedback amendments, GitHub and GitLab provider contracts, sibling worktrees, POSIX descendant process cancellation, native-free package inventory, Codex app-server continuity contracts, and wrap-up. Controlled Pi 0.84.4 with pi-subagents 0.60.0 process-local tests cover exact admission, cancellation, extension-context loss, direct fallback, and runtime-core completion in a reused local repository. Codex app-server 0.151.0 live completion and interruption passed on one uninterrupted stdio connection without changing the tested Codex config digest; its connection-loss behavior has controlled fault coverage. Disposable exact-tree reviews also passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25; Claude Code 2.1.251 remains unverified because its identity-linked API key requires an `ANTHROPIC_WORKSPACE_ID` that is not present in the validation environment. GitHub PR creation, marker reconciliation, exact review-thread resolution, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `gh` 2.98.0. GitLab MR creation and reconciliation, exact discussion resolution, duplicate-status latest selection, exact-head amendment, merge, and wrap-up passed on an authorized private reusable validation project with `glab` 1.115.0. diff --git a/skills/autopilot/docs/architecture.md b/skills/autopilot/docs/architecture.md index 19eadd5..c0cb100 100644 --- a/skills/autopilot/docs/architecture.md +++ b/skills/autopilot/docs/architecture.md @@ -136,7 +136,7 @@ interface RunCharter { readonly baseCommit: string; readonly writableRoots: readonly string[]; }; - readonly harnessAdapter: "pi" | "claude-code" | "codex" | "opencode"; + readonly harnessAdapter: "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode"; readonly mode: "single" | "independent-queue" | "ordered-stack"; readonly work: readonly WorkItem[]; readonly delivery: "local-commits" | "change-request-ready" | "merge-verified"; @@ -310,7 +310,9 @@ Capability degradation is explicit: - Missing or incomplete attempt-supervisor evidence fails closed as `EXECUTION_STATE_UNKNOWN`; the runtime never spends another attempt on a speculative replacement. - Missing a required delivery or enforcement capability stops before edits. -The first adapters target Claude Code, Codex, Pi, and OpenCode. They share one conformance suite. +The first adapters target Claude Code, Codex, Pi, and OpenCode. They share one conformance suite. The `codex` and `codex-app-server` charter values are distinct execution modes: direct Codex CLI retains runtime-owned POSIX supervision, while app-server implementation turns use harness-owned same-instance cooperative terminality. + +The Codex app-server adapter starts one per-attempt stdio server with its process working directory set to the dedicated worktree. After `initialize`, it creates an ephemeral workspace-write thread with unattended approvals and deliberately omits the `cwd` request field, avoiding Codex's persistent project-trust update while still verifying the returned working directory. It journals an exact subject derived from the app-server instance nonce, thread ID, and turn ID. Only the matching `turn/completed` notification on the same connection is terminal; interruption must finish that turn as `interrupted`. Server requests are denied. The stdio connection cannot be reattached, while reconnectable Codex transports remain experimental or require separately managed daemon state, so any coordinator, connection, or server loss is `EXECUTION_STATE_UNKNOWN`. Independent review remains on the direct read-only Codex CLI adapter. The packaged Pi extension invokes the same runtime core inside the owning Pi process and probes an installed `pi-subagents` 0.53.0+ owner before selecting process-local structured delegation. The runtime persists admission intent before the extension emits one request, then binds the exact request, logical node, subject, and extension-instance identity. Only one matching terminal response from that uninterrupted instance may proceed to repository verification. Extension reload, session replacement, stale context, process loss, or a missing exact response becomes `EXECUTION_STATE_UNKNOWN` and cannot launch a replacement. Pi's ordinary foreground subagent observability remains available, while bounded activity is also projected to stderr. If the compatible owner is absent or inactive, Autopilot selects and reports the distinct direct Pi CLI fallback before admission. Direct POSIX execution keeps process supervision; direct Windows execution remains session-scoped. Independent review remains a separate direct read-only Pi execution. diff --git a/skills/autopilot/docs/getting-started.md b/skills/autopilot/docs/getting-started.md index 0dbfa3a..ab58caf 100644 --- a/skills/autopilot/docs/getting-started.md +++ b/skills/autopilot/docs/getting-started.md @@ -38,6 +38,8 @@ Autopilot converts the request into a proposed charter. Review the charter when For the Pi process-local backend, load the packaged runtime extension through Pi's documented package or `--extension` mechanism and start the charter with `/autopilot-start `. The extension checks that the compatible `pi-subagents` owner is active in the same process; otherwise it reports and uses the distinct direct Pi CLI fallback. Autopilot never installs or enables either extension. Leave the owning Pi process running. Extension reload, session replacement, or process loss makes an admitted in-process execution unknown rather than launching a replacement. +For Codex, choose the charter adapter deliberately. `codex-app-server` runs one exact ephemeral thread and turn over a per-attempt stdio app-server connection; losing that coordinator, connection, or server makes the attempt unknown. `codex` keeps the direct CLI boundary, including POSIX process-supervised restart reattachment. The app-server mode never bootstraps a daemon or changes Codex authentication. It starts the server in the worktree and omits explicit thread `cwd` so Codex does not persist project trust. + ## Understand the run A run uses one of three graph modes: @@ -70,7 +72,7 @@ Natural requests work too: `status` rebuilds progress from the sealed charter, hash-linked journal, Git identities, and receipts. It reports the last durable milestone, unmet predicate identities, normalized failure, remaining budgets, repeated no-change attempts, and next legal action. `pause` asks the live coordinator to cancel active implementation work, prove quiescence, retire the exact lease, and enter nonterminal waiting. A cancellation caused solely by pause remains auditable but does not consume an attempt. `resume` continues a paused or interrupted nonterminal run within its original limits. Verified items reconcile their checkpoint and effects without rerunning implementation. It does not restart a run that still has a live coordinator. `stop` asks a live coordinator to cancel active adapter work and record a durable terminal stop; if the coordinator is gone, Autopilot records the stop under the run lock. Branches, worktrees, receipts, and evidence remain intact. -A stopped run cannot be resumed. Changed authority, budgets, or objectives require a sealed successor. After coordinator loss on supported POSIX hosts, Autopilot reattaches built-in supervised implementation executions and waits for terminal process-tree evidence. Autopilot contains no Windows native containment helper, so direct Windows CLI execution is session-scoped and continuity loss becomes `EXECUTION_STATE_UNKNOWN`. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts also refuse a replacement launch until quiescence can be proven. Pi in-process implementations become unknown when their exact owning extension instance is lost. Resume them through `/autopilot-resume [run-id]` in a loaded Autopilot extension; use fenced `/autopilot-recover` or the runtime CLI to abandon, adopt, or stop an unknown attempt. +A stopped run cannot be resumed. Changed authority, budgets, or objectives require a sealed successor. After coordinator loss on supported POSIX hosts, Autopilot reattaches built-in supervised implementation executions and waits for terminal process-tree evidence. Autopilot contains no Windows native containment helper, so direct Windows CLI execution is session-scoped and continuity loss becomes `EXECUTION_STATE_UNKNOWN`. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts also refuse a replacement launch until quiescence can be proven. Pi in-process and Codex app-server implementations become unknown when their exact owning harness instance is lost. Resume them through `/autopilot-resume [run-id]` in a loaded Autopilot extension; use fenced `/autopilot-recover` or the runtime CLI to abandon, adopt, or stop an unknown attempt. If several runs match, Autopilot lists their title, short ID, state, progress, and last update. It changes nothing until you choose one, for example `resume 1` or `status spring-boot-4`. diff --git a/skills/autopilot/docs/implementation-plan.md b/skills/autopilot/docs/implementation-plan.md index e8845cb..b6e274f 100644 --- a/skills/autopilot/docs/implementation-plan.md +++ b/skills/autopilot/docs/implementation-plan.md @@ -1,6 +1,6 @@ # Harness-agnostic Autopilot implementation plan -- **Status:** Developer-preview implementation available; POSIX attempt-scoped reattachment, versioned execution assurance, fenced unknown recovery, and the Pi process-local structured delegation backend are packaged. The Windows native-helper source, runtime path, build scripts, and tests have been removed; Windows restart reattachment remains unsupported. +- **Status:** Developer-preview implementation available; POSIX attempt-scoped reattachment, versioned execution assurance, fenced unknown recovery, Pi process-local structured delegation, and the Codex same-instance app-server backend are packaged. The Windows native-helper source, runtime path, build scripts, and tests have been removed; Windows restart reattachment remains unsupported. - **Date:** 2026-08-22 - **Audience:** Autopilot implementers and reviewers - **Governing design:** [Autopilot architecture](architecture.md) @@ -41,7 +41,7 @@ The completed first release must: ## Developer-preview evidence -The implementation currently has 200 Node test cases and a clean-copy package smoke test. Generated attempt context, predicate-to-evidence reports, exact-tree independent review, intentional pause, and exact-subject provider waiting have controlled coverage. Disposable exact-tree review runs passed with Pi 0.84.4 through pi-subagents 0.60.0, Codex 0.151.0, and OpenCode 1.18.25. Claude Code 2.1.251 reached its adapter but reported no usable noninteractive credential source, so its edit and review flows remain unverified. The same suite passes in Node 24 CI on Ubuntu and Windows; Windows coverage includes locking, atomic writes, Git worktrees and governed hooks, provider fixtures, cancellation, and descendant process-tree termination. An authorized GitHub wrap-up was exercised against merged chat4j PR #69. Authorized private GitHub project `drafael/autopilot-amendment-validation` PR #1 exercised immutable feedback capture, exact-head successor adoption, fast-forward update, exact thread resolution, merge, and amendment-chain wrap-up with `gh` 2.98.0. Authorized private GitLab project `drafael/autopilot-amendment-validation` MR !2 exercised the equivalent complete amendment workflow with `glab` 1.115.0. +The implementation currently has 205 Node test cases (203 passing and 2 platform skips on macOS) and a clean-copy package smoke test. Generated attempt context, predicate-to-evidence reports, exact-tree independent review, intentional pause, exact-subject provider waiting, and Codex app-server admission, cancellation, continuity loss, identity fencing, server-request denial, and protocol bounds have controlled coverage. Disposable exact-tree review runs passed with Pi 0.84.4 through pi-subagents 0.60.0, Codex 0.151.0, and OpenCode 1.18.25. Claude Code 2.1.251 reached its adapter but reported no usable noninteractive credential source, so its edit and review flows remain unverified. The same suite passes in Node 24 CI on Ubuntu and Windows; Windows coverage includes locking, atomic writes, Git worktrees and governed hooks, provider fixtures, cancellation, and descendant process-tree termination. An authorized GitHub wrap-up was exercised against merged chat4j PR #69. Authorized private GitHub project `drafael/autopilot-amendment-validation` PR #1 exercised immutable feedback capture, exact-head successor adoption, fast-forward update, exact thread resolution, merge, and amendment-chain wrap-up with `gh` 2.98.0. Authorized private GitLab project `drafael/autopilot-amendment-validation` MR !2 exercised the equivalent complete amendment workflow with `glab` 1.115.0. ## Planned package boundary @@ -254,6 +254,8 @@ skills/autopilot/runtime/test/adapter-compatibility.test.ts - An adapter that cannot preserve background execution reports that limitation. - Same-model review limitations appear in capability or report metadata rather than being hidden. - Cross-harness integration claims remain limited to versions actually exercised. +- Codex app-server 0.151.0 returns exact ephemeral thread and turn identity on one stdio connection, reports exact completion and interruption, and leaves the tested Codex config digest unchanged because Autopilot omits explicit `thread/start.cwd`. +- Codex app-server connection or process loss remains unknown; its default stdio transport is not restart-reattachable, and direct `codex` remains a separate fallback. ## Phase 6: Add independent queues and ordered stacks diff --git a/skills/autopilot/docs/runtime-cli.md b/skills/autopilot/docs/runtime-cli.md index f731370..a035532 100644 --- a/skills/autopilot/docs/runtime-cli.md +++ b/skills/autopilot/docs/runtime-cli.md @@ -57,7 +57,7 @@ If no coordinator owns the run, `stop` acquires the run lock and records the ter `recover` requires an inactive coordinator and the exact current unknown item, attempt, and lease epoch. Every action records the owning run-lock token hash and explicit operator attestation. `abandon` permanently moves the uncertain worktree aside before a fresh attempt; `adopt` requires the exact freshly observed `--tree` and runs verification without an implementation launch; `stop` preserves the evidence and terminalizes the run. -`stop` is terminal. A stopped or successful run requires a sealed successor for changed work. On supported POSIX hosts, built-in adapters reattach supervised implementation executions after coordinator loss and wait for terminal process-tree evidence before retrying. Autopilot packages no Windows native containment helper: direct Windows CLI executions are session-scoped, and continuity loss records `EXECUTION_STATE_UNKNOWN` without a replacement launch. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts fail closed the same way. +`stop` is terminal. A stopped or successful run requires a sealed successor for changed work. On supported POSIX hosts, built-in direct CLI adapters reattach supervised implementation executions after coordinator loss and wait for terminal process-tree evidence before retrying. The `codex-app-server` mode instead binds one stdio connection, thread, turn, and app-server instance; coordinator, connection, or server loss records `EXECUTION_STATE_UNKNOWN` and cannot reconnect or launch a replacement. Autopilot packages no Windows native containment helper: direct Windows CLI executions are session-scoped and fail closed on continuity loss. Legacy attempts, review executions, and incomplete or mismatched supervisor artifacts fail closed the same way. ## Journal repair diff --git a/skills/autopilot/references/adapters.md b/skills/autopilot/references/adapters.md index a4672e0..222a5ee 100644 --- a/skills/autopilot/references/adapters.md +++ b/skills/autopilot/references/adapters.md @@ -8,7 +8,8 @@ Adapters start fresh noninteractive sessions and normalize observations. They ca |---|---|---|---|---| | Pi | Owning Pi extension plus process-local `pi-subagents` structured delegation when version 0.53.0+ is installed and active; direct Pi CLI fallback otherwise | Cooperative | In-process implementations require the same uninterrupted extension instance; direct fallback retains supervised POSIX execution and session-scoped Windows execution | Local in-process runtime invocation, exact admission, cancellation, terminal response, continuity loss, direct fallback, and exact-tree verification have controlled coverage with Pi 0.84.4 and pi-subagents 0.60.0; whole-process live fault evidence remains bounded to the documented local matrix | | Claude Code | `claude --print --output-format stream-json --safe-mode ...` | Cooperative | Supervised implementation attempts on POSIX; session-scoped on Windows | The current 2.1.251 credential reaches the API but requires `ANTHROPIC_WORKSPACE_ID`; authenticated edit and review flows remain unverified | -| Codex | `codex exec --json --ephemeral --sandbox workspace-write ...` | Cooperative overall; Codex enforces the workspace sandbox, while item-path restrictions are post-checked | Supervised implementation attempts on POSIX; session-scoped on Windows | Disposable local edit, exact-tree review, verification, and commit passed with 0.151.0 | +| Codex app-server | Per-attempt `codex app-server --listen stdio://`, v2 `thread/start`, and `turn/start` | Harness-owned, same-app-server-instance cooperative implementation; exact thread/turn identity and terminal notification required | No reattachment; connection, coordinator, or app-server loss is unknown | Contract, loss, cancellation, identity, server-request denial, and output-bound tests passed; live completion and cancellation passed with 0.151.0 | +| Codex CLI | `codex exec --json --ephemeral --sandbox workspace-write ...` | Cooperative overall; Codex enforces the workspace sandbox, while item-path restrictions are post-checked | Supervised implementation attempts on POSIX; session-scoped on Windows | Distinct fallback; disposable local edit, exact-tree review, verification, and commit passed with 0.151.0 | | OpenCode | `opencode run --format json --pure --auto ...` | Cooperative | Supervised implementation attempts on POSIX; session-scoped on Windows | Disposable local edit, exact-tree review, verification, and commit passed with 1.18.25 | The adapter parser bounds output and rejects malformed JSON-mode output. The runtime ignores model completion claims and inspects the worktree directly. @@ -21,6 +22,10 @@ Before launch, `ATTEMPT_STARTED` persists the selected assurance and exact reque For a `review` gate, the runtime sends a separate role-scoped request with no writable roots or worker write/process grants. Claude Code receives only read/search tools, Codex uses its read-only sandbox, and direct Pi receives only its read tool. OpenCode and any ambient operating-system access remain cooperative. The adapter extracts exactly one structured review marker; missing, contradictory, malformed, truncated, timed-out, or inconclusive output is `UNVERIFIED`. The runtime compares the complete tree, HEAD, refs, and Git configuration before and after review and rejects any mutation. Version-pinned disposable exact-tree runs passed with Pi 0.84.4, Codex 0.151.0, and OpenCode 1.18.25. Claude Code 2.1.251 now reaches its API credential path, but the identity-linked key requires an `ANTHROPIC_WORKSPACE_ID` that is not present in the validation environment, so edit and review remain unverified. +For Codex, `codex-app-server` and `codex` are separate charter values and never silently inherit one another's guarantees. The app-server implementation starts a fresh stdio server in the attempt worktree, initializes one connection, creates one ephemeral thread with `approvalPolicy: never` and `workspace-write`, then starts one turn. It deliberately omits `cwd` from `thread/start`: Codex uses the app-server process working directory, while avoiding the app-server behavior that persists project trust when an explicit writable `cwd` is supplied. Autopilot verifies the returned working directory, thread ID, turn ID, ephemeral flag, approval policy, and sandbox before journaling the subject. It rejects every server-initiated request, binds the subject to a process-instance nonce, and accepts only the matching `turn/completed` notification on that uninterrupted connection. `turn/interrupt` is only accepted as cancellation after the same exact turn reports `interrupted`. + +The default stdio transport cannot be reconnected. Codex's reconnectable WebSocket transport is experimental and unsupported, and Autopilot does not bootstrap a global daemon or modify Codex configuration to create a reconnectable endpoint. Therefore this backend reports `same-harness-instance`, not durable reattachment. Process, connection, coordinator, malformed-event, deadline, or identity loss becomes `EXECUTION_STATE_UNKNOWN`. Logical completion does not prove that background terminals or external effects are quiescent. Independent review stays on the direct read-only Codex CLI path. The `codex` charter value preserves the pre-existing direct CLI adapter, including POSIX process-supervised implementation reattachment. + For Pi, the packaged Autopilot extension probes the process-local `pi-subagents` owner before sealing an execution mode. A compatible installed and active owner receives one public structured delegation request only after `ATTEMPT_STARTED` is durable. Autopilot binds the exact `requestId`, run-scoped `ownerRunId`, attempt-and-context-derived `nodeId`, subject digest, and extension-instance identity in `ATTEMPT_EXECUTION_ADMITTED`. It accepts at most one matching terminal response. Reload, session replacement, stale context, missing admission, or missing terminal response becomes `EXECUTION_STATE_UNKNOWN`; cancellation is only a request until the same tuple returns terminal cancellation. The in-process worker stays in Autopilot's dedicated worktree and appears through Pi's ordinary foreground subagent observability. Bounded activity is also written to stderr without copying child output or tool arguments. Independent review remains a separate direct read-only Pi execution. An absent, inactive, or old `pi-subagents` installation selects the distinct direct CLI fallback before admission; it cannot inherit the in-process backend's harness-owned same-instance assurance. Direct POSIX execution retains its proven process supervisor, while direct Windows execution remains session-scoped. Autopilot never installs, activates, or updates either extension. @@ -44,7 +49,7 @@ Unit tests use controlled fake CLIs for command construction, changed-head denia - Queue execution becomes serial when an adapter reports concurrency one. - Missing required assurance or grants stops before edits. -- Pi in-process implementations accept terminality only through the uninterrupted owning extension instance. Harness loss remains `EXECUTION_STATE_UNKNOWN`; a fresh Pi instance never repeats admission. Direct CLI implementations retain supervised POSIX behavior, while direct Windows, legacy attempts, reviews, and incomplete supervisor bootstraps fail closed at their declared boundaries. +- Pi in-process and Codex app-server implementations accept terminality only through their uninterrupted owning harness instance. Harness loss remains `EXECUTION_STATE_UNKNOWN`; a fresh instance never repeats admission. Direct CLI implementations retain supervised POSIX behavior, while direct Windows, legacy attempts, reviews, and incomplete supervisor bootstraps fail closed at their declared boundaries. - Late results from expired leases are quarantined. - Provider head changes block merge. - Review findings block the current attempt and enter the next deterministic attempt context as untrusted data. diff --git a/skills/autopilot/references/charter.md b/skills/autopilot/references/charter.md index bf5de6e..e8085f1 100644 --- a/skills/autopilot/references/charter.md +++ b/skills/autopilot/references/charter.md @@ -11,7 +11,7 @@ Runtime validation in `runtime/src/charter.ts` is authoritative. `runtime/schema - `sourceText`: original user request - `createdAt`: ISO timestamp - `repository`: canonical real root, immutable base ref/commit, and maximum writable roots -- `harnessAdapter`: `pi`, `claude-code`, `codex`, or `opencode` +- `harnessAdapter`: `pi`, `claude-code`, `codex`, `codex-app-server`, or `opencode`; the two Codex values are distinct direct-CLI and same-app-server-instance execution modes - `mode`: `single`, `independent-queue`, or `ordered-stack` - `work`: resolved items with concise change-request titles, full objectives, dependencies, predicates, writable roots, and branches - `delivery`: `local-commits`, `change-request-ready`, or `merge-verified` diff --git a/skills/autopilot/runtime/adapters/codex/app-server.ts b/skills/autopilot/runtime/adapters/codex/app-server.ts new file mode 100644 index 0000000..66f8dac --- /dev/null +++ b/skills/autopilot/runtime/adapters/codex/app-server.ts @@ -0,0 +1,753 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { realpath } from "node:fs/promises"; +import { + executionAssuranceFor, + type CancelResult, + type CapabilityManifest, + type ExecutionHandle, + type ExecutionObservation, + type ExecutionRequest, + type ExecutionSubject, + type HarnessPort, +} from "../../src/adapter-protocol.js"; +import { + adapterCredentialNames, + adapterEnvironment, + redactSecrets, + redactionValues, +} from "../../src/adapter-process.js"; +import { renderAttemptContext } from "../../src/attempt-context.js"; +import { AutopilotError } from "../../src/errors.js"; +import { canonicalJson, isRecord, sha256 } from "../../src/json.js"; +import { boundUtf8, runProcess, StreamingRedactor } from "../../src/process.js"; + +interface CodexAppServerAdapterOptions { + readonly executable: string; + readonly reviewAdapter: HarnessPort; +} + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (error: Error) => void; +} + +interface RpcPendingRequest { + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; +} + +interface PendingExecution { + readonly request: ExecutionRequest; + readonly connection: AppServerConnection; + readonly harnessInstanceId: string; + readonly threadId: string; + readonly turnId: string; + readonly subject: ExecutionSubject; + readonly terminal: Promise; + readonly isTerminalAccepted: () => boolean; + readonly dispose: () => Promise; +} + +function deferred(): Deferred { + let resolvePromise: (value: T) => void = () => undefined; + let rejectPromise: (error: Error) => void = () => undefined; + const promise = new Promise((resolvePromiseValue, rejectPromiseValue) => { + resolvePromise = resolvePromiseValue; + rejectPromise = rejectPromiseValue; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function requiredRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} is malformed`); + } + return value; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} is malformed`); + } + return value; +} + +async function threadIdentity(value: unknown, worktreePath: string): Promise { + const response = requiredRecord(value, "Codex thread/start response"); + const thread = requiredRecord(response.thread, "Codex thread/start response.thread"); + const threadId = requiredString(thread.id, "Codex thread id"); + if (thread.ephemeral !== true) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server did not admit an ephemeral thread"); + } + const cwd = requiredString(response.cwd, "Codex thread cwd"); + const [admittedCwd, expectedCwd] = await Promise.all([realpath(cwd), realpath(worktreePath)]); + if (admittedCwd !== expectedCwd) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server admitted a different working directory"); + } + if (response.approvalPolicy !== "never") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server changed the unattended approval policy"); + } + const sandbox = requiredRecord(response.sandbox, "Codex thread sandbox"); + if (sandbox.type !== "workspaceWrite") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server changed the implementation sandbox"); + } + return threadId; +} + +function turnIdentity(value: unknown): string { + const response = requiredRecord(value, "Codex turn/start response"); + const turn = requiredRecord(response.turn, "Codex turn/start response.turn"); + return requiredString(turn.id, "Codex turn id"); +} + +function turnFromNotification(value: unknown): { + readonly threadId: string; + readonly turnId: string; + readonly status: string; + readonly error?: string; + readonly agentOutput: readonly string[]; +} { + const params = requiredRecord(value, "Codex turn/completed notification"); + const turn = requiredRecord(params.turn, "Codex turn/completed notification.turn"); + const items = Array.isArray(turn.items) ? turn.items : []; + const agentOutput = items.flatMap((item): readonly string[] => + isRecord(item) && item.type === "agentMessage" && typeof item.text === "string" ? [item.text] : [] + ); + const turnError = isRecord(turn.error) && typeof turn.error.message === "string" ? turn.error.message : undefined; + return { + threadId: requiredString(params.threadId, "Codex terminal thread id"), + turnId: requiredString(turn.id, "Codex terminal turn id"), + status: requiredString(turn.status, "Codex terminal turn status"), + ...(turnError === undefined ? {} : { error: turnError }), + agentOutput, + }; +} + +async function waitForChildClose(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return true; + } + return await new Promise((resolvePromise) => { + const timer = setTimeout(() => { + child.off("close", onClose); + resolvePromise(false); + }, timeoutMs); + timer.unref(); + const onClose = (): void => { + clearTimeout(timer); + resolvePromise(true); + }; + child.once("close", onClose); + }); +} + +async function terminateAppServer(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill(); + if (await waitForChildClose(child, 5_000)) { + return; + } + child.kill("SIGKILL"); + if (!await waitForChildClose(child, 5_000)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server process did not terminate after forced cleanup"); + } +} + +class AppServerConnection { + readonly #child: ChildProcessWithoutNullStreams; + readonly #maximumLineBytes: number; + readonly #pendingRequests = new Map(); + readonly #onNotification: (method: string, params: unknown) => void; + readonly #onFailure: (error: Error) => void; + #buffer = Buffer.alloc(0); + #nextRequestId = 1; + #closed = false; + #termination: Promise | undefined; + + constructor(options: { + readonly child: ChildProcessWithoutNullStreams; + readonly maximumLineBytes: number; + readonly onNotification: (method: string, params: unknown) => void; + readonly onFailure: (error: Error) => void; + }) { + this.#child = options.child; + this.#maximumLineBytes = options.maximumLineBytes; + this.#onNotification = options.onNotification; + this.#onFailure = options.onFailure; + this.#child.stdin.on("error", this.#onStdinError); + this.#child.stdout.on("data", this.#onData); + this.#child.stdout.on("end", this.#onEnd); + this.#child.stdout.on("error", this.#onStdoutError); + this.#child.stderr.on("error", this.#onStderrError); + this.#child.on("error", this.#onProcessError); + this.#child.on("close", this.#onProcessClose); + } + + async request(method: string, params: unknown): Promise { + if (this.#closed) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server connection is no longer authoritative"); + } + const id = this.#nextRequestId; + this.#nextRequestId += 1; + const pending = deferred(); + this.#pendingRequests.set(id, { resolve: pending.resolve, reject: pending.reject }); + try { + this.#write({ method, id, params }); + } catch (error) { + this.#pendingRequests.delete(id); + throw error; + } + return await pending.promise; + } + + notify(method: string): void { + this.#write({ method }); + } + + async close(): Promise { + if (!this.#closed) { + this.#closed = true; + this.#rejectPending(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server connection closed")); + } + await this.#cleanup(); + } + + #write(message: Readonly>): void { + if (this.#closed || !this.#child.stdin.writable) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server request channel is unavailable"); + } + this.#child.stdin.write(`${JSON.stringify(message)}\n`); + } + + readonly #onData = (chunk: Buffer): void => { + if (this.#closed) { + return; + } + this.#buffer = Buffer.concat([this.#buffer, chunk]); + let newline = this.#buffer.indexOf(0x0a); + while (newline >= 0) { + const line = this.#buffer.subarray(0, newline); + this.#buffer = this.#buffer.subarray(newline + 1); + if (line.byteLength > this.#maximumLineBytes) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server event exceeded the configured line bound")); + return; + } + if (line.byteLength > 0) { + this.#acceptLine(line); + if (this.#closed) { + return; + } + } + newline = this.#buffer.indexOf(0x0a); + } + if (this.#buffer.byteLength > this.#maximumLineBytes) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server event exceeded the configured line bound")); + } + }; + + readonly #onEnd = (): void => { + if (!this.#closed && this.#buffer.byteLength > 0) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server ended with an incomplete event")); + } + }; + + readonly #onStdinError = (error: Error): void => { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server request channel failed", { + cause: error.message, + })); + }; + + readonly #onStdoutError = (error: Error): void => { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server event channel failed", { + cause: error.message, + })); + }; + + readonly #onStderrError = (error: Error): void => { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server diagnostic channel failed", { + cause: error.message, + })); + }; + + readonly #onProcessError = (error: Error): void => { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server process failed", { cause: error.message })); + }; + + readonly #onProcessClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if (!this.#closed) { + this.#fail(new AutopilotError( + "EXECUTION_STATE_UNKNOWN", + "Codex app-server exited before the exact turn reached terminal state", + { code, signal }, + )); + } + }; + + #acceptLine(line: Buffer): void { + let value: unknown; + try { + value = JSON.parse(line.toString("utf8")) as unknown; + } catch (error) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server emitted malformed JSON", { + cause: errorMessage(error), + })); + return; + } + if (!isRecord(value)) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server emitted a malformed message")); + return; + } + if (typeof value.method === "string" && value.id !== undefined) { + try { + this.#write({ + id: value.id, + error: { code: -32601, message: "Autopilot unattended execution rejects server requests" }, + }); + } catch (error) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex server-request rejection failed", { + cause: errorMessage(error), + })); + } + return; + } + if (typeof value.method === "string") { + try { + this.#onNotification(value.method, value.params); + } catch (error) { + this.#fail(error instanceof Error ? error : new Error(String(error))); + } + return; + } + if (typeof value.id !== "number" || !Number.isSafeInteger(value.id)) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server response identity is malformed")); + return; + } + const pending = this.#pendingRequests.get(value.id); + if (pending === undefined) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server returned an unknown response identity")); + return; + } + this.#pendingRequests.delete(value.id); + if (value.error !== undefined) { + const rpcError = isRecord(value.error) && typeof value.error.message === "string" + ? value.error.message : "Codex app-server request failed"; + pending.reject(new AutopilotError("EXECUTION_STATE_UNKNOWN", rpcError)); + return; + } + if (!("result" in value)) { + pending.reject(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server response has no result")); + return; + } + pending.resolve(value.result); + } + + #fail(error: Error): void { + if (this.#closed) { + return; + } + this.#closed = true; + this.#rejectPending(error); + this.#onFailure(error); + void this.#cleanup().catch(() => undefined); + } + + async #cleanup(): Promise { + this.#termination ??= (async () => { + this.#removeListeners(); + this.#child.stdin.destroy(); + this.#child.stdout.destroy(); + this.#child.stderr.destroy(); + try { + await terminateAppServer(this.#child); + } finally { + this.#child.stdin.off("error", this.#onStdinError); + this.#child.stdout.off("error", this.#onStdoutError); + this.#child.stderr.off("error", this.#onStderrError); + this.#child.unref(); + } + })(); + await this.#termination; + } + + #rejectPending(error: Error): void { + this.#pendingRequests.forEach(({ reject }) => reject(error)); + this.#pendingRequests.clear(); + } + + #removeListeners(): void { + this.#child.stdout.off("data", this.#onData); + this.#child.stdout.off("end", this.#onEnd); + this.#child.off("error", this.#onProcessError); + this.#child.off("close", this.#onProcessClose); + } +} + +export class CodexAppServerAdapter implements HarnessPort { + readonly #options: CodexAppServerAdapterOptions; + readonly #pending = new Map(); + readonly #reviewHandles = new Set(); + #harnessVersion: string | undefined; + + constructor(options: CodexAppServerAdapterOptions) { + this.#options = options; + } + + async describe(): Promise { + const directManifest = await this.#options.reviewAdapter.describe(); + const appServer = await runProcess({ + executable: this.#options.executable, + arguments: ["app-server", "--help"], + cwd: process.cwd(), + timeoutMs: 10_000, + maxOutputBytes: 65_536, + }); + if (appServer.exitCode !== 0) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "Codex app-server is unavailable"); + } + this.#harnessVersion = directManifest.harnessVersion; + return { + protocolVersion: 1, + adapterName: "codex-app-server", + adapterVersion: "1", + harnessVersion: directManifest.harnessVersion, + families: directManifest.families, + assurance: "cooperative", + unattended: true, + maxConcurrency: 1, + eventStreaming: true, + cancellation: true, + restartReattachment: false, + executionAssurance: { + schemaVersion: 1, + implementation: { + schemaVersion: 1, + owner: "harness", + continuity: "same-harness-instance", + terminality: "cooperative", + admission: "single-shot", + }, + review: executionAssuranceFor(directManifest, "review"), + }, + restrictions: "cooperative", + limitations: [ + "Codex app-server implementation completion requires an exact thread and turn terminal notification on the uninterrupted stdio connection.", + "Coordinator, connection, or app-server loss is execution-state-unknown and cannot launch a replacement turn.", + "Logical turn completion does not prove operating-system process-tree quiescence or rollback external effects.", + "The app-server process starts in the attempt worktree and thread/start omits cwd to avoid Codex persisting project trust.", + "Independent review uses the direct Codex CLI adapter and remains session-scoped.", + ], + }; + } + + async launch(request: ExecutionRequest): Promise { + if (request.role === "review") { + const handle = await this.#options.reviewAdapter.launch(request); + this.#reviewHandles.add(handle.adapterExecutionId); + return handle; + } + if (request.protocolVersion !== 1) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "execution request protocol version is not supported"); + } + if (this.#harnessVersion === undefined) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "Codex app-server capabilities must be loaded before launch"); + } + const harnessVersion = this.#harnessVersion; + const adapterExecutionId = randomUUID(); + const harnessInstanceId = randomUUID(); + const startedAt = new Date().toISOString(); + const credentials = adapterCredentialNames(request); + const stderrRedactor = new StreamingRedactor(redactionValues(credentials)); + const stderr = { value: "", truncated: false, finished: false }; + let threadId: string | undefined; + let turnId: string | undefined; + let provisionalTurnId: string | undefined; + let earlyTerminal: unknown; + let output = ""; + let outputTruncated = false; + let terminalAccepted = false; + let failure: AutopilotError | undefined; + let disposal: Promise | undefined; + const terminal = deferred(); + void terminal.promise.catch(() => undefined); + const timeoutMs = Math.max(1, Date.parse(request.deadline) - Date.now()); + let idleTimer: NodeJS.Timeout | undefined; + let deadlineTimer: NodeJS.Timeout | undefined; + let connection: AppServerConnection | undefined; + + const appendOutput = (text: string): void => { + const combined = output.length === 0 ? text : `${output}\n${text}`; + const bounded = boundUtf8(redactSecrets(combined, credentials), request.maximumOutputBytes); + output = bounded.value; + outputTruncated ||= bounded.truncated; + }; + const appendStderr = (text: string): void => { + const bounded = boundUtf8(`${stderr.value}${text}`, request.maximumOutputBytes); + stderr.value = bounded.value; + stderr.truncated ||= bounded.truncated; + }; + const finishStderr = (): void => { + if (!stderr.finished) { + stderr.finished = true; + appendStderr(stderrRedactor.end()); + } + }; + const resetIdleTimer = (): void => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + failUnknown("Codex app-server exceeded the harness idle timeout without an exact terminal response"); + }, request.idleTimeoutMs); + idleTimer.unref(); + }; + const dispose = (): Promise => { + disposal ??= (async () => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + await connection?.close(); + })(); + return disposal; + }; + const failUnknown = (message: string, cause?: unknown): void => { + if (terminalAccepted) { + return; + } + terminalAccepted = true; + const error = new AutopilotError("EXECUTION_STATE_UNKNOWN", message, { + ...(cause === undefined ? {} : { cause: errorMessage(cause) }), + }); + failure = error; + terminal.reject(error); + void dispose().catch(() => undefined); + }; + const observeTurnId = (value: unknown): string | undefined => { + if (!isRecord(value) || typeof value.threadId !== "string" || value.threadId !== threadId) { + return undefined; + } + const notificationTurn = typeof value.turnId === "string" + ? value.turnId + : isRecord(value.turn) && typeof value.turn.id === "string" ? value.turn.id : undefined; + if (notificationTurn === undefined) { + return undefined; + } + if (turnId !== undefined) { + return notificationTurn === turnId ? notificationTurn : undefined; + } + if (provisionalTurnId !== undefined && provisionalTurnId !== notificationTurn) { + failUnknown("Codex app-server emitted conflicting pre-admission turn identities"); + return undefined; + } + provisionalTurnId = notificationTurn; + return notificationTurn; + }; + const acceptTerminal = (params: unknown): void => { + if (terminalAccepted) { + return; + } + const completed = turnFromNotification(params); + if (completed.threadId !== threadId || completed.turnId !== turnId) { + return; + } + if (!(["completed", "interrupted", "failed"] as const).includes( + completed.status as "completed" | "interrupted" | "failed", + )) { + failUnknown("Codex app-server emitted a nonterminal turn/completed status"); + return; + } + if (completed.agentOutput.length > 0) { + output = ""; + completed.agentOutput.forEach(appendOutput); + } + finishStderr(); + terminalAccepted = true; + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + const status: ExecutionObservation["status"] = completed.status === "completed" + ? "completed" : completed.status === "interrupted" ? "cancelled" : "failed"; + const stderrOutput = boundUtf8( + completed.error === undefined + ? stderr.value + : `${stderr.value}${redactSecrets(completed.error, credentials)}`, + request.maximumOutputBytes, + ); + terminal.resolve({ + protocolVersion: 1, + adapterExecutionId, + status, + exitCode: status === "completed" ? 0 : status === "cancelled" ? 130 : 1, + completedAt: new Date().toISOString(), + stdout: output, + stderr: stderrOutput.value, + truncated: outputTruncated || stderr.truncated || stderrOutput.truncated, + }); + }; + const onNotification = (method: string, params: unknown): void => { + const exactTurn = observeTurnId(params); + if (exactTurn === undefined) { + return; + } + resetIdleTimer(); + if (method === "item/completed" && isRecord(params)) { + const item = params.item; + if (isRecord(item) && item.type === "agentMessage" && typeof item.text === "string") { + appendOutput(item.text); + } + } + if (method === "error" && isRecord(params) && isRecord(params.error) + && typeof params.error.message === "string") { + appendStderr(redactSecrets(params.error.message, credentials)); + } + if (method === "turn/completed") { + if (turnId === undefined) { + if (earlyTerminal !== undefined) { + failUnknown("Codex app-server emitted duplicate terminal state before exact admission"); + return; + } + earlyTerminal = params; + } else { + acceptTerminal(params); + } + } + }; + + try { + const child = spawn(this.#options.executable, ["app-server", "--listen", "stdio://"], { + cwd: request.worktreePath, + env: { ...adapterEnvironment(request) }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + child.stderr.on("data", (chunk: Buffer) => appendStderr(stderrRedactor.write(chunk))); + connection = new AppServerConnection({ + child, + maximumLineBytes: request.maximumLineBytes, + onNotification, + onFailure: (error) => failUnknown("Codex app-server continuity was lost", error), + }); + resetIdleTimer(); + deadlineTimer = setTimeout(() => { + if (threadId !== undefined && turnId !== undefined) { + void connection?.request("turn/interrupt", { threadId, turnId }).catch(() => undefined); + } + failUnknown("Codex app-server did not return an exact terminal response before the attempt deadline"); + }, timeoutMs); + deadlineTimer.unref(); + + await connection.request("initialize", { + clientInfo: { name: "coding_harness_autopilot", title: "Coding Harness Autopilot", version: "0.1.0" }, + capabilities: null, + }); + connection.notify("initialized"); + resetIdleTimer(); + // An explicit writable cwd makes Codex persist project trust. The child already runs in the exact worktree. + const thread = await connection.request("thread/start", { + approvalPolicy: "never", + sandbox: "workspace-write", + ephemeral: true, + serviceName: "coding-harness-autopilot", + }); + threadId = await threadIdentity(thread, request.worktreePath); + resetIdleTimer(); + const turn = await connection.request("turn/start", { + threadId, + input: [{ type: "text", text: renderAttemptContext(request.context), textElements: [] }], + }); + if (failure !== undefined) { + throw failure; + } + turnId = turnIdentity(turn); + if (provisionalTurnId !== undefined && provisionalTurnId !== turnId) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex turn/start response changed the observed turn identity"); + } + resetIdleTimer(); + if (earlyTerminal !== undefined) { + acceptTerminal(earlyTerminal); + } + const subject: ExecutionSubject = { + schemaVersion: 1, + backendId: `codex-app-server-v2@${harnessVersion}`, + subjectId: sha256(canonicalJson({ harnessInstanceId, threadId, turnId })), + harnessInstanceId, + }; + const pending: PendingExecution = { + request, + connection, + harnessInstanceId, + threadId, + turnId, + subject, + terminal: terminal.promise, + isTerminalAccepted: () => terminalAccepted, + dispose, + }; + this.#pending.set(adapterExecutionId, pending); + return { protocolVersion: 1, adapterExecutionId, startedAt, subject }; + } catch (error) { + failUnknown("Codex app-server admission did not return an exact thread and turn identity", error); + await dispose().catch(() => undefined); + throw new AutopilotError( + "EXECUTION_STATE_UNKNOWN", + "Codex app-server admission did not return an exact thread and turn identity", + { cause: errorMessage(error) }, + ); + } + } + + async observe(handle: ExecutionHandle): Promise { + if (this.#reviewHandles.delete(handle.adapterExecutionId)) { + return await this.#options.reviewAdapter.observe(handle); + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || handle.subject?.backendId !== pending.subject.backendId + || handle.subject.subjectId !== pending.subject.subjectId + || handle.subject.harnessInstanceId !== pending.harnessInstanceId) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex turn is not attached to the exact app-server instance"); + } + try { + return await pending.terminal; + } finally { + try { + await pending.dispose(); + } finally { + this.#pending.delete(handle.adapterExecutionId); + } + } + } + + async cancel(handle: ExecutionHandle): Promise { + if (this.#reviewHandles.has(handle.adapterExecutionId)) { + return await this.#options.reviewAdapter.cancel(handle); + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || pending.isTerminalAccepted() + || handle.subject?.backendId !== pending.subject.backendId + || handle.subject.subjectId !== pending.subject.subjectId + || handle.subject.harnessInstanceId !== pending.harnessInstanceId) { + return { protocolVersion: 1, accepted: false }; + } + const interrupt = pending.connection.request("turn/interrupt", { + threadId: pending.threadId, + turnId: pending.turnId, + }).then(() => ({ kind: "response" as const })); + const terminal = pending.terminal.then((observation) => ({ kind: "terminal" as const, observation })); + const first = await Promise.race([interrupt, terminal]); + const observation = first.kind === "terminal" ? first.observation : await pending.terminal; + return { protocolVersion: 1, accepted: observation.status === "cancelled" }; + } +} diff --git a/skills/autopilot/runtime/adapters/codex/index.ts b/skills/autopilot/runtime/adapters/codex/index.ts index 3fdb533..79c25a8 100644 --- a/skills/autopilot/runtime/adapters/codex/index.ts +++ b/skills/autopilot/runtime/adapters/codex/index.ts @@ -1,4 +1,5 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; +import { CodexAppServerAdapter } from "./app-server.js"; export function createCodexAdapter(): CliHarnessAdapter { return new CliHarnessAdapter({ @@ -20,8 +21,15 @@ export function createCodexAdapter(): CliHarnessAdapter { limitations: [ "Codex enforces a workspace sandbox, but Autopilot item-path and effect restrictions are checked after execution.", "Implementation executions use the attempt-scoped supervisor for restart reattachment; review executions remain session-scoped.", - "The exact-tree review role is implemented but has no version-pinned live verification.", + "The exact-tree review role passed disposable validation with Codex 0.151.0; other versions remain environment-specific.", ], expectsJsonLines: true, }); } + +export function createCodexAppServerAdapter(): CodexAppServerAdapter { + return new CodexAppServerAdapter({ + executable: "codex", + reviewAdapter: createCodexAdapter(), + }); +} diff --git a/skills/autopilot/runtime/dist/adapters/codex/app-server.d.ts b/skills/autopilot/runtime/dist/adapters/codex/app-server.d.ts new file mode 100644 index 0000000..c8c9370 --- /dev/null +++ b/skills/autopilot/runtime/dist/adapters/codex/app-server.d.ts @@ -0,0 +1,14 @@ +import { type CancelResult, type CapabilityManifest, type ExecutionHandle, type ExecutionObservation, type ExecutionRequest, type HarnessPort } from "../../src/adapter-protocol.js"; +interface CodexAppServerAdapterOptions { + readonly executable: string; + readonly reviewAdapter: HarnessPort; +} +export declare class CodexAppServerAdapter implements HarnessPort { + #private; + constructor(options: CodexAppServerAdapterOptions); + describe(): Promise; + launch(request: ExecutionRequest): Promise; + observe(handle: ExecutionHandle): Promise; + cancel(handle: ExecutionHandle): Promise; +} +export {}; diff --git a/skills/autopilot/runtime/dist/adapters/codex/app-server.js b/skills/autopilot/runtime/dist/adapters/codex/app-server.js new file mode 100644 index 0000000..982e9a0 --- /dev/null +++ b/skills/autopilot/runtime/dist/adapters/codex/app-server.js @@ -0,0 +1,658 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { realpath } from "node:fs/promises"; +import { executionAssuranceFor, } from "../../src/adapter-protocol.js"; +import { adapterCredentialNames, adapterEnvironment, redactSecrets, redactionValues, } from "../../src/adapter-process.js"; +import { renderAttemptContext } from "../../src/attempt-context.js"; +import { AutopilotError } from "../../src/errors.js"; +import { canonicalJson, isRecord, sha256 } from "../../src/json.js"; +import { boundUtf8, runProcess, StreamingRedactor } from "../../src/process.js"; +function deferred() { + let resolvePromise = () => undefined; + let rejectPromise = () => undefined; + const promise = new Promise((resolvePromiseValue, rejectPromiseValue) => { + resolvePromise = resolvePromiseValue; + rejectPromise = rejectPromiseValue; + }); + return { promise, resolve: resolvePromise, reject: rejectPromise }; +} +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} +function requiredRecord(value, label) { + if (!isRecord(value)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} is malformed`); + } + return value; +} +function requiredString(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `${label} is malformed`); + } + return value; +} +async function threadIdentity(value, worktreePath) { + const response = requiredRecord(value, "Codex thread/start response"); + const thread = requiredRecord(response.thread, "Codex thread/start response.thread"); + const threadId = requiredString(thread.id, "Codex thread id"); + if (thread.ephemeral !== true) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server did not admit an ephemeral thread"); + } + const cwd = requiredString(response.cwd, "Codex thread cwd"); + const [admittedCwd, expectedCwd] = await Promise.all([realpath(cwd), realpath(worktreePath)]); + if (admittedCwd !== expectedCwd) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server admitted a different working directory"); + } + if (response.approvalPolicy !== "never") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server changed the unattended approval policy"); + } + const sandbox = requiredRecord(response.sandbox, "Codex thread sandbox"); + if (sandbox.type !== "workspaceWrite") { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server changed the implementation sandbox"); + } + return threadId; +} +function turnIdentity(value) { + const response = requiredRecord(value, "Codex turn/start response"); + const turn = requiredRecord(response.turn, "Codex turn/start response.turn"); + return requiredString(turn.id, "Codex turn id"); +} +function turnFromNotification(value) { + const params = requiredRecord(value, "Codex turn/completed notification"); + const turn = requiredRecord(params.turn, "Codex turn/completed notification.turn"); + const items = Array.isArray(turn.items) ? turn.items : []; + const agentOutput = items.flatMap((item) => isRecord(item) && item.type === "agentMessage" && typeof item.text === "string" ? [item.text] : []); + const turnError = isRecord(turn.error) && typeof turn.error.message === "string" ? turn.error.message : undefined; + return { + threadId: requiredString(params.threadId, "Codex terminal thread id"), + turnId: requiredString(turn.id, "Codex terminal turn id"), + status: requiredString(turn.status, "Codex terminal turn status"), + ...(turnError === undefined ? {} : { error: turnError }), + agentOutput, + }; +} +async function waitForChildClose(child, timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) { + return true; + } + return await new Promise((resolvePromise) => { + const timer = setTimeout(() => { + child.off("close", onClose); + resolvePromise(false); + }, timeoutMs); + timer.unref(); + const onClose = () => { + clearTimeout(timer); + resolvePromise(true); + }; + child.once("close", onClose); + }); +} +async function terminateAppServer(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill(); + if (await waitForChildClose(child, 5_000)) { + return; + } + child.kill("SIGKILL"); + if (!await waitForChildClose(child, 5_000)) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server process did not terminate after forced cleanup"); + } +} +class AppServerConnection { + #child; + #maximumLineBytes; + #pendingRequests = new Map(); + #onNotification; + #onFailure; + #buffer = Buffer.alloc(0); + #nextRequestId = 1; + #closed = false; + #termination; + constructor(options) { + this.#child = options.child; + this.#maximumLineBytes = options.maximumLineBytes; + this.#onNotification = options.onNotification; + this.#onFailure = options.onFailure; + this.#child.stdin.on("error", this.#onStdinError); + this.#child.stdout.on("data", this.#onData); + this.#child.stdout.on("end", this.#onEnd); + this.#child.stdout.on("error", this.#onStdoutError); + this.#child.stderr.on("error", this.#onStderrError); + this.#child.on("error", this.#onProcessError); + this.#child.on("close", this.#onProcessClose); + } + async request(method, params) { + if (this.#closed) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server connection is no longer authoritative"); + } + const id = this.#nextRequestId; + this.#nextRequestId += 1; + const pending = deferred(); + this.#pendingRequests.set(id, { resolve: pending.resolve, reject: pending.reject }); + try { + this.#write({ method, id, params }); + } + catch (error) { + this.#pendingRequests.delete(id); + throw error; + } + return await pending.promise; + } + notify(method) { + this.#write({ method }); + } + async close() { + if (!this.#closed) { + this.#closed = true; + this.#rejectPending(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server connection closed")); + } + await this.#cleanup(); + } + #write(message) { + if (this.#closed || !this.#child.stdin.writable) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server request channel is unavailable"); + } + this.#child.stdin.write(`${JSON.stringify(message)}\n`); + } + #onData = (chunk) => { + if (this.#closed) { + return; + } + this.#buffer = Buffer.concat([this.#buffer, chunk]); + let newline = this.#buffer.indexOf(0x0a); + while (newline >= 0) { + const line = this.#buffer.subarray(0, newline); + this.#buffer = this.#buffer.subarray(newline + 1); + if (line.byteLength > this.#maximumLineBytes) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server event exceeded the configured line bound")); + return; + } + if (line.byteLength > 0) { + this.#acceptLine(line); + if (this.#closed) { + return; + } + } + newline = this.#buffer.indexOf(0x0a); + } + if (this.#buffer.byteLength > this.#maximumLineBytes) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server event exceeded the configured line bound")); + } + }; + #onEnd = () => { + if (!this.#closed && this.#buffer.byteLength > 0) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server ended with an incomplete event")); + } + }; + #onStdinError = (error) => { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server request channel failed", { + cause: error.message, + })); + }; + #onStdoutError = (error) => { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server event channel failed", { + cause: error.message, + })); + }; + #onStderrError = (error) => { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server diagnostic channel failed", { + cause: error.message, + })); + }; + #onProcessError = (error) => { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server process failed", { cause: error.message })); + }; + #onProcessClose = (code, signal) => { + if (!this.#closed) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server exited before the exact turn reached terminal state", { code, signal })); + } + }; + #acceptLine(line) { + let value; + try { + value = JSON.parse(line.toString("utf8")); + } + catch (error) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server emitted malformed JSON", { + cause: errorMessage(error), + })); + return; + } + if (!isRecord(value)) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server emitted a malformed message")); + return; + } + if (typeof value.method === "string" && value.id !== undefined) { + try { + this.#write({ + id: value.id, + error: { code: -32601, message: "Autopilot unattended execution rejects server requests" }, + }); + } + catch (error) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex server-request rejection failed", { + cause: errorMessage(error), + })); + } + return; + } + if (typeof value.method === "string") { + try { + this.#onNotification(value.method, value.params); + } + catch (error) { + this.#fail(error instanceof Error ? error : new Error(String(error))); + } + return; + } + if (typeof value.id !== "number" || !Number.isSafeInteger(value.id)) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server response identity is malformed")); + return; + } + const pending = this.#pendingRequests.get(value.id); + if (pending === undefined) { + this.#fail(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server returned an unknown response identity")); + return; + } + this.#pendingRequests.delete(value.id); + if (value.error !== undefined) { + const rpcError = isRecord(value.error) && typeof value.error.message === "string" + ? value.error.message : "Codex app-server request failed"; + pending.reject(new AutopilotError("EXECUTION_STATE_UNKNOWN", rpcError)); + return; + } + if (!("result" in value)) { + pending.reject(new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server response has no result")); + return; + } + pending.resolve(value.result); + } + #fail(error) { + if (this.#closed) { + return; + } + this.#closed = true; + this.#rejectPending(error); + this.#onFailure(error); + void this.#cleanup().catch(() => undefined); + } + async #cleanup() { + this.#termination ??= (async () => { + this.#removeListeners(); + this.#child.stdin.destroy(); + this.#child.stdout.destroy(); + this.#child.stderr.destroy(); + try { + await terminateAppServer(this.#child); + } + finally { + this.#child.stdin.off("error", this.#onStdinError); + this.#child.stdout.off("error", this.#onStdoutError); + this.#child.stderr.off("error", this.#onStderrError); + this.#child.unref(); + } + })(); + await this.#termination; + } + #rejectPending(error) { + this.#pendingRequests.forEach(({ reject }) => reject(error)); + this.#pendingRequests.clear(); + } + #removeListeners() { + this.#child.stdout.off("data", this.#onData); + this.#child.stdout.off("end", this.#onEnd); + this.#child.off("error", this.#onProcessError); + this.#child.off("close", this.#onProcessClose); + } +} +export class CodexAppServerAdapter { + #options; + #pending = new Map(); + #reviewHandles = new Set(); + #harnessVersion; + constructor(options) { + this.#options = options; + } + async describe() { + const directManifest = await this.#options.reviewAdapter.describe(); + const appServer = await runProcess({ + executable: this.#options.executable, + arguments: ["app-server", "--help"], + cwd: process.cwd(), + timeoutMs: 10_000, + maxOutputBytes: 65_536, + }); + if (appServer.exitCode !== 0) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "Codex app-server is unavailable"); + } + this.#harnessVersion = directManifest.harnessVersion; + return { + protocolVersion: 1, + adapterName: "codex-app-server", + adapterVersion: "1", + harnessVersion: directManifest.harnessVersion, + families: directManifest.families, + assurance: "cooperative", + unattended: true, + maxConcurrency: 1, + eventStreaming: true, + cancellation: true, + restartReattachment: false, + executionAssurance: { + schemaVersion: 1, + implementation: { + schemaVersion: 1, + owner: "harness", + continuity: "same-harness-instance", + terminality: "cooperative", + admission: "single-shot", + }, + review: executionAssuranceFor(directManifest, "review"), + }, + restrictions: "cooperative", + limitations: [ + "Codex app-server implementation completion requires an exact thread and turn terminal notification on the uninterrupted stdio connection.", + "Coordinator, connection, or app-server loss is execution-state-unknown and cannot launch a replacement turn.", + "Logical turn completion does not prove operating-system process-tree quiescence or rollback external effects.", + "The app-server process starts in the attempt worktree and thread/start omits cwd to avoid Codex persisting project trust.", + "Independent review uses the direct Codex CLI adapter and remains session-scoped.", + ], + }; + } + async launch(request) { + if (request.role === "review") { + const handle = await this.#options.reviewAdapter.launch(request); + this.#reviewHandles.add(handle.adapterExecutionId); + return handle; + } + if (request.protocolVersion !== 1) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "execution request protocol version is not supported"); + } + if (this.#harnessVersion === undefined) { + throw new AutopilotError("ADAPTER_UNSUPPORTED", "Codex app-server capabilities must be loaded before launch"); + } + const harnessVersion = this.#harnessVersion; + const adapterExecutionId = randomUUID(); + const harnessInstanceId = randomUUID(); + const startedAt = new Date().toISOString(); + const credentials = adapterCredentialNames(request); + const stderrRedactor = new StreamingRedactor(redactionValues(credentials)); + const stderr = { value: "", truncated: false, finished: false }; + let threadId; + let turnId; + let provisionalTurnId; + let earlyTerminal; + let output = ""; + let outputTruncated = false; + let terminalAccepted = false; + let failure; + let disposal; + const terminal = deferred(); + void terminal.promise.catch(() => undefined); + const timeoutMs = Math.max(1, Date.parse(request.deadline) - Date.now()); + let idleTimer; + let deadlineTimer; + let connection; + const appendOutput = (text) => { + const combined = output.length === 0 ? text : `${output}\n${text}`; + const bounded = boundUtf8(redactSecrets(combined, credentials), request.maximumOutputBytes); + output = bounded.value; + outputTruncated ||= bounded.truncated; + }; + const appendStderr = (text) => { + const bounded = boundUtf8(`${stderr.value}${text}`, request.maximumOutputBytes); + stderr.value = bounded.value; + stderr.truncated ||= bounded.truncated; + }; + const finishStderr = () => { + if (!stderr.finished) { + stderr.finished = true; + appendStderr(stderrRedactor.end()); + } + }; + const resetIdleTimer = () => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + failUnknown("Codex app-server exceeded the harness idle timeout without an exact terminal response"); + }, request.idleTimeoutMs); + idleTimer.unref(); + }; + const dispose = () => { + disposal ??= (async () => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + await connection?.close(); + })(); + return disposal; + }; + const failUnknown = (message, cause) => { + if (terminalAccepted) { + return; + } + terminalAccepted = true; + const error = new AutopilotError("EXECUTION_STATE_UNKNOWN", message, { + ...(cause === undefined ? {} : { cause: errorMessage(cause) }), + }); + failure = error; + terminal.reject(error); + void dispose().catch(() => undefined); + }; + const observeTurnId = (value) => { + if (!isRecord(value) || typeof value.threadId !== "string" || value.threadId !== threadId) { + return undefined; + } + const notificationTurn = typeof value.turnId === "string" + ? value.turnId + : isRecord(value.turn) && typeof value.turn.id === "string" ? value.turn.id : undefined; + if (notificationTurn === undefined) { + return undefined; + } + if (turnId !== undefined) { + return notificationTurn === turnId ? notificationTurn : undefined; + } + if (provisionalTurnId !== undefined && provisionalTurnId !== notificationTurn) { + failUnknown("Codex app-server emitted conflicting pre-admission turn identities"); + return undefined; + } + provisionalTurnId = notificationTurn; + return notificationTurn; + }; + const acceptTerminal = (params) => { + if (terminalAccepted) { + return; + } + const completed = turnFromNotification(params); + if (completed.threadId !== threadId || completed.turnId !== turnId) { + return; + } + if (!["completed", "interrupted", "failed"].includes(completed.status)) { + failUnknown("Codex app-server emitted a nonterminal turn/completed status"); + return; + } + if (completed.agentOutput.length > 0) { + output = ""; + completed.agentOutput.forEach(appendOutput); + } + finishStderr(); + terminalAccepted = true; + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + if (deadlineTimer !== undefined) { + clearTimeout(deadlineTimer); + } + const status = completed.status === "completed" + ? "completed" : completed.status === "interrupted" ? "cancelled" : "failed"; + const stderrOutput = boundUtf8(completed.error === undefined + ? stderr.value + : `${stderr.value}${redactSecrets(completed.error, credentials)}`, request.maximumOutputBytes); + terminal.resolve({ + protocolVersion: 1, + adapterExecutionId, + status, + exitCode: status === "completed" ? 0 : status === "cancelled" ? 130 : 1, + completedAt: new Date().toISOString(), + stdout: output, + stderr: stderrOutput.value, + truncated: outputTruncated || stderr.truncated || stderrOutput.truncated, + }); + }; + const onNotification = (method, params) => { + const exactTurn = observeTurnId(params); + if (exactTurn === undefined) { + return; + } + resetIdleTimer(); + if (method === "item/completed" && isRecord(params)) { + const item = params.item; + if (isRecord(item) && item.type === "agentMessage" && typeof item.text === "string") { + appendOutput(item.text); + } + } + if (method === "error" && isRecord(params) && isRecord(params.error) + && typeof params.error.message === "string") { + appendStderr(redactSecrets(params.error.message, credentials)); + } + if (method === "turn/completed") { + if (turnId === undefined) { + if (earlyTerminal !== undefined) { + failUnknown("Codex app-server emitted duplicate terminal state before exact admission"); + return; + } + earlyTerminal = params; + } + else { + acceptTerminal(params); + } + } + }; + try { + const child = spawn(this.#options.executable, ["app-server", "--listen", "stdio://"], { + cwd: request.worktreePath, + env: { ...adapterEnvironment(request) }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + child.stderr.on("data", (chunk) => appendStderr(stderrRedactor.write(chunk))); + connection = new AppServerConnection({ + child, + maximumLineBytes: request.maximumLineBytes, + onNotification, + onFailure: (error) => failUnknown("Codex app-server continuity was lost", error), + }); + resetIdleTimer(); + deadlineTimer = setTimeout(() => { + if (threadId !== undefined && turnId !== undefined) { + void connection?.request("turn/interrupt", { threadId, turnId }).catch(() => undefined); + } + failUnknown("Codex app-server did not return an exact terminal response before the attempt deadline"); + }, timeoutMs); + deadlineTimer.unref(); + await connection.request("initialize", { + clientInfo: { name: "coding_harness_autopilot", title: "Coding Harness Autopilot", version: "0.1.0" }, + capabilities: null, + }); + connection.notify("initialized"); + resetIdleTimer(); + // An explicit writable cwd makes Codex persist project trust. The child already runs in the exact worktree. + const thread = await connection.request("thread/start", { + approvalPolicy: "never", + sandbox: "workspace-write", + ephemeral: true, + serviceName: "coding-harness-autopilot", + }); + threadId = await threadIdentity(thread, request.worktreePath); + resetIdleTimer(); + const turn = await connection.request("turn/start", { + threadId, + input: [{ type: "text", text: renderAttemptContext(request.context), textElements: [] }], + }); + if (failure !== undefined) { + throw failure; + } + turnId = turnIdentity(turn); + if (provisionalTurnId !== undefined && provisionalTurnId !== turnId) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex turn/start response changed the observed turn identity"); + } + resetIdleTimer(); + if (earlyTerminal !== undefined) { + acceptTerminal(earlyTerminal); + } + const subject = { + schemaVersion: 1, + backendId: `codex-app-server-v2@${harnessVersion}`, + subjectId: sha256(canonicalJson({ harnessInstanceId, threadId, turnId })), + harnessInstanceId, + }; + const pending = { + request, + connection, + harnessInstanceId, + threadId, + turnId, + subject, + terminal: terminal.promise, + isTerminalAccepted: () => terminalAccepted, + dispose, + }; + this.#pending.set(adapterExecutionId, pending); + return { protocolVersion: 1, adapterExecutionId, startedAt, subject }; + } + catch (error) { + failUnknown("Codex app-server admission did not return an exact thread and turn identity", error); + await dispose().catch(() => undefined); + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex app-server admission did not return an exact thread and turn identity", { cause: errorMessage(error) }); + } + } + async observe(handle) { + if (this.#reviewHandles.delete(handle.adapterExecutionId)) { + return await this.#options.reviewAdapter.observe(handle); + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || handle.subject?.backendId !== pending.subject.backendId + || handle.subject.subjectId !== pending.subject.subjectId + || handle.subject.harnessInstanceId !== pending.harnessInstanceId) { + throw new AutopilotError("EXECUTION_STATE_UNKNOWN", "Codex turn is not attached to the exact app-server instance"); + } + try { + return await pending.terminal; + } + finally { + try { + await pending.dispose(); + } + finally { + this.#pending.delete(handle.adapterExecutionId); + } + } + } + async cancel(handle) { + if (this.#reviewHandles.has(handle.adapterExecutionId)) { + return await this.#options.reviewAdapter.cancel(handle); + } + const pending = this.#pending.get(handle.adapterExecutionId); + if (pending === undefined || pending.isTerminalAccepted() + || handle.subject?.backendId !== pending.subject.backendId + || handle.subject.subjectId !== pending.subject.subjectId + || handle.subject.harnessInstanceId !== pending.harnessInstanceId) { + return { protocolVersion: 1, accepted: false }; + } + const interrupt = pending.connection.request("turn/interrupt", { + threadId: pending.threadId, + turnId: pending.turnId, + }).then(() => ({ kind: "response" })); + const terminal = pending.terminal.then((observation) => ({ kind: "terminal", observation })); + const first = await Promise.race([interrupt, terminal]); + const observation = first.kind === "terminal" ? first.observation : await pending.terminal; + return { protocolVersion: 1, accepted: observation.status === "cancelled" }; + } +} diff --git a/skills/autopilot/runtime/dist/adapters/codex/index.d.ts b/skills/autopilot/runtime/dist/adapters/codex/index.d.ts index e23b14c..adf5ff3 100644 --- a/skills/autopilot/runtime/dist/adapters/codex/index.d.ts +++ b/skills/autopilot/runtime/dist/adapters/codex/index.d.ts @@ -1,2 +1,4 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; +import { CodexAppServerAdapter } from "./app-server.js"; export declare function createCodexAdapter(): CliHarnessAdapter; +export declare function createCodexAppServerAdapter(): CodexAppServerAdapter; diff --git a/skills/autopilot/runtime/dist/adapters/codex/index.js b/skills/autopilot/runtime/dist/adapters/codex/index.js index 68675c6..ac2bf09 100644 --- a/skills/autopilot/runtime/dist/adapters/codex/index.js +++ b/skills/autopilot/runtime/dist/adapters/codex/index.js @@ -1,4 +1,5 @@ import { CliHarnessAdapter } from "../../src/adapter-process.js"; +import { CodexAppServerAdapter } from "./app-server.js"; export function createCodexAdapter() { return new CliHarnessAdapter({ name: "codex", @@ -19,8 +20,14 @@ export function createCodexAdapter() { limitations: [ "Codex enforces a workspace sandbox, but Autopilot item-path and effect restrictions are checked after execution.", "Implementation executions use the attempt-scoped supervisor for restart reattachment; review executions remain session-scoped.", - "The exact-tree review role is implemented but has no version-pinned live verification.", + "The exact-tree review role passed disposable validation with Codex 0.151.0; other versions remain environment-specific.", ], expectsJsonLines: true, }); } +export function createCodexAppServerAdapter() { + return new CodexAppServerAdapter({ + executable: "codex", + reviewAdapter: createCodexAdapter(), + }); +} diff --git a/skills/autopilot/runtime/dist/src/adapter-process.d.ts b/skills/autopilot/runtime/dist/src/adapter-process.d.ts index 0017b7a..e0bcbe4 100644 --- a/skills/autopilot/runtime/dist/src/adapter-process.d.ts +++ b/skills/autopilot/runtime/dist/src/adapter-process.d.ts @@ -12,7 +12,11 @@ export interface CliHarnessConfiguration { readonly validateResult?: (stdout: string, request: ExecutionRequest) => string | undefined; readonly displayStderrActivity?: boolean; } +export declare function adapterCredentialNames(request: ExecutionRequest): readonly string[]; +export declare function adapterEnvironment(request: ExecutionRequest): NodeJS.ProcessEnv; export declare function parseReviewResult(stdout: string): ReviewResult | undefined; +export declare function redactionValues(credentialEnvironmentNames: readonly string[]): readonly string[]; +export declare function redactSecrets(text: string, credentialEnvironmentNames?: readonly string[]): string; export declare class CliHarnessAdapter implements HarnessPort { #private; constructor(configuration: CliHarnessConfiguration); diff --git a/skills/autopilot/runtime/dist/src/adapter-process.js b/skills/autopilot/runtime/dist/src/adapter-process.js index ae53957..00ba5de 100644 --- a/skills/autopilot/runtime/dist/src/adapter-process.js +++ b/skills/autopilot/runtime/dist/src/adapter-process.js @@ -5,12 +5,12 @@ import { AutopilotError } from "./errors.js"; import { isRecord } from "./json.js"; import { cancelSupervisedProcess, launchSupervisedProcess, observeSupervisedProcess, reattachSupervisedProcess, supervisedExecutionId, supervisorDirectory, } from "./process-supervisor.js"; import { boundUtf8, runProcess } from "./process.js"; -function adapterCredentialNames(request) { +export function adapterCredentialNames(request) { return [...new Set(request.grants .filter(({ actor, family }) => actor === "adapter" && family === "credentials.use") .flatMap(({ environmentNames }) => environmentNames ?? []))].sort(); } -function adapterEnvironment(request) { +export function adapterEnvironment(request) { const allowedCredentialNames = new Set(adapterCredentialNames(request)); return Object.fromEntries(Object.entries(process.env).filter(([name]) => !/(TOKEN|KEY|SECRET|PASSWORD|COOKIE|AUTH)/i.test(name) || allowedCredentialNames.has(name))); } @@ -126,7 +126,7 @@ export function parseReviewResult(stdout) { const unique = new Map(parsed.map((result) => [JSON.stringify(result), result])); return unique.size === 1 ? [...unique.values()][0] : undefined; } -function redactionValues(credentialEnvironmentNames) { +export function redactionValues(credentialEnvironmentNames) { const credentialNames = new Set(credentialEnvironmentNames); return Object.entries(process.env).flatMap(([name, value]) => { const explicitlyGranted = credentialNames.has(name); @@ -136,7 +136,7 @@ function redactionValues(credentialEnvironmentNames) { : [value]; }); } -function redactSecrets(text, credentialEnvironmentNames = []) { +export function redactSecrets(text, credentialEnvironmentNames = []) { const credentialNames = new Set(credentialEnvironmentNames); return Object.entries(process.env).reduce((current, [name, value]) => { const explicitlyGranted = credentialNames.has(name); diff --git a/skills/autopilot/runtime/dist/src/adapters.d.ts b/skills/autopilot/runtime/dist/src/adapters.d.ts index 0094ea2..6591b49 100644 --- a/skills/autopilot/runtime/dist/src/adapters.d.ts +++ b/skills/autopilot/runtime/dist/src/adapters.d.ts @@ -1,3 +1,3 @@ import type { HarnessPort } from "./adapter-protocol.js"; -export type AdapterName = "pi" | "claude-code" | "codex" | "opencode"; +export type AdapterName = "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode"; export declare function createAdapter(name: string): HarnessPort; diff --git a/skills/autopilot/runtime/dist/src/adapters.js b/skills/autopilot/runtime/dist/src/adapters.js index 7467df3..dfc277f 100644 --- a/skills/autopilot/runtime/dist/src/adapters.js +++ b/skills/autopilot/runtime/dist/src/adapters.js @@ -1,5 +1,5 @@ import { createClaudeCodeAdapter } from "../adapters/claude-code/index.js"; -import { createCodexAdapter } from "../adapters/codex/index.js"; +import { createCodexAdapter, createCodexAppServerAdapter } from "../adapters/codex/index.js"; import { createOpenCodeAdapter } from "../adapters/opencode/index.js"; import { createPiAdapter } from "../adapters/pi/index.js"; import { AutopilotError } from "./errors.js"; @@ -11,6 +11,8 @@ export function createAdapter(name) { return createClaudeCodeAdapter(); case "codex": return createCodexAdapter(); + case "codex-app-server": + return createCodexAppServerAdapter(); case "opencode": return createOpenCodeAdapter(); default: diff --git a/skills/autopilot/runtime/dist/src/charter.d.ts b/skills/autopilot/runtime/dist/src/charter.d.ts index 0b6c1b8..a9ab079 100644 --- a/skills/autopilot/runtime/dist/src/charter.d.ts +++ b/skills/autopilot/runtime/dist/src/charter.d.ts @@ -147,7 +147,7 @@ export interface ProposedRunCharter { readonly sourceText: string; readonly createdAt: string; readonly repository: RepositorySpec; - readonly harnessAdapter: "pi" | "claude-code" | "codex" | "opencode"; + readonly harnessAdapter: "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode"; readonly mode: RunMode; readonly work: readonly WorkItem[]; readonly delivery: DeliveryMode; diff --git a/skills/autopilot/runtime/dist/src/charter.js b/skills/autopilot/runtime/dist/src/charter.js index 212df2e..81368cf 100644 --- a/skills/autopilot/runtime/dist/src/charter.js +++ b/skills/autopilot/runtime/dist/src/charter.js @@ -267,7 +267,7 @@ function parseProposed(value, allowHash) { sourceText: expectString(object.sourceText, "charter.sourceText"), createdAt: expectString(object.createdAt, "charter.createdAt"), repository: parseRepository(object.repository, "charter.repository"), - harnessAdapter: expectLiteral(object.harnessAdapter, ["pi", "claude-code", "codex", "opencode"], "charter.harnessAdapter"), + harnessAdapter: expectLiteral(object.harnessAdapter, ["pi", "claude-code", "codex", "codex-app-server", "opencode"], "charter.harnessAdapter"), mode: expectLiteral(object.mode, ["single", "independent-queue", "ordered-stack"], "charter.mode"), work: object.work.map((entry, index) => parseWorkItem(entry, `charter.work[${index}]`)), delivery: expectLiteral(object.delivery, ["local-commits", "change-request-ready", "merge-verified"], "charter.delivery"), diff --git a/skills/autopilot/runtime/dist/src/doctor.js b/skills/autopilot/runtime/dist/src/doctor.js index 6332740..2dcd0ba 100644 --- a/skills/autopilot/runtime/dist/src/doctor.js +++ b/skills/autopilot/runtime/dist/src/doctor.js @@ -68,6 +68,6 @@ export async function runDoctor() { const piSubagents = findPiSubagentsInstallation(); checks.push(await commandCheck("git", "git", ["--version"], "Install Git and make it available on PATH."), await commandCheck("pi", "pi", ["--version"], "Install Pi only if you plan to use the Pi adapter."), piSubagents === undefined ? { name: "pi-subagents", status: "unverified", detail: "version 0.53.0 or newer was not found; Pi will use its distinct direct CLI fallback", setup: "Install and enable pi-subagents through Pi to use the process-local backend; Autopilot never installs it." } - : { name: "pi-subagents", status: "ok", detail: `${piSubagents.version} at ${piSubagents.extensionPath}; process-local owner availability is checked by the Autopilot Pi extension before launch` }, await commandCheck("claude-code", "claude", ["--version"], "Install Claude Code only if you plan to use that adapter."), await commandCheck("codex", "codex", ["--version"], "Install Codex only if you plan to use that adapter."), await commandCheck("opencode", "opencode", ["--version"], "Install OpenCode only if you plan to use that adapter."), await commandCheck("github-cli", "gh", ["--version"], "Install gh only for GitHub delivery."), await commandCheck("gitlab-cli", "glab", ["--version"], "Install glab only for GitLab delivery."), await authenticationCheck("claude-auth-config", "claude", ["auth", "status"]), await authenticationCheck("codex-auth-config", "codex", ["login", "status"]), await authenticationCheck("opencode-auth-config", "opencode", ["providers", "list"]), await authenticationCheck("github-auth", "gh", ["auth", "status"]), await authenticationCheck("gitlab-auth", "glab", ["auth", "status"]), await filesystemCheck()); + : { name: "pi-subagents", status: "ok", detail: `${piSubagents.version} at ${piSubagents.extensionPath}; process-local owner availability is checked by the Autopilot Pi extension before launch` }, await commandCheck("claude-code", "claude", ["--version"], "Install Claude Code only if you plan to use that adapter."), await commandCheck("codex", "codex", ["--version"], "Install Codex only if you plan to use that adapter."), await commandCheck("codex-app-server", "codex", ["app-server", "--help"], "Install a Codex version with app-server support only if you plan to use that adapter."), await commandCheck("opencode", "opencode", ["--version"], "Install OpenCode only if you plan to use that adapter."), await commandCheck("github-cli", "gh", ["--version"], "Install gh only for GitHub delivery."), await commandCheck("gitlab-cli", "glab", ["--version"], "Install glab only for GitLab delivery."), await authenticationCheck("claude-auth-config", "claude", ["auth", "status"]), await authenticationCheck("codex-auth-config", "codex", ["login", "status"]), await authenticationCheck("opencode-auth-config", "opencode", ["providers", "list"]), await authenticationCheck("github-auth", "gh", ["auth", "status"]), await authenticationCheck("gitlab-auth", "glab", ["auth", "status"]), await filesystemCheck()); return checks; } diff --git a/skills/autopilot/runtime/dist/src/process.d.ts b/skills/autopilot/runtime/dist/src/process.d.ts index 76cf22e..2200e2c 100644 --- a/skills/autopilot/runtime/dist/src/process.d.ts +++ b/skills/autopilot/runtime/dist/src/process.d.ts @@ -23,6 +23,12 @@ export interface ProcessResult { readonly truncated: boolean; } export declare function terminateProcessTree(pid: number, executable: string): Promise; +export declare class StreamingRedactor { + #private; + constructor(values: readonly string[]); + write(chunk: Buffer): string; + end(): string; +} export declare function boundUtf8(text: string, maximumBytes: number): { readonly value: string; readonly truncated: boolean; diff --git a/skills/autopilot/runtime/dist/src/process.js b/skills/autopilot/runtime/dist/src/process.js index b5be2bb..52bfd12 100644 --- a/skills/autopilot/runtime/dist/src/process.js +++ b/skills/autopilot/runtime/dist/src/process.js @@ -75,7 +75,7 @@ export async function terminateProcessTree(pid, executable) { throw new AutopilotError("EXECUTION_STATE_UNKNOWN", `cannot prove that ${executable} descendants stopped`, { pid }); } } -class StreamingRedactor { +export class StreamingRedactor { #decoder = new StringDecoder("utf8"); #values; #pending = ""; diff --git a/skills/autopilot/runtime/schemas/charter.schema.json b/skills/autopilot/runtime/schemas/charter.schema.json index 4df9381..762bd24 100644 --- a/skills/autopilot/runtime/schemas/charter.schema.json +++ b/skills/autopilot/runtime/schemas/charter.schema.json @@ -28,7 +28,7 @@ "sourceText": { "type": "string", "minLength": 1 }, "createdAt": { "type": "string", "format": "date-time" }, "repository": { "$ref": "#/$defs/repository" }, - "harnessAdapter": { "enum": ["pi", "claude-code", "codex", "opencode"] }, + "harnessAdapter": { "enum": ["pi", "claude-code", "codex", "codex-app-server", "opencode"] }, "mode": { "enum": ["single", "independent-queue", "ordered-stack"] }, "work": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/workItem" } }, "delivery": { "enum": ["local-commits", "change-request-ready", "merge-verified"] }, diff --git a/skills/autopilot/runtime/src/adapter-process.ts b/skills/autopilot/runtime/src/adapter-process.ts index 1b1326e..851b8d8 100644 --- a/skills/autopilot/runtime/src/adapter-process.ts +++ b/skills/autopilot/runtime/src/adapter-process.ts @@ -43,13 +43,13 @@ interface ExecutionEntry { readonly promise: Promise; } -function adapterCredentialNames(request: ExecutionRequest): readonly string[] { +export function adapterCredentialNames(request: ExecutionRequest): readonly string[] { return [...new Set(request.grants .filter(({ actor, family }) => actor === "adapter" && family === "credentials.use") .flatMap(({ environmentNames }) => environmentNames ?? []))].sort(); } -function adapterEnvironment(request: ExecutionRequest): NodeJS.ProcessEnv { +export function adapterEnvironment(request: ExecutionRequest): NodeJS.ProcessEnv { const allowedCredentialNames = new Set(adapterCredentialNames(request)); return Object.fromEntries(Object.entries(process.env).filter(([name]) => !/(TOKEN|KEY|SECRET|PASSWORD|COOKIE|AUTH)/i.test(name) || allowedCredentialNames.has(name), @@ -179,7 +179,7 @@ export function parseReviewResult(stdout: string): ReviewResult | undefined { return unique.size === 1 ? [...unique.values()][0] : undefined; } -function redactionValues(credentialEnvironmentNames: readonly string[]): readonly string[] { +export function redactionValues(credentialEnvironmentNames: readonly string[]): readonly string[] { const credentialNames = new Set(credentialEnvironmentNames); return Object.entries(process.env).flatMap(([name, value]) => { const explicitlyGranted = credentialNames.has(name); @@ -190,7 +190,7 @@ function redactionValues(credentialEnvironmentNames: readonly string[]): readonl }); } -function redactSecrets(text: string, credentialEnvironmentNames: readonly string[] = []): string { +export function redactSecrets(text: string, credentialEnvironmentNames: readonly string[] = []): string { const credentialNames = new Set(credentialEnvironmentNames); return Object.entries(process.env).reduce((current, [name, value]) => { const explicitlyGranted = credentialNames.has(name); diff --git a/skills/autopilot/runtime/src/adapters.ts b/skills/autopilot/runtime/src/adapters.ts index 1daf3b1..e2d0b46 100644 --- a/skills/autopilot/runtime/src/adapters.ts +++ b/skills/autopilot/runtime/src/adapters.ts @@ -1,11 +1,11 @@ import { createClaudeCodeAdapter } from "../adapters/claude-code/index.js"; -import { createCodexAdapter } from "../adapters/codex/index.js"; +import { createCodexAdapter, createCodexAppServerAdapter } from "../adapters/codex/index.js"; import { createOpenCodeAdapter } from "../adapters/opencode/index.js"; import { createPiAdapter } from "../adapters/pi/index.js"; import type { HarnessPort } from "./adapter-protocol.js"; import { AutopilotError } from "./errors.js"; -export type AdapterName = "pi" | "claude-code" | "codex" | "opencode"; +export type AdapterName = "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode"; export function createAdapter(name: string): HarnessPort { switch (name) { @@ -15,6 +15,8 @@ export function createAdapter(name: string): HarnessPort { return createClaudeCodeAdapter(); case "codex": return createCodexAdapter(); + case "codex-app-server": + return createCodexAppServerAdapter(); case "opencode": return createOpenCodeAdapter(); default: diff --git a/skills/autopilot/runtime/src/charter.ts b/skills/autopilot/runtime/src/charter.ts index 98e2c28..05d3ad7 100644 --- a/skills/autopilot/runtime/src/charter.ts +++ b/skills/autopilot/runtime/src/charter.ts @@ -186,7 +186,7 @@ export interface ProposedRunCharter { readonly sourceText: string; readonly createdAt: string; readonly repository: RepositorySpec; - readonly harnessAdapter: "pi" | "claude-code" | "codex" | "opencode"; + readonly harnessAdapter: "pi" | "claude-code" | "codex" | "codex-app-server" | "opencode"; readonly mode: RunMode; readonly work: readonly WorkItem[]; readonly delivery: DeliveryMode; @@ -480,7 +480,11 @@ function parseProposed(value: unknown, allowHash: boolean): ProposedRunCharter { sourceText: expectString(object.sourceText, "charter.sourceText"), createdAt: expectString(object.createdAt, "charter.createdAt"), repository: parseRepository(object.repository, "charter.repository"), - harnessAdapter: expectLiteral(object.harnessAdapter, ["pi", "claude-code", "codex", "opencode"], "charter.harnessAdapter"), + harnessAdapter: expectLiteral( + object.harnessAdapter, + ["pi", "claude-code", "codex", "codex-app-server", "opencode"], + "charter.harnessAdapter", + ), mode: expectLiteral(object.mode, ["single", "independent-queue", "ordered-stack"], "charter.mode"), work: object.work.map((entry, index) => parseWorkItem(entry, `charter.work[${index}]`)), delivery: expectLiteral(object.delivery, ["local-commits", "change-request-ready", "merge-verified"], "charter.delivery"), diff --git a/skills/autopilot/runtime/src/doctor.ts b/skills/autopilot/runtime/src/doctor.ts index 36691a0..3f0d75a 100644 --- a/skills/autopilot/runtime/src/doctor.ts +++ b/skills/autopilot/runtime/src/doctor.ts @@ -81,6 +81,12 @@ export async function runDoctor(): Promise { : { name: "pi-subagents", status: "ok", detail: `${piSubagents.version} at ${piSubagents.extensionPath}; process-local owner availability is checked by the Autopilot Pi extension before launch` }, await commandCheck("claude-code", "claude", ["--version"], "Install Claude Code only if you plan to use that adapter."), await commandCheck("codex", "codex", ["--version"], "Install Codex only if you plan to use that adapter."), + await commandCheck( + "codex-app-server", + "codex", + ["app-server", "--help"], + "Install a Codex version with app-server support only if you plan to use that adapter.", + ), await commandCheck("opencode", "opencode", ["--version"], "Install OpenCode only if you plan to use that adapter."), await commandCheck("github-cli", "gh", ["--version"], "Install gh only for GitHub delivery."), await commandCheck("gitlab-cli", "glab", ["--version"], "Install glab only for GitLab delivery."), diff --git a/skills/autopilot/runtime/src/process.ts b/skills/autopilot/runtime/src/process.ts index 2fcf7d5..759df84 100644 --- a/skills/autopilot/runtime/src/process.ts +++ b/skills/autopilot/runtime/src/process.ts @@ -112,7 +112,7 @@ export async function terminateProcessTree(pid: number, executable: string): Pro } } -class StreamingRedactor { +export class StreamingRedactor { readonly #decoder = new StringDecoder("utf8"); readonly #values: readonly string[]; #pending = ""; diff --git a/skills/autopilot/runtime/test/adapter-compatibility.test.ts b/skills/autopilot/runtime/test/adapter-compatibility.test.ts index dce9455..ab91ab3 100644 --- a/skills/autopilot/runtime/test/adapter-compatibility.test.ts +++ b/skills/autopilot/runtime/test/adapter-compatibility.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import { createAdapter } from "../src/adapters.js"; test("all documented harness adapter names resolve through one port factory", () => { - for (const name of ["pi", "claude-code", "codex", "opencode"]) { + for (const name of ["pi", "claude-code", "codex", "codex-app-server", "opencode"]) { const adapter = createAdapter(name); assert.equal(typeof adapter.describe, "function"); diff --git a/skills/autopilot/runtime/test/codex-app-server.test.ts b/skills/autopilot/runtime/test/codex-app-server.test.ts new file mode 100644 index 0000000..05b3cb8 --- /dev/null +++ b/skills/autopilot/runtime/test/codex-app-server.test.ts @@ -0,0 +1,437 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import type { ExecutionRequest } from "../src/adapter-protocol.js"; +import { CliHarnessAdapter } from "../src/adapter-process.js"; +import { CodexAppServerAdapter } from "../adapters/codex/app-server.js"; +import { attemptContextFixture, writeNodeExecutable } from "./helpers.js"; + +const FAKE_CODEX = `#!/usr/bin/env node +const mode = ${JSON.stringify("MODE")}; +if (process.argv.includes("--version")) { + process.stdout.write("codex-cli 0.151.0\\n"); + process.exit(0); +} +if (process.argv.includes("--help")) { + process.stdout.write("Usage: codex app-server\\n"); + process.exit(0); +} +if (process.argv.includes("--review")) { + process.stdout.write("direct review\\n"); + process.exit(0); +} +if (mode.startsWith("ignore-term")) { + process.on("SIGTERM", () => {}); + setInterval(() => {}, 1000); + process.getBuiltinModule("node:fs").writeFileSync("fake.pid", String(process.pid)); +} +let buffer = ""; +let threadParams; +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +const terminal = (status, text = "") => { + const items = text === "" ? [] : [{ type: "agentMessage", id: "agent-1", text }]; + return { method: "turn/completed", params: { + threadId: "thread-1", + turn: { id: "turn-1", status, items, error: status === "failed" ? { message: "failed" } : null }, + } }; +}; +const complete = (status, text = "") => send(terminal(status, text)); +const handle = (message) => { + if (message.method === "initialize") { + if (mode === "stdin-error") process.stdin.destroy(); + send({ id: message.id, result: { + userAgent: "fake", codexHome: process.cwd(), platformFamily: "test", platformOs: "test", + } }); + if (mode === "stdin-error") setTimeout(() => {}, 10000); + return; + } + if (message.method === "initialized") return; + if (message.method === "thread/start") { + threadParams = message.params; + send({ id: message.id, result: { + thread: { id: "thread-1", ephemeral: true }, + cwd: process.cwd(), + approvalPolicy: message.params.approvalPolicy, + sandbox: { type: message.params.sandbox === "workspace-write" ? "workspaceWrite" : "readOnly" }, + } }); + send({ method: "thread/started", params: { thread: { id: "thread-1" } } }); + return; + } + if (message.method === "turn/start") { + if (mode === "admission-exit") { + process.exit(18); + } + if (mode === "ignore-term-admission") { + return; + } + const response = { id: message.id, result: { + turn: { id: "turn-1", status: "inProgress", items: [], error: null }, + } }; + if (mode === "early-terminal") { + process.stdout.write(JSON.stringify(response) + "\\n" + JSON.stringify(terminal("completed", "early completion")) + "\\n"); + return; + } + if (mode === "early-conflict") { + process.stdout.write(JSON.stringify(response) + "\\n" + JSON.stringify(terminal("failed")) + "\\n" + JSON.stringify(terminal("completed")) + "\\n"); + return; + } + send(response); + send({ method: "turn/started", params: { + threadId: "thread-1", turn: { id: "turn-1", status: "inProgress", items: [], error: null }, + } }); + if (mode === "complete") { + setImmediate(() => complete("completed", "cwdSupplied=" + Object.hasOwn(threadParams, "cwd") + ";secret=" + process.env.AUTOPILOT_CODEX_TEST_TOKEN)); + } else if (mode === "exit") { + setImmediate(() => process.exit(17)); + } else if (mode === "wrong-terminal") { + setImmediate(() => send({ method: "turn/completed", params: { + threadId: "thread-1", turn: { id: "turn-other", status: "completed", items: [], error: null }, + } })); + } else if (mode === "oversized" || mode === "ignore-term-oversized") { + setImmediate(() => process.stdout.write(JSON.stringify({ method: "warning", params: { text: "x".repeat(4096) } }) + "\\n")); + } else if (mode === "server-request") { + setImmediate(() => send({ method: "item/tool/requestUserInput", id: 900, params: { + threadId: "thread-1", turnId: "turn-1", + } })); + } else if (mode === "stderr-split") { + process.stderr.write("abc"); + setTimeout(() => { + process.stderr.write("def"); + complete("completed"); + }, 20); + } else if (mode === "ignore-term") { + setImmediate(() => complete("completed", "pid=" + process.pid)); + } + return; + } + if (message.method === "turn/interrupt") { + if (mode === "cancel-terminal-first") { + complete("interrupted"); + return; + } + if (mode === "cancel-completed-first") { + complete("completed", "natural completion"); + return; + } + if (mode === "cancel-ack-natural") { + send({ id: message.id, result: {} }); + setImmediate(() => complete("completed", "natural completion after ack")); + return; + } + send({ id: message.id, result: {} }); + setImmediate(() => complete("interrupted")); + return; + } + if (message.id === 900) { + const code = message.error && message.error.code; + setImmediate(() => complete("completed", "serverRequestError=" + code)); + } +}; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline = buffer.indexOf("\\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line !== "") handle(JSON.parse(line)); + newline = buffer.indexOf("\\n"); + } +}); +`; + +function request(worktreePath: string, attemptId: string, role: ExecutionRequest["role"] = "implementation"): ExecutionRequest { + return { + protocolVersion: 1, + role, + runId: "run", + itemId: "item", + attemptId, + worktreePath, + objective: "test Codex app-server", + acceptanceSummary: "test", + context: attemptContextFixture(attemptId), + contextHash: `context-${attemptId}`, + writableRoots: ["."], + grants: [{ family: "credentials.use", actor: "adapter", environmentNames: ["AUTOPILOT_CODEX_TEST_TOKEN"] }], + deadline: new Date(Date.now() + 10_000).toISOString(), + idleTimeoutMs: 500, + maximumLineBytes: 1024, + maximumOutputBytes: 4096, + }; +} + +async function createAdapter(mode: string): Promise<{ + readonly adapter: CodexAppServerAdapter; + readonly worktreePath: string; +}> { + const worktreePath = await mkdtemp(join(tmpdir(), "autopilot-codex-app-server-")); + const executable = await writeNodeExecutable(worktreePath, "codex", FAKE_CODEX.replace('"MODE"', JSON.stringify(mode))); + const reviewAdapter = new CliHarnessAdapter({ + name: "codex", + executable, + versionArguments: ["--version"], + buildArguments: () => ["--review"], + assurance: "cooperative", + maxConcurrency: 1, + cancellation: true, + limitations: [], + expectsJsonLines: false, + }); + return { + worktreePath, + adapter: new CodexAppServerAdapter({ executable, reviewAdapter }), + }; +} + +function isExecutionUnknown(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "EXECUTION_STATE_UNKNOWN"; +} + +test("Codex app-server admits an exact ephemeral turn without persisting cwd trust", async () => { + process.env.AUTOPILOT_CODEX_TEST_TOKEN = "codex-test-secret"; + try { + const { adapter, worktreePath } = await createAdapter("complete"); + + const manifest = await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "complete")); + const observation = await adapter.observe(handle); + + assert.equal(manifest.adapterName, "codex-app-server"); + assert.equal(manifest.restartReattachment, false); + assert.deepEqual(manifest.executionAssurance?.implementation, { + schemaVersion: 1, + owner: "harness", + continuity: "same-harness-instance", + terminality: "cooperative", + admission: "single-shot", + }); + assert.match(handle.subject?.backendId ?? "", /^codex-app-server-v2@codex-cli 0\.151\.0$/); + assert.ok(handle.subject?.harnessInstanceId); + assert.equal(observation.status, "completed"); + assert.match(observation.stdout, /cwdSupplied=false/); + assert.doesNotMatch(observation.stdout, /codex-test-secret/); + assert.match(observation.stdout, /secret=\*\*\*\*/); + } finally { + delete process.env.AUTOPILOT_CODEX_TEST_TOKEN; + } +}); + +test("Codex app-server cancellation requires the exact interrupted turn terminal", async () => { + const { adapter, worktreePath } = await createAdapter("cancel"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "cancel")); + + const cancellation = await adapter.cancel(handle); + const observation = await adapter.observe(handle); + + assert.equal(cancellation.accepted, true); + assert.equal(observation.status, "cancelled"); + assert.equal(observation.exitCode, 130); +}); + +test("Codex app-server accepts exact terminal cancellation before the interrupt reply", async () => { + const { adapter, worktreePath } = await createAdapter("cancel-terminal-first"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "cancel-terminal-first")); + + const cancellation = await adapter.cancel(handle); + const observation = await adapter.observe(handle); + + assert.equal(cancellation.accepted, true); + assert.equal(observation.status, "cancelled"); +}); + +test("Codex app-server reports a natural completion that wins the cancellation race", async () => { + const { adapter, worktreePath } = await createAdapter("cancel-completed-first"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "cancel-completed-first")); + + const cancellation = await adapter.cancel(handle); + const observation = await adapter.observe(handle); + + assert.equal(cancellation.accepted, false); + assert.equal(observation.status, "completed"); + assert.equal(observation.stdout, "natural completion"); +}); + +test("Codex app-server does not treat an interrupt acknowledgment as terminal cancellation", async () => { + const { adapter, worktreePath } = await createAdapter("cancel-ack-natural"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "cancel-ack-natural")); + + const cancellation = await adapter.cancel(handle); + const observation = await adapter.observe(handle); + + assert.equal(cancellation.accepted, false); + assert.equal(observation.status, "completed"); + assert.equal(observation.stdout, "natural completion after ack"); +}); + +test("Codex app-server rejects a handle from another harness instance", async () => { + const { adapter, worktreePath } = await createAdapter("cancel"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "wrong-handle")); + if (handle.subject === undefined) { + throw new Error("Codex app-server handle did not include its exact subject"); + } + const wrongHandle = { + ...handle, + subject: { ...handle.subject, harnessInstanceId: "other-instance" }, + }; + const wrongBackend = { + ...handle, + subject: { ...handle.subject, backendId: "other-backend" }, + }; + + assert.equal((await adapter.cancel(wrongBackend)).accepted, false); + await assert.rejects(adapter.observe(wrongHandle), isExecutionUnknown); + await adapter.cancel(handle); + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "cancelled"); +}); + +test("Codex app-server accepts one exact terminal that arrives with the turn response", async () => { + const { adapter, worktreePath } = await createAdapter("early-terminal"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "early-terminal")); + + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "completed"); + assert.equal(observation.stdout, "early completion"); +}); + +test("Codex app-server fails closed on conflicting pre-admission terminal events", async () => { + const { adapter, worktreePath } = await createAdapter("early-conflict"); + await adapter.describe(); + + await assert.rejects(adapter.launch(request(worktreePath, "early-conflict")), isExecutionUnknown); +}); + +test("Codex app-server converts an asynchronous stdin failure into unknown state", async () => { + const { adapter, worktreePath } = await createAdapter("stdin-error"); + await adapter.describe(); + + await assert.rejects(adapter.launch(request(worktreePath, "stdin-error")), isExecutionUnknown); +}); + +test("Codex app-server admission loss is execution-state-unknown without a subject", async () => { + const { adapter, worktreePath } = await createAdapter("admission-exit"); + await adapter.describe(); + + await assert.rejects(adapter.launch(request(worktreePath, "admission-exit")), isExecutionUnknown); +}); + +test("Codex app-server process loss is execution-state-unknown", async () => { + const { adapter, worktreePath } = await createAdapter("exit"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "exit")); + + await assert.rejects(adapter.observe(handle), isExecutionUnknown); +}); + +test("Codex app-server ignores a mismatched terminal identity and fails closed on continuity loss", async () => { + const { adapter, worktreePath } = await createAdapter("wrong-terminal"); + await adapter.describe(); + const executionRequest = { ...request(worktreePath, "wrong-terminal"), idleTimeoutMs: 50 }; + const execution = adapter.launch(executionRequest).then(async (handle) => await adapter.observe(handle)); + + await assert.rejects(execution, isExecutionUnknown); +}); + +test("Codex app-server enforces the inbound protocol line bound", async () => { + const { adapter, worktreePath } = await createAdapter("oversized"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "oversized")); + + await assert.rejects(adapter.observe(handle), isExecutionUnknown); +}); + +test("Codex app-server redacts a credential split across bounded stderr chunks", async () => { + process.env.AUTOPILOT_CODEX_TEST_TOKEN = "abcdef"; + try { + const { adapter, worktreePath } = await createAdapter("stderr-split"); + await adapter.describe(); + const executionRequest = { ...request(worktreePath, "stderr-split"), maximumOutputBytes: 2 }; + const handle = await adapter.launch(executionRequest); + + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "completed"); + assert.doesNotMatch(observation.stderr, /[a-f]/); + assert.equal(observation.truncated, true); + } finally { + delete process.env.AUTOPILOT_CODEX_TEST_TOKEN; + } +}); + +test("Codex app-server force-stops a direct child that ignores graceful cleanup", { + skip: process.platform === "win32", +}, async () => { + const { adapter, worktreePath } = await createAdapter("ignore-term"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "ignore-term")); + + const observation = await adapter.observe(handle); + const pid = Number.parseInt(observation.stdout.replace("pid=", ""), 10); + + assert.equal(observation.status, "completed"); + assert.ok(Number.isSafeInteger(pid)); + assert.throws(() => process.kill(pid, 0), (error: unknown) => + error instanceof Error && "code" in error && error.code === "ESRCH" + ); +}); + +test("Codex app-server awaits forced direct-child cleanup on unknown admission, timeout, and protocol paths", { + skip: process.platform === "win32", +}, async () => { + for (const scenario of ["admission", "timeout", "oversized"] as const) { + const { adapter, worktreePath } = await createAdapter(`ignore-term-${scenario}`); + await adapter.describe(); + const baseRequest = request(worktreePath, `ignore-term-${scenario}`); + const executionRequest = scenario === "admission" + ? { ...baseRequest, idleTimeoutMs: 50 } + : scenario === "timeout" + ? { ...baseRequest, deadline: new Date(Date.now() + 50).toISOString() } + : baseRequest; + + if (scenario === "admission") { + await assert.rejects(adapter.launch(executionRequest), isExecutionUnknown); + } else { + const handle = await adapter.launch(executionRequest); + await assert.rejects(adapter.observe(handle), isExecutionUnknown); + } + const pid = Number.parseInt(await readFile(join(worktreePath, "fake.pid"), "utf8"), 10); + + assert.throws(() => process.kill(pid, 0), (error: unknown) => + error instanceof Error && "code" in error && error.code === "ESRCH" + ); + } +}); + +test("Codex app-server denies server requests without expanding unattended authority", async () => { + const { adapter, worktreePath } = await createAdapter("server-request"); + await adapter.describe(); + const handle = await adapter.launch(request(worktreePath, "server-request")); + + const observation = await adapter.observe(handle); + + assert.equal(observation.status, "completed"); + assert.equal(observation.stdout, "serverRequestError=-32601"); +}); + +test("Codex app-server keeps independent review on the direct CLI adapter", async () => { + const { adapter, worktreePath } = await createAdapter("complete"); + await adapter.describe(); + + const handle = await adapter.launch(request(worktreePath, "review", "review")); + const observation = await adapter.observe(handle); + + assert.equal(handle.subject?.backendId, "direct-process"); + assert.equal(observation.status, "completed"); + assert.equal(observation.stdout, "direct review\n"); +});