feat(runner): add skillless Codex app-server tracer

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-08 01:33:36 +00:00
1 parent 6f179780e2
commit 2161ddcc4a
25 files changed
+18738 -24

No files matched your search

+13 -5
View File
@@ -10,10 +10,12 @@ static replay, and a standalone browser reference page. Phase 2 adds the Rust
runner process, process-group supervision, a scripted fake harness over stdio
JSONL, a TypeScript mock core, and validated live browser replay. Phase 3 adds
the Rust outbound WebSocket client, private durable state, replay and restart
recovery, and safe CLI/browser diagnostics. These phases do not import or start
Paperclip's server, UI, CLI, or production database.
recovery, and safe CLI/browser diagnostics. Phase 4 adds a direct, skillless
Codex app-server driver, semantic completion tools, canonical event tracing,
and a real-model example against the mock core. These phases do not import or
start Paperclip's server, UI, CLI, or production database.
## Phase 0–3 quick start
## Phase 0–4 quick start
From the repository root:
@@ -52,6 +54,7 @@ devtool:
pnpm --filter @paperclipai/paperclip-runner replay:phase1
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-path
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack
pnpm --filter @paperclipai/paperclip-runner trace:phase4
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
```
@@ -75,9 +78,11 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port
| `record:phase2` | Capture a validated happy-path live trace as a replay fixture. |
| `trace:phase3` | Run the Rust runner against the mock core with a selected recovery fault. |
| `record:phase3` | Regenerate the complete Phase 3 fault matrix and exact per-fault traces. |
| `trace:phase4` | Run the mock core with a real, local skillless Codex app-server session. |
| `record:phase4` | Run the safe Codex task and record its validated, normalized trace. |
| `browser:dev` | Start the standalone live/replay/recovery browser devtool. |
| `test:browser` | Exercise static replay and live scenarios, then capture temporary screenshots under ignored test output. |
| `verify` | Run the complete Phase 0 through Phase 3 acceptance sequence. |
| `verify` | Run the complete deterministic Phase 0 through Phase 4 acceptance sequence. |
| `verify:rootless` | Extract Debian/Ubuntu browser libraries without root, then run `verify`. |
## Navigate
@@ -90,6 +95,8 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port
- [Phase 2 local protocol reference](docs/phase-02-local-protocol.md)
- [Phase 3 break-it-on-purpose tutorial](docs/tutorials/phase-03-break-recovery.md)
- [Phase 3 durable transport reference](docs/phase-03-durable-transport.md)
- [Phase 4 skillless Codex tutorial](docs/tutorials/phase-04-skillless-codex.md)
- [Phase 4 skillless Codex driver reference](docs/phase-04-skillless-codex-driver.md)
- [PRP compatibility/versioning policy](docs/protocol-compatibility.md)
- [Cumulative end-to-end tutorial](docs/tutorials/end-to-end.md)
- [Journal guide](docs/journal.md)
@@ -97,4 +104,5 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port
- [Implementation plan](spec/paperclip-native-runner-implementation-plan.md)
- [Normative spike specification](spec/paperclip-native-runner-spike-spec.md)
Phase 3 adds package-local durable network recovery. Production Paperclip integration and a real model harness remain deferred.
Phase 4 adds the package-local real-model reference driver. Production
Paperclip integration and Phase 5 remain deferred.
+4 -1
View File
@@ -9,6 +9,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 1: static PRP replay](tutorials/phase-01-static-replay.md) — validate the shared schema/fixture corpus, reduce a fixture in the CLI, and inspect the same final snapshot in the standalone browser page.
- [Phase 2: local runner and fake harness](tutorials/phase-02-local-runner.md) — run a supervised Rust process, exercise scripted live scenarios, resolve requests, interrupt a turn, and confirm live/replay parity.
- [Phase 3: break recovery on purpose](tutorials/phase-03-break-recovery.md) — lose an ACK, drop the socket, restart the runner side, replay durable events, and inspect recovery diagnostics.
- [Phase 4: run the skillless Codex driver](tutorials/phase-04-skillless-codex.md) — run a safe real-model task, inspect the exact context boundary, steer or interrupt it, and confirm one replay-safe result.
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
## Reference
@@ -17,8 +18,10 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [PRP compatibility and versioning policy](protocol-compatibility.md)
- [Phase 2 local protocol and supervision](phase-02-local-protocol.md)
- [Phase 3 durable transport and recovery](phase-03-durable-transport.md)
- [Phase 4 skillless Codex driver](phase-04-skillless-codex-driver.md)
- [Engineering journal guide](journal.md)
- [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md)
- [Package README](../README.md)
Phase 3 is implemented and ready for its review and human checkpoint. Later tutorials remain deferred.
Phase 4 is implemented and ready for Security, CTO, QA, and human review.
Phase 5 remains uncreated.
@@ -0,0 +1,119 @@
# Phase 4 Skillless Codex Driver
## Scope
Phase 4 implements a direct Codex app-server v2 driver behind the package's
existing `HarnessDriver` contract. The driver, mock core, example CLI, tests,
and evidence stay inside `packages/paperclip-runner/`. They do not import or
change Paperclip server, UI, database, or production control-plane behavior.
The app-server process is local to the execution environment and uses newline
delimited JSON-RPC over stdio. It is not exposed as a network service.
## Identity mapping
| Runner identity | Codex source | Persistence rule |
| --- | --- | --- |
| run ID | mock-core input | Never replaced during recovery. |
| normalized session ID | `session:<runId>` | Stable across transport/process recovery. |
| driver session ID | `thread.id` | Resumed by exact ID. A different returned ID fails recovery. |
| provider session ID | `thread.sessionId` | Kept separately from the driver thread ID. |
| turn ID | `turn.id` | Required by steer and interrupt preconditions. |
| item ID | `item.id`, request ID, or deterministic turn/kind key | Preserved on lifecycle and delta events. |
| source event ID | runner instance + run + source sequence | Source sequence continues from the persisted snapshot. |
The persisted session snapshot records run, normalized session, driver
session, provider session, active turn, and last source sequence. Recovery
starts a new local app-server transport, calls `thread/resume`, then uses
`thread/read` for reconciliation. It never silently starts a replacement
thread.
## App-server operations
| Driver operation | App-server method | Degradation |
| --- | --- | --- |
| initialize | `initialize`, then `initialized` | Startup fails visibly. |
| create | `thread/start` | Required. |
| resume | `thread/resume` | `recovered: false` with a redacted reason. |
| read | `thread/read` | Explicit `UnsupportedCodexOperationError`. |
| start turn | `turn/start` | Required. |
| steer | `turn/steer` with `expectedTurnId` | Explicit unsupported diagnostic; no stdin fallback. |
| interrupt | `turn/interrupt` | Explicit unsupported diagnostic; session is not killed. |
| usage | `thread/tokenUsage/updated` | Returns the last snapshot or explicit unsupported error. |
| reconcile | `thread/read` plus `session.reconciled` | Disabled when read is unavailable. |
Capability flags are descriptive and executable. Unsupported operations emit
canonical `harness.diagnostic` events with secret-redacted detail. No
harness-specific branch is required in the mock core.
## Skillless context boundary
The model receives one text input containing `paperclip.skillless_task.v1`:
- objective;
- completion-contract revision and criteria;
- task constraints; and
- the expected canonical result schema name.
The thread config explicitly disables automatic skill, app, and collaboration
instruction blocks. The model input accepts only text, never a Codex `skill`
input. The driver captures the returned instruction-source list and requires it
to be empty for the skillless assertion.
The child process environment is allowlisted. It may receive only local runtime
keys such as `PATH`, `HOME`, `CODEX_HOME`, locale, temporary-directory, trusted
certificate, and credential-free proxy settings. Paperclip bearer values,
`OPENAI_API_KEY`, arbitrary skill paths, and other inherited variables are not
passed. Diagnostics redact bearer/basic credentials, credentialed proxy URLs,
secret query parameters, sensitive JSON keys, and common key assignments.
The context snapshot records configuration and environment **key names**, not
secret values.
## Semantic completion
The provider-facing structured-output schema covers `done` and `needs_review`.
It uses the strict OpenAI shape: every object rejects additional properties and
the constant schema field includes both `type: "string"` and `const`.
Two dynamic semantic tools are registered when supported:
- `paperclip_finish` accepts `done` or `needs_review`;
- `paperclip_block` accepts `blocked` and requires a blocker owner, action,
reason, and scope.
Both normalize through the canonical `paperclip.run_result.v1` validator. The
first valid result is committed. Repeating the same result is idempotent; a
different later result is rejected. A completed turn with no valid semantic
result produces one explicit `needs_review` result and one failed terminal
event. Process exit or prose alone never implies completion.
## Canonical event mapping
- thread lifecycle -> `session.started`, `session.resumed`,
`session.reconciled`;
- turn lifecycle -> `turn.submitted`, `turn.accepted`, `turn.started`, and one
terminal turn event;
- messages, reasoning, plans, commands, file changes, dynamic tools, and diffs
-> `item.started`, `item.delta`, `item.completed`;
- model selection -> a completed `model` item;
- app-server decisions -> `runtime_request.created` and
`runtime_request.resolved` with redacted detail;
- token snapshots -> completed `usage` items;
- semantic verification rows -> completed `verification` items;
- completion -> one `run.result.proposed` and one `run.terminal`.
The existing Phase 1 reducer consumes the live stream and the replay stream.
The Phase 4 tracer requires byte-equivalent snapshots, contiguous source
sequences, stable item IDs, one result, and one terminal event.
## Runnable example
`trace:phase4` starts a real local `codex app-server` session through the mock
core. Its safe task can create only `hello.txt`, disables network access, and
checks the exact file contents. See the
[Phase 4 tutorial](tutorials/phase-04-skillless-codex.md).
This phase changes no browser surface, so no new browser screenshot applies.
The canonical events are proved through the existing reducer/replay path and
JSON trace evidence.
@@ -3,18 +3,21 @@
## What this tutorial is
This tutorial combines each implemented Native Runner phase into one procedure.
It currently includes Phase 0 through Phase 3.
It currently includes Phase 0 through Phase 4.
## What this tutorial proves
This tutorial proves that the standalone package boundary, static replay path,
local live-run path, and durable recovery path work together. It does not use
Paperclip or a real model.
local live-run path, durable recovery path, and direct Codex driver work
together. It does not use the Paperclip control plane. Phase 4 uses a real
local Codex session through the mock core.
The current system includes the Rust mock-core tracer, shared protocol fixtures,
the Rust supervisor, a scripted fake harness, CLI live runs, and browser live and
replay modes. It also includes the Rust outbound WebSocket client, durable
outbox, package-local mock core, recovery CLI, and recovery browser view.
The final phase adds a skillless task envelope, direct app-server driver,
semantic completion tools, and the same reducer/replay proof used by fixtures.
## Current end-to-end path
@@ -39,6 +42,13 @@ outbox, package-local mock core, recovery CLI, and recovery browser view.
15. Confirm that the recovered runner and session IDs stay the same.
16. Confirm that the outbox is empty after replay and cumulative acknowledgement.
17. Open the [Phase 3 journal entry](../../knowledge/journal/2026-08-07-phase-03.md).
18. Follow [Phase 4: Run the Skillless Codex Driver](phase-04-skillless-codex.md).
19. Inspect the exact model-context snapshot and confirm that it has no
Paperclip instructions, bearer credentials, or unrelated skills.
20. Run the safe task, then steer and interrupt separate sessions. Confirm
stable session identities and exactly one result and terminal event.
21. Open the [Phase 4 journal entry](../../knowledge/journal/2026-08-08-phase-04.md)
and its linked real-session trace and verification evidence.
The one-command form after installation is:
@@ -82,6 +92,17 @@ pnpm --filter @paperclipai/paperclip-runner verify:rootless
- lease expiry, drain, revoke, and unrecoverable storage outcomes are explicit;
- CLI and browser diagnostics do not expose bootstrap or connection-lease
tokens.
- the Codex child receives an allowlisted environment without Paperclip or
OpenAI bearer credentials;
- automatic skill, app, and collaboration instruction blocks are disabled;
- direct app-server create, resume, read, turn, steer, interrupt, usage, and
reconciliation operations preserve stable identities;
- provider events normalize to canonical lifecycle, model, tool, file,
request, usage, verification, result, and terminal events;
- the first validated semantic completion wins, identical duplicates are
idempotent, and a changed duplicate is rejected;
- unsupported capabilities degrade through explicit redacted diagnostics;
- the real trace and its replay reduce to the same final snapshot.
Phase 4 remains uncreated until the Phase 3 review and human checkpoint are
Phase 5 remains uncreated until the Phase 4 review and human checkpoint are
complete.
@@ -0,0 +1,170 @@
# Phase 4: Run the Skillless Codex Driver
## What this phase is
Phase 4 connects the mock core to a real Codex app-server session.
Codex gets one small task envelope. Codex does not get the Paperclip skill.
Codex does not get a Paperclip API key or API instructions.
## What this phase proves
This phase proves that a real Codex session can create a file, stream typed
events, return one checked result, and replay the same result.
It also proves that steer, interrupt, resume, and unsupported-operation paths
keep clear session identities and safe diagnostics.
## Before you start
- Run commands from the repository root.
- Install Node.js 20+, pnpm 9+, Rust, and the `codex` CLI.
- Sign in to Codex with the normal local Codex setup.
- Do not add a Paperclip or OpenAI API key for this tutorial.
Check the installed harness:
```sh
codex --version
codex app-server --help
```
## Step 1: Run the focused conformance checks
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
Confirm that the driver tests pass and the package boundary check says
`Standalone boundary check passed.`
## Step 2: Run the safe real Codex task
Choose a temporary directory and trace file:
```sh
phase4_workspace="$(mktemp -d)"
phase4_trace="$phase4_workspace/trace.json"
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
--working-directory "$phase4_workspace" \
--output "$phase4_trace"
```
Confirm:
- the result is `done`;
- every printed assertion says `PASS`;
- the file has the exact text:
```sh
test "$(cat "$phase4_workspace/hello.txt")" = "hello from phase 4"
```
## Step 3: Inspect the exact model boundary
```sh
jq '.context | {
protocolVersion,
codexVersion,
model,
modelProvider,
workingDirectory,
sandbox,
approvalPolicy,
baseInstructions,
instructionSources,
instructionPolicy,
environmentKeys,
dynamicToolNames,
modelInputKinds,
envelope
}' "$phase4_trace"
```
Confirm:
- `instructionSources` is empty;
- all three instruction-policy values are `false`;
- `modelInputKinds` contains only `text`;
- the semantic tools are `paperclip_finish` and `paperclip_block`;
- no environment **value** is present;
- the envelope contains only this safe task and its completion criteria.
Check that no control-plane route or bearer credential appears:
```sh
if rg -ni 'authorization: bearer|paperclip_api_key|/api/issues/' "$phase4_trace"; then
echo "FAIL: forbidden control-plane context found"
exit 1
fi
```
## Step 4: Inspect canonical events and replay
```sh
jq '[.events[].eventType] | group_by(.) | map({type: .[0], count: length})' \
"$phase4_trace"
jq '.assertions' "$phase4_trace"
```
Confirm:
- session and turn lifecycle events exist;
- model, command/tool, usage, verification, result, and terminal events exist;
- `exactlyOneTerminalResult` is `true`;
- `liveReplayParity` is `true`;
- source sequence and item identity assertions are `true`.
The focused test suite supplies deterministic file-change and runtime-request
events because a real model may choose a shell command instead of a file patch
and may not need human input for this small task.
## Step 5: Try steering
Use a new directory:
```sh
phase4_steer_workspace="$(mktemp -d)"
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
--working-directory "$phase4_steer_workspace" \
--steer "Keep the answer short and verify the exact file text."
```
Confirm that the same driver and provider session IDs are printed and the task
still returns one result.
## Step 6: Try interruption
Use a new directory:
```sh
phase4_interrupt_workspace="$(mktemp -d)"
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
--working-directory "$phase4_interrupt_workspace" \
--interrupt
```
An interrupt can win before the file is created. That is expected. Confirm that
the trace still has one session identity, one semantic result, and one terminal
event. It must not create a replacement session.
## Step 7: Run the package verification path
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
This command is the package acceptance path. The real Codex example remains a
separate command because automated environments may not have a signed-in Codex
session.
## Step 8: Read the evidence
- [Phase 4 driver reference](../phase-04-skillless-codex-driver.md)
- [Phase 4 verification evidence](../../knowledge/evidence/2026-08-08-phase-04-verification.md)
- [Phase 4 journal](../../knowledge/journal/2026-08-08-phase-04.md)
Phase 5 is not part of this tutorial and remains uncreated.
@@ -0,0 +1,99 @@
---
type: Verification Evidence
title: Phase 4 skillless Codex driver verification
description: Package-only evidence for the direct Codex app-server driver, skillless context boundary, semantic completion, recovery, and replay.
tags: [native-runner, phase-4, codex, app-server, skillless, evidence]
status: stable
generated: { by: openai/gpt-5, at: 2026-08-08T00:00:00Z }
---
# Scope
This evidence covers the direct Codex app-server driver and package-local mock
core. It does not import or change Paperclip server, UI, database, CLI, or
production control-plane behavior.
# Required checks
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
pnpm --filter @paperclipai/paperclip-runner record:phase4
pnpm --filter @paperclipai/paperclip-runner verify
```
# Acceptance mapping
- Common driver conformance covers create, resume, read, turn, steer,
interrupt, usage, reconciliation, stable identities, and exactly one result.
- The exact real-session context records the Codex/app-server version, model,
provider, working directory, sandbox, approval policy, base instructions,
instruction sources and policy, environment key names, tools, input kinds,
and complete compact envelope.
- Automatic skill, app, and collaboration instructions are disabled. The
instruction-source list is empty, and the model receives no Paperclip API
instruction, bearer credential, unrelated skill, or inherited secret.
- Semantic finish and block tools normalize to one validated canonical result.
An identical duplicate is idempotent; a changed duplicate is rejected.
- Steering and interruption retain the same driver and provider session.
- Resume and reconciliation retain the persisted run, normalized session,
driver session, provider session, turn, item, and source cursor identities.
- Unsupported capabilities emit explicit redacted diagnostics without
driver-specific mock-core branches.
- All live events pass the existing Phase 1 validator/reducer. Replaying them
produces the same snapshot.
- The package boundary rejects imports from Paperclip core packages.
# Recorded outputs
- [Validated real Codex trace](phase-04-codex-trace.json)
- [Driver reference](../../docs/phase-04-skillless-codex-driver.md)
- [Hands-on tutorial](../../docs/tutorials/phase-04-skillless-codex.md)
The trace normalizes its temporary working directory and writable Codex state
root. It contains no credential values. This phase changes no browser surface,
so a browser screenshot is not applicable.
# Local verification result
- Codex CLI/app-server: `0.132.0`.
- Real model/provider: `gpt-5.5` / `openai`.
- Focused driver tests: 7 passed.
- Full TypeScript suite: 73 passed.
- Full Rust suite: 39 passed (37 unit and 2 process-supervisor tests).
- Existing browser suite: 9 passed with the rootless Linux library path.
- The real safe task created `hello.txt` with exactly `hello from phase 4`.
- The real trace contains 340 canonical events and all eight assertions pass:
exactly one terminal result, live/replay parity, stable run/session identity,
contiguous source sequence, stable item identity, skillless context,
unrelated-skill absence, and credential absence. The recorder separately
verifies the exact safe-file content.
- Real steering and interruption commands preserved the session identity and
ended with exactly one result and terminal event.
- TypeScript, Rust, and browser builds and typechecks passed. Golden, parity,
token, package-boundary, documentation-link, and OKF checks passed.
- The complete package `verify` path passed through `verify:rootless`.
# Failures found and fixed
- The first real session returned an app-server `400` because the strict JSON
Schema used `const` without its required `type`. The finish and block schemas
now use `type: "string"` with `const`, and tests lock the provider shape.
- The first sandboxed launch could not write Codex's local SQLite state. The
real-session proof uses the normal signed-in Codex environment; deterministic
conformance remains fully package-local and does not need that state.
- The installed Codex home contained an unrelated invalid skill. The driver
explicitly disables automatic skill instructions and verifies the returned
instruction-source list is empty instead of relying on the host setup.
- The normal package verifier reached Playwright but the host lacked
`libatk-1.0.so.0`. The supported rootless verifier supplied private browser
libraries, then completed the same `verify` path with all nine browser tests.
# Review state
- Security context/credential review: pending.
- CTO semantic-completion contract review: pending.
- QA hands-on tutorial run: pending.
- Human checkpoint: pending.
@@ -7,3 +7,4 @@
* [Phase 1 verification](2026-08-07-phase-01-verification.md) - Schema, golden, reducer, Rust parity, CLI, browser, token, boundary, and screenshot evidence.
* [Phase 2 verification](2026-08-07-phase-02-verification.md) - Rust supervisor, fake-driver scenarios, cleanup, live/replay parity, browser flows, screenshots, and complete package verification.
* [Phase 3 verification](2026-08-07-phase-03-verification.md) - Lost-ACK replay, restart recovery, command deduplication, storage, and redacted diagnostics.
* [Phase 4 verification](2026-08-08-phase-04-verification.md) - Direct Codex conformance, exact skillless context, semantic completion, recovery, and real-session replay.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,83 @@
---
type: Engineering Journal Entry
title: Phase 4 skillless Codex app-server driver
description: Decisions, evidence, failures, and fixes for the real Codex driver and model-context boundary.
tags: [native-runner, phase-4, codex, app-server, skillless, completion]
status: stable
generated: { by: openai/gpt-5, at: 2026-08-08T00:00:00Z }
entry_kind: phase
phase: "4"
---
# Context
Phase 4 connects the already deterministic mock core and reducer to a real
Codex app-server process. It proves the driver boundary without adding a
Paperclip production integration or browser surface.
# Decisions
1. Use app-server v2 JSON-RPC over local stdio. Keep its experimental network
transport out of the runner WAN contract.
2. Keep runner, normalized session, driver thread, provider session, turn,
item, and source-event identities separate and stable.
3. Resume only the exact persisted thread. Never replace a lost session with a
new one.
4. Use one compact `paperclip.skillless_task.v1` text envelope. Disable
automatic skill, app, collaboration, plugin, memory, and multi-agent
instruction blocks in thread config.
5. Pass an allowlisted child environment. Record key names, never values.
6. Register separate `paperclip_finish` and `paperclip_block` dynamic tools.
Normalize both into the same canonical result validator.
7. Commit the first valid semantic result. Treat the same later result as an
idempotent retry and reject any changed result.
8. Use app-server capabilities to implement or explicitly degrade read, steer,
interrupt, usage, structured output, dynamic tools, and reconciliation.
9. Normalize every provider notification through the existing PRP validator,
reducer, and replay path.
10. Keep the real example safe: workspace-write sandbox, no network request,
one allowed file, and exact output verification.
# Evidence
See the [Phase 4 verification evidence](../evidence/2026-08-08-phase-04-verification.md)
and [validated real trace](../evidence/phase-04-codex-trace.json).
Follow the [Phase 4 tutorial](../../docs/tutorials/phase-04-skillless-codex.md)
and read the [driver reference](../../docs/phase-04-skillless-codex-driver.md).
# Failures
- The first real structured-output request failed because the schema combined
`const` with no explicit JSON type. Adding `type: "string"` fixed the
app-server validation error; focused tests now assert the exact schema.
- A sandboxed app-server could read but not update the signed-in Codex SQLite
state. Real-session verification therefore runs with normal local Codex
access, while all deterministic driver tests use the in-process fake
transport.
- An unrelated broken skill was present in the host Codex home. Thread config
now disables automatic skill instructions, and the context assertion checks
both the policy flags and empty instruction-source list.
- The first redaction test stopped reading before the diagnostic event. It now
consumes the complete deterministic event sequence before asserting that the
secret is absent.
# Known gaps
- The reference driver is TypeScript while the production runner direction is
Rust. It proves the contract and real provider mapping, not the final daemon
implementation language.
- Real-model choices are nondeterministic. The focused fake-transport suite is
the conformance authority; the recorded real trace is a hands-on proof.
- The safe task may use a command instead of a file-change item. Deterministic
tests cover both file and request mappings.
- This package proof does not supply production MCP bindings, approval UI,
multi-tenant credentials, or control-plane status authority.
# Follow-up questions
- Which app-server versions should the production compatibility matrix retain?
- Should the runner persist provider item reconciliation cursors separately
from the normalized source-event cursor?
- Which final implementation owns the Rust equivalent of this reference
mapping after the package contract is accepted?
@@ -7,3 +7,4 @@
* [Phase 1 protocol, fixtures, reducer, and static replay](2026-08-07-phase-01.md) - Executable PRP schemas, deterministic replay decisions, browser findings, and checkpoint gaps.
* [Phase 2 local runner and fake harness](2026-08-07-phase-02.md) - Rust supervision, stdio protocol, scripted driver, live browser, cleanup, and parity decisions.
* [Phase 3 durable outbound transport and recovery](2026-08-07-phase-03.md) - WebSocket authentication, durable ACK replay, command deduplication, and recovery diagnostics.
* [Phase 4 skillless Codex app-server driver](2026-08-08-phase-04.md) - Direct app-server mapping, context isolation, semantic completion, real-model failures, and fixes.
@@ -7,3 +7,10 @@
* **Phase 1 static replay**: Added executable PRP v1 schemas, cross-language fixture/golden parity, deterministic reducer, CLI/browser replay, compatibility policy, tutorial, screenshot, and verification evidence.
* **Phase 2 local runner**: Added the deterministic Rust supervisor, stdio fake-harness protocol, scripted scenarios, bounded logs, cleanup and terminal guards, TypeScript mock core, validated live browser mode, screenshots, tutorial, and verification evidence.
* **Phase 3 durable recovery**: Added package-local WebSocket authentication, a durable outbox, cumulative acknowledgements, restart replay, command deduplication, recovery diagnostics, tutorial, and evidence.
## 2026-08-08
* **Phase 4 skillless Codex driver**: Added the direct app-server v2 driver,
compact task envelope, isolated model context, semantic completion tools,
canonical event/replay mapping, safe real-model example, tutorial, journal,
and exact verification trace.
+2
View File
@@ -45,6 +45,8 @@
"trace:phase2": "pnpm run build:typescript && pnpm run build:phase2-binaries && node dist/cli/phase2-mock-core.js",
"record:phase2": "pnpm run trace:phase2 -- --quiet --output knowledge/evidence/phase-02-happy-path.json",
"trace:phase3": "pnpm run build:typescript && pnpm run build:phase2-binaries && node dist/cli/phase3-recovery.js",
"trace:phase4": "pnpm run build:typescript && node dist/cli/phase4-codex.js",
"record:phase4": "pnpm run build:typescript && node scripts/record-phase4-evidence.mjs",
"diagnose:phase3": "pnpm run trace:phase3",
"record:phase3": "pnpm run build:typescript && pnpm run build:phase2-binaries && node scripts/record-phase3-evidence.mjs",
"record:phase3:lost-ack": "pnpm run trace:phase3 -- --fault lost-ack --output knowledge/evidence/phase-03-lost-ack.json",
@@ -0,0 +1,81 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const scratchRoot =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ??
process.env.PAPERCLIP_SCRATCH_DIR ??
tmpdir();
await mkdir(scratchRoot, { recursive: true });
const workspace = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-"));
const rawTracePath = join(workspace, "trace.raw.json");
const evidencePath = join(
packageRoot,
"knowledge/evidence/phase-04-codex-trace.json",
);
try {
const run = spawnSync(
process.execPath,
[
join(packageRoot, "dist/cli/phase4-codex.js"),
"--working-directory",
workspace,
"--output",
rawTracePath,
],
{ cwd: packageRoot, env: process.env, stdio: "inherit" },
);
if (run.status !== 0) {
throw new Error(`Phase 4 tracer exited with status ${run.status ?? "unknown"}`);
}
const trace = JSON.parse(await readFile(rawTracePath, "utf8"));
if (Object.values(trace.assertions ?? {}).some((passed) => passed !== true)) {
throw new Error("Phase 4 tracer reported a failed assertion");
}
const hello = await readFile(join(workspace, "hello.txt"), "utf8");
if (hello !== "hello from phase 4") {
throw new Error("Phase 4 safe task produced unexpected hello.txt content");
}
const replacements = [[workspace, "$PHASE4_WORKSPACE"]];
const writableRoots = trace.context?.sandbox?.writableRoots;
if (Array.isArray(writableRoots)) {
writableRoots.forEach((root, index) => {
if (typeof root === "string" && root.length > 0) {
replacements.push([root, `$CODEX_WRITABLE_ROOT_${index + 1}`]);
}
});
}
const normalized = normalizeStrings(trace, replacements);
await writeFile(evidencePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
process.stdout.write(`Recorded ${evidencePath}\n`);
} finally {
await rm(workspace, { recursive: true, force: true });
}
function normalizeStrings(value, replacements) {
if (typeof value === "string") {
return replacements.reduce(
(current, [from, to]) => current.replaceAll(from, to),
value,
);
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeStrings(entry, replacements));
}
if (typeof value === "object" && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
normalizeStrings(entry, replacements),
]),
);
}
return value;
}
@@ -6,7 +6,9 @@ This document is the implementation-phase plan for [`paperclip-native-runner-spi
All implementation starts inside `packages/paperclip-runner/` with protocol and mock-control-plane layers. These standalone phases do not modify Paperclip control-plane semantics. A real Paperclip bridge is a late, separately reviewed phase after the standalone contract passes conformance tests.
Phase 0, Phase 1, and Phase 2 are complete. The Phase 3 tracer is implemented and awaits Security, UX, QA, and human acceptance. Keep Phase 4 and later issues deferred until the Phase 3 human checkpoint is complete.
Phases 0 through 3 are complete and accepted. The authorized Phase 4 tracer is
implemented and awaits Security, CTO, QA, and human acceptance. Keep Phase 5
and later issues uncreated until the Phase 4 human checkpoint is complete.
## Non-negotiable delivery rules
@@ -217,6 +219,15 @@ to the package index and cumulative end-to-end tutorial.
**Tests/evidence:** common driver conformance, model-context snapshot, credential absence test, interruption without session loss, duplicate completion idempotency, resume/reconciliation test.
**Implementation evidence (2026-08-08):** the package now contains the direct
app-server v2 driver, compact envelope, separate finish/block tools, safe real
Codex example, canonical reducer/replay trace, capability degradation, focused
conformance suite, reference documentation, Simplified English tutorial, and
OKF evidence. The exact context snapshot proves automatic skill, app, and
collaboration instructions are disabled and the allowlisted child environment
contains no Paperclip or OpenAI bearer credential. Browser screenshots are not
applicable because Phase 4 changes no browser surface.
**Owners/review:** CodexCoder implementation; SecurityEngineer credential/context review; QA real-harness tutorial; CTO reviews semantic completion and status-authority boundary.
**Depends on:** Phase 3.
@@ -314,8 +325,8 @@ PAP-16717 Plan and phase governance
├─ Phase 0 Boundary/journal/skeleton create now
├─ Phase 1 Protocol/fixtures/replay create now; blocks on Phase 0
├─ Phase 2 Local runner/fake harness deferred; blocks on Phase 1
├─ Phase 3 Durable transport/recovery deferred; blocks on Phase 2
├─ Phase 4 Skillless Codex driver deferred; blocks on Phase 3
├─ Phase 3 Durable transport/recovery complete
├─ Phase 4 Skillless Codex driver implemented; review pending
├─ Phase 5 Browser SDK/reference console deferred; blocks on Phase 3
├─ Phase 6 Paperclip integration adapter deferred; blocks on Phases 4 and 5 + board gate
└─ Phase 7 Portability/release reference kit deferred; blocks on Phase 6 for final closure
@@ -2198,9 +2198,11 @@ Do not depend on its experimental network WebSocket as the Paperclip WAN transpo
3. Send protocol initialization.
4. Confirm server capabilities/version.
5. Register client-defined semantic tools if configured.
6. Create or resume thread.
7. emit `session.started` or `session.resumed`.
8. Persist thread/session parameters in the normalized session binding.
6. Disable automatic skill, app, collaboration, plugin, memory, and
multi-agent instruction injection in thread config.
7. Create or resume thread.
8. emit `session.started` or `session.resumed`.
9. Persist thread/session parameters in the normalized session binding.
### 14.4 Event mapping
@@ -2247,11 +2249,18 @@ Race behavior:
Preferred order:
1. app-server structured output schema for terminal run result, when supported;
2. client-defined `paperclip.finish` dynamic tool;
2. client-defined `paperclip_finish` and `paperclip_block` dynamic tools;
3. invalid/no result becomes `needs_review`, never inferred `done`.
The task envelope tells the model the expected result shape but does not teach Paperclip API mechanics.
Every provider-facing object schema is strict and every constant field declares
both its JSON `type` and `const`. The finish tool accepts only `done` and
`needs_review`; the block tool accepts only `blocked` with a typed blocker
owner/action/reason/scope. Both normalize through one canonical
`paperclip.run_result.v1` validator. The first valid result is committed,
identical repeats are idempotent, and changed repeats are rejected.
### 14.8 Reconciliation
After runner or control-plane reconnect:
@@ -6575,7 +6584,7 @@ Never change an active run from native to legacy mid-session.
The implementation phases, dependency policy, package-local documentation layout, human checkpoints, and retained future backlog now live in [`paperclip-native-runner-implementation-plan.md`](./paperclip-native-runner-implementation-plan.md).
The spike specification remains the normative contract. Keep it updated when implementation evidence changes architecture, protocol, security, persistence, API, UI, performance, or exit criteria. Keep sequencing and task-creation details in the implementation plan. Phase 0, Phase 1, Phase 2, and Phase 3 are complete, including the Phase 3 implementation, security, UX, QA, documentation, tutorial, journal, and human checkpoint gates. On 2026-08-08, the board authorized Phase 4. Phase 4 must implement the skillless Codex reference driver entirely inside `packages/paperclip-runner/` against the mock core, with no changes or imports from `server/`, `ui/`, `packages/db/`, or production control-plane behavior. Keep Phase 5 and later tasks deferred until the Phase 4 implementation, security review, CTO contract review, QA tutorial evidence, package-local documentation, OKF journal entry, and human checkpoint are complete.
The spike specification remains the normative contract. Keep it updated when implementation evidence changes architecture, protocol, security, persistence, API, UI, performance, or exit criteria. Keep sequencing and task-creation details in the implementation plan. Phase 0, Phase 1, Phase 2, and Phase 3 are complete, including the Phase 3 implementation, security, UX, QA, documentation, tutorial, journal, and human checkpoint gates. On 2026-08-08, the board authorized Phase 4. The package-local Phase 4 driver, conformance suite, safe real-model tracer, documentation, tutorial, and OKF evidence are implemented without changes or imports from `server/`, `ui/`, `packages/db/`, or production control-plane behavior. Security review, CTO contract review, QA tutorial evidence, and the human checkpoint remain pending. Keep Phase 5 and later tasks uncreated until those gates complete.
## 30. Dependency sequencing
@@ -0,0 +1,91 @@
#!/usr/bin/env node
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createPhase4TaskEnvelope } from "../contracts/phase4.js";
import { CodexAppServerDriver } from "../drivers/codex/codex-app-server-driver.js";
import { runPhase4CodexTracer } from "../mock-core/phase4-codex-runner.js";
interface CliOptions {
workingDirectory?: string;
output?: string;
steer?: string;
interrupt: boolean;
json: boolean;
}
function parseArgs(args: string[]): CliOptions {
const options: CliOptions = { interrupt: false, json: false };
for (let index = 0; index < args.length; index += 1) {
const value = args[index];
if (value === "--") continue;
if (value === "--json") options.json = true;
else if (value === "--interrupt") options.interrupt = true;
else if (value === "--working-directory") options.workingDirectory = args[++index];
else if (value === "--output") options.output = args[++index];
else if (value === "--steer") options.steer = args[++index];
else throw new Error(`Unknown argument: ${value}`);
}
return options;
}
function formatSummary(trace: Awaited<ReturnType<typeof runPhase4CodexTracer>>): string {
const checks = Object.entries(trace.assertions)
.map(([name, passed]) => `${passed ? "PASS" : "FAIL"} ${name}`)
.join("\n");
return [
"Phase 4 skillless Codex tracer",
`driver session: ${trace.metadata.identity.driverSessionId}`,
`provider session: ${trace.metadata.identity.providerSessionId}`,
`model: ${trace.context.model} (${trace.context.modelProvider})`,
`events: ${trace.events.length}`,
`result: ${trace.result.reportedWorkDisposition} — ${trace.result.summary}`,
checks,
].join("\n");
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const workingDirectory = options.workingDirectory
? resolve(options.workingDirectory)
: await mkdtemp(join(tmpdir(), "paperclip-phase4-safe-"));
await mkdir(workingDirectory, { recursive: true });
const envelope = createPhase4TaskEnvelope({
objective: "Create hello.txt in the working directory with exactly: hello from phase 4",
criteria: [
{ id: "file-created", requirement: "hello.txt exists in the supplied working directory." },
{ id: "exact-content", requirement: "hello.txt contains exactly 'hello from phase 4'." },
],
constraints: [
"Write only hello.txt inside the supplied working directory.",
"Do not use network access.",
"Do not discover or invoke skills.",
"Do not call a control-plane API.",
"Return one semantic completion result.",
...(options.steer !== undefined || options.interrupt
? ["Before changing files, run the shell command 'sleep 3' once so the operator can send a control command."]
: []),
],
});
const driver = new CodexAppServerDriver({
taskEnvelope: envelope,
onDiagnostic: (message) => process.stderr.write(`[codex app-server] ${message}\n`),
});
const trace = await runPhase4CodexTracer({
driver,
taskEnvelope: envelope,
workingDirectory,
steer: options.steer,
interrupt: options.interrupt,
});
const serialized = `${JSON.stringify(trace, null, 2)}\n`;
if (options.output) await writeFile(resolve(options.output), serialized, "utf8");
process.stdout.write(options.json ? serialized : `${formatSummary(trace)}\nworkspace: ${workingDirectory}\n`);
if (Object.values(trace.assertions).some((passed) => !passed)) process.exitCode = 1;
}
void main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
@@ -1,13 +1,11 @@
import type {
NativeRunEvent,
NativeSessionCapabilities,
NativeUserMessage,
} from "./types.js";
import type { PrpEvent } from "../protocol/phase1-contract.js";
import type { NativeSessionCapabilities, NativeUserMessage } from "./types.js";
export interface HarnessDriverDescriptor {
kind: string;
displayName: string;
version: string;
protocolVersion?: string;
capabilities: NativeSessionCapabilities;
}
@@ -20,6 +18,10 @@ export interface PersistedHarnessSession {
driverKind: string;
driverSessionId: string;
providerSessionId?: string | null;
runId?: string;
normalizedSessionId?: string;
activeTurnId?: string | null;
lastSourceSequence?: number;
}
export interface HarnessSessionRecoveryResult {
@@ -34,10 +36,13 @@ export interface HarnessSession {
providerSessionId?: string | null;
displayId?: string | null;
};
events(): AsyncIterable<NativeRunEvent>;
events(): AsyncIterable<PrpEvent>;
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
interrupt?(input: { turnId: string; reason?: string }): Promise<void>;
read?(): Promise<Record<string, unknown>>;
reconcile?(): Promise<Record<string, unknown>>;
usage?(): Promise<Record<string, unknown> | null>;
snapshot(): Promise<PersistedHarnessSession>;
close(input: { reason: string; force?: boolean }): Promise<void>;
}
@@ -0,0 +1,275 @@
import type {
PrpCapabilities,
PrpEvent,
PrpStructuredRunResult,
} from "../protocol/phase1-contract.js";
import type { SessionSnapshot } from "../reducer/session-reducer.js";
export const PHASE4_TASK_ENVELOPE_SCHEMA = "paperclip.skillless_task.v1" as const;
export const PHASE4_CODEX_PROTOCOL_VERSION = "v2" as const;
export const PHASE4_COMPLETION_TOOL_NAME = "paperclip_finish" as const;
export const PHASE4_BLOCK_TOOL_NAME = "paperclip_block" as const;
export const PHASE4_SEMANTIC_TOOL_NAMES = [
PHASE4_COMPLETION_TOOL_NAME,
PHASE4_BLOCK_TOOL_NAME,
] as const;
export const PHASE4_SKILLLESS_BASE_INSTRUCTIONS =
"Complete only the supplied task envelope. Do not discover or invoke skills. Do not call a control-plane API. Return exactly one semantic completion result; use paperclip_finish when the work is done or needs review, and paperclip_block only when work cannot continue." as const;
export interface Phase4TaskEnvelope {
schema: typeof PHASE4_TASK_ENVELOPE_SCHEMA;
objective: string;
completionContract: {
revision: string;
criteria: Array<{ id: string; requirement: string }>;
};
constraints: string[];
expectedResultSchema: "paperclip.run_result.v1";
}
export interface Phase4ModelContextSnapshot {
protocolVersion: typeof PHASE4_CODEX_PROTOCOL_VERSION;
codexVersion: string;
clientInfo: {
name: "paperclip-runner";
title: "Paperclip Runner";
version: string;
};
model: string;
modelProvider: string;
workingDirectory: string;
sandbox: unknown;
approvalPolicy: unknown;
baseInstructions: typeof PHASE4_SKILLLESS_BASE_INSTRUCTIONS;
instructionSources: string[];
instructionPolicy: {
skillInstructions: false;
appInstructions: false;
collaborationInstructions: false;
};
environmentKeys: string[];
dynamicToolNames: string[];
modelInputKinds: ["text"];
envelope: Phase4TaskEnvelope;
}
export interface Phase4RunMetadata {
schema: "paperclip.runner.phase4.metadata.v1";
fixtureName: string;
identity: {
schema: "paperclip.prp.identity.v1";
companyId: string;
issueId: string;
runId: string;
environmentLeaseId: string;
runnerInstanceId: string;
normalizedSessionId: string;
driverSessionId?: string;
providerSessionId?: string;
};
capabilities: PrpCapabilities;
}
export interface Phase4RunTrace {
schema: "paperclip.runner.phase4.trace.v1";
metadata: Phase4RunMetadata;
context: Phase4ModelContextSnapshot;
events: PrpEvent[];
result: PrpStructuredRunResult;
liveSnapshot: SessionSnapshot;
replaySnapshot: SessionSnapshot;
diagnostics: string[];
assertions: {
exactlyOneTerminalResult: boolean;
liveReplayParity: boolean;
stableIdentity: boolean;
sourceSequenceContinuous: boolean;
stableItemIdentity: boolean;
contextIsSkillless: boolean;
unrelatedSkillsAbsent: boolean;
credentialsAbsent: boolean;
};
}
export function createPhase4TaskEnvelope(input: {
objective: string;
contractRevision?: string;
criteria?: Array<{ id: string; requirement: string }>;
constraints?: string[];
}): Phase4TaskEnvelope {
return {
schema: PHASE4_TASK_ENVELOPE_SCHEMA,
objective: input.objective,
completionContract: {
revision: input.contractRevision ?? "phase4-demo-v1",
criteria:
input.criteria ?? [{ id: "objective", requirement: "Complete the objective safely." }],
},
constraints: input.constraints ?? [
"Work only inside the supplied working directory.",
"Do not discover or invoke skills.",
"Do not call a control-plane API.",
"Return one semantic completion result.",
],
expectedResultSchema: "paperclip.run_result.v1",
};
}
/**
* Strict provider-facing subset of the wider PRP result schema.
*
* OpenAI structured outputs require every object to reject additional
* properties. The accepted value is still validated against the canonical PRP
* schema before it is committed as a semantic result.
*/
const completionClaimSchema = {
type: "object",
additionalProperties: false,
required: ["contractRevision", "objectiveSatisfied", "criteria", "remainingWork"],
properties: {
contractRevision: { type: "string", minLength: 1 },
objectiveSatisfied: { type: "boolean" },
criteria: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["criterionId", "status", "evidenceRefs"],
properties: {
criterionId: { type: "string", minLength: 1 },
status: { enum: ["satisfied", "not_satisfied", "unknown"] },
evidenceRefs: { type: "array", items: { type: "string" } },
},
},
},
remainingWork: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["description", "blocksCompletion"],
properties: {
description: { type: "string", minLength: 1 },
blocksCompletion: { type: "boolean" },
},
},
},
},
} as const;
const evidenceSchema = {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["ref"],
properties: { ref: { type: "string", minLength: 1 } },
},
} as const;
const verificationSchema = {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["commandOrCheck", "status"],
properties: {
commandOrCheck: { type: "string", minLength: 1 },
status: { enum: ["passed", "failed", "not_run"] },
},
},
} as const;
const attentionRequestsSchema = {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["kind", "summary"],
properties: {
kind: { type: "string", minLength: 1 },
summary: { type: "string", minLength: 1 },
},
},
} as const;
const artifactsSchema = {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["kind", "ref"],
properties: {
kind: { type: "string", minLength: 1 },
ref: { type: "string", minLength: 1 },
},
},
} as const;
const commonResultProperties = {
schema: { type: "string", const: "paperclip.run_result.v1" },
summary: { type: "string", minLength: 1 },
completionClaim: completionClaimSchema,
evidence: evidenceSchema,
verification: verificationSchema,
attentionRequests: attentionRequestsSchema,
artifacts: artifactsSchema,
} as const;
export const PHASE4_RESULT_OUTPUT_SCHEMA = {
type: "object",
additionalProperties: false,
required: [
"schema",
"reportedWorkDisposition",
"summary",
"completionClaim",
"evidence",
"verification",
"attentionRequests",
"artifacts",
],
properties: {
...commonResultProperties,
reportedWorkDisposition: { enum: ["done", "needs_review"] },
},
} as const;
export const PHASE4_BLOCK_RESULT_OUTPUT_SCHEMA = {
type: "object",
additionalProperties: false,
required: [
"schema",
"reportedWorkDisposition",
"summary",
"completionClaim",
"evidence",
"verification",
"attentionRequests",
"artifacts",
"blocker",
],
properties: {
...commonResultProperties,
reportedWorkDisposition: { type: "string", const: "blocked" },
blocker: {
type: "object",
additionalProperties: false,
required: ["reasonCode", "owner", "unblockAction", "scope"],
properties: {
reasonCode: { type: "string", minLength: 1 },
owner: {
type: "object",
additionalProperties: false,
required: ["kind", "name"],
properties: {
kind: { enum: ["agent", "user", "system", "external"] },
name: { type: "string", minLength: 1 },
},
},
unblockAction: { type: "string", minLength: 1 },
scope: { enum: ["current_track", "task_wide"] },
},
},
},
} as const;
@@ -29,6 +29,11 @@ export interface NativeSessionCapabilities {
steering: boolean;
interruption: boolean;
structuredResult: boolean;
read?: boolean;
reconciliation?: boolean;
usage?: boolean;
dynamicTools?: boolean;
unsupported?: string[];
}
export interface NativeUserMessage {
@@ -0,0 +1,242 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
export interface CodexRpcNotification {
method: string;
params: Record<string, unknown>;
}
export interface CodexRpcServerRequest extends CodexRpcNotification {
id: string | number;
}
export type CodexServerRequestHandler = (
request: CodexRpcServerRequest,
) => Promise<Record<string, unknown>>;
export interface CodexAppServerTransport {
request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
notify(method: string, params?: Record<string, unknown>): void;
notifications(): AsyncIterable<CodexRpcNotification>;
setServerRequestHandler(handler: CodexServerRequestHandler): void;
close(): Promise<void>;
}
class AsyncQueue<T> implements AsyncIterable<T> {
#values: T[] = [];
#waiters: Array<(value: IteratorResult<T>) => void> = [];
#closed = false;
push(value: T): void {
if (this.#closed) return;
const waiter = this.#waiters.shift();
if (waiter === undefined) this.#values.push(value);
else waiter({ value, done: false });
}
close(): void {
this.#closed = true;
for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: async () => {
const value = this.#values.shift();
if (value !== undefined) return { value, done: false };
if (this.#closed) return { value: undefined, done: true };
return new Promise<IteratorResult<T>>((resolve) => this.#waiters.push(resolve));
},
};
}
}
const SAFE_ENVIRONMENT_KEYS = [
"ALL_PROXY",
"CODEX_HOME",
"HOME",
"HTTP_PROXY",
"HTTPS_PROXY",
"LANG",
"LC_ALL",
"NO_PROXY",
"NODE_EXTRA_CA_CERTS",
"PATH",
"PATHEXT",
"SSL_CERT_FILE",
"SystemRoot",
"TEMP",
"TMP",
"TMPDIR",
"WINDIR",
] as const;
export function createSanitizedCodexEnvironment(
source: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = {};
for (const key of SAFE_ENVIRONMENT_KEYS) {
const value = source[key];
if (value === undefined) continue;
if (key.includes("PROXY") && proxyContainsCredentials(value)) continue;
environment[key] = value;
}
return environment;
}
export function sanitizedEnvironmentKeys(source: NodeJS.ProcessEnv = process.env): string[] {
return Object.keys(createSanitizedCodexEnvironment(source)).sort();
}
export function redactCodexDiagnostic(message: string): string {
return message
.replaceAll(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, "Bearer [REDACTED]")
.replace(/Basic\s+[A-Za-z0-9+/=]+/gi, "Basic [REDACTED]")
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1[REDACTED]@")
.replace(/([?&](?:api[_-]?key|token|secret|password)=)[^&#\s]+/gi, "$1[REDACTED]")
.replace(/(["'](?:api[_-]?key|token|secret|password|authorization)["']\s*:\s*["'])[^"']+/gi, "$1[REDACTED]")
.replace(/(api[_-]?key|token|secret|password)\s*[=:]\s*[^\s,;]+/gi, "$1=[REDACTED]")
.replace(/(PAPERCLIP_API_KEY|OPENAI_API_KEY)=[^\s]+/g, "$1=[REDACTED]");
}
function proxyContainsCredentials(value: string): boolean {
try {
const url = new URL(value);
return url.username.length > 0 || url.password.length > 0;
} catch {
return true;
}
}
interface PendingRequest {
resolve: (value: Record<string, unknown>) => void;
reject: (error: Error) => void;
}
export interface ProcessCodexTransportOptions {
command?: string;
args?: string[];
environment?: NodeJS.ProcessEnv;
onDiagnostic?: (message: string) => void;
}
/** Local stdio JSON-RPC transport. App-server never becomes a WAN endpoint. */
export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
#process: ChildProcessWithoutNullStreams;
#notifications = new AsyncQueue<CodexRpcNotification>();
#pending = new Map<number, PendingRequest>();
#nextId = 1;
#serverRequestHandler: CodexServerRequestHandler = async () => ({
success: false,
contentItems: [{ type: "input_text", text: "Unsupported client request." }],
});
#closed = false;
#onDiagnostic?: (message: string) => void;
constructor(options: ProcessCodexTransportOptions = {}) {
this.#onDiagnostic = options.onDiagnostic;
this.#process = spawn(options.command ?? "codex", options.args ?? ["app-server"], {
env: options.environment ?? createSanitizedCodexEnvironment(),
stdio: "pipe",
});
createInterface({ input: this.#process.stdout }).on("line", (line) => this.#onLine(line));
createInterface({ input: this.#process.stderr }).on("line", (line) => {
this.#onDiagnostic?.(redactCodexDiagnostic(line));
});
this.#process.on("error", (error) => this.#failAll(error));
this.#process.on("exit", (code, signal) => {
if (!this.#closed) {
this.#failAll(new Error(`codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "none"})`));
}
this.#notifications.close();
});
}
request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
const id = this.#nextId++;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
try {
this.#write({ method, id, params });
} catch (error) {
this.#pending.delete(id);
reject(error);
}
});
}
notify(method: string, params?: Record<string, unknown>): void {
this.#write(params === undefined ? { method } : { method, params });
}
notifications(): AsyncIterable<CodexRpcNotification> {
return this.#notifications;
}
setServerRequestHandler(handler: CodexServerRequestHandler): void {
this.#serverRequestHandler = handler;
}
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#process.kill("SIGTERM");
this.#notifications.close();
this.#failAll(new Error("codex app-server transport closed"));
}
#write(message: unknown): void {
if (this.#closed) throw new Error("codex app-server transport is closed");
this.#process.stdin.write(`${JSON.stringify(message)}\n`);
}
#onLine(line: string): void {
let value: unknown;
try {
value = JSON.parse(line);
} catch {
this.#onDiagnostic?.("codex app-server emitted malformed JSON");
return;
}
if (typeof value !== "object" || value === null) return;
const message = value as Record<string, unknown>;
if (typeof message.id === "number" && ("result" in message || "error" in message)) {
const pending = this.#pending.get(message.id);
if (pending === undefined) return;
this.#pending.delete(message.id);
if (message.error !== undefined) {
pending.reject(new Error(redactCodexDiagnostic(JSON.stringify(message.error))));
} else {
pending.resolve(asRecord(message.result));
}
return;
}
if (typeof message.method !== "string") return;
const params = asRecord(message.params);
if ((typeof message.id === "string" || typeof message.id === "number") && "id" in message) {
const request: CodexRpcServerRequest = { id: message.id, method: message.method, params };
void this.#serverRequestHandler(request).then(
(result) => this.#write({ id: request.id, result }),
(error: unknown) =>
this.#write({
id: request.id,
error: { code: -32_000, message: redactCodexDiagnostic(String(error)) },
}),
);
return;
}
this.#notifications.push({ method: message.method, params });
}
#failAll(error: Error): void {
for (const pending of this.#pending.values()) pending.reject(error);
this.#pending.clear();
}
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
@@ -0,0 +1,371 @@
import { describe, expect, it } from "vitest";
import {
PHASE4_BLOCK_RESULT_OUTPUT_SCHEMA,
PHASE4_RESULT_OUTPUT_SCHEMA,
createPhase4TaskEnvelope,
} from "../../contracts/phase4.js";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
} from "../../reducer/session-reducer.js";
import {
validatePrpEvent,
type PrpCapabilities,
type PrpEvent,
type PrpStructuredRunResult,
} from "../../protocol/phase1-contract.js";
import {
CodexAppServerDriver,
UnsupportedCodexOperationError,
isSkilllessContext,
} from "./codex-app-server-driver.js";
import type {
CodexAppServerTransport,
CodexRpcNotification,
CodexRpcServerRequest,
CodexServerRequestHandler,
} from "./app-server-transport.js";
class TestQueue<T> implements AsyncIterable<T> {
values: T[] = [];
waiters: Array<(value: IteratorResult<T>) => void> = [];
closed = false;
push(value: T): void {
const waiter = this.waiters.shift();
if (waiter) waiter({ value, done: false });
else this.values.push(value);
}
close(): void {
this.closed = true;
for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true });
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: async () => {
const value = this.values.shift();
if (value) return { value, done: false };
if (this.closed) return { value: undefined, done: true };
return new Promise((resolve) => this.waiters.push(resolve));
},
};
}
}
class FakeCodexTransport implements CodexAppServerTransport {
readonly calls: Array<{ method: string; params: Record<string, unknown> }> = [];
readonly sentNotifications: Array<{ method: string; params?: Record<string, unknown> }> = [];
readonly queue = new TestQueue<CodexRpcNotification>();
handler: CodexServerRequestHandler = async () => ({});
rejectMethods = new Map<string, Error>();
constructor(
readonly threadId = "thread-1",
readonly providerSessionId = "provider-session-1",
) {}
async request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
this.calls.push({ method, params: structuredClone(params) });
const rejection = this.rejectMethods.get(method);
if (rejection) throw rejection;
if (method === "initialize") {
return { userAgent: "codex-cli/0.132.0", codexHome: "/isolated/codex", platformFamily: "unix", platformOs: "linux" };
}
if (method === "thread/start" || method === "thread/resume") {
return {
thread: { id: this.threadId, sessionId: this.providerSessionId, modelProvider: "openai", cwd: "/workspace", turns: [] },
model: "gpt-test",
modelProvider: "openai",
cwd: "/workspace",
sandbox: { type: "workspaceWrite" },
approvalPolicy: "never",
instructionSources: [],
};
}
if (method === "turn/start") return { turn: { id: "turn-1", status: "inProgress", items: [] } };
if (method === "thread/read") {
return { thread: { id: this.threadId, sessionId: this.providerSessionId, turns: [{ id: "turn-1", status: "inProgress", items: [] }] } };
}
return {};
}
notify(method: string, params?: Record<string, unknown>): void {
this.sentNotifications.push(params === undefined ? { method } : { method, params });
}
notifications(): AsyncIterable<CodexRpcNotification> {
return this.queue;
}
setServerRequestHandler(handler: CodexServerRequestHandler): void {
this.handler = handler;
}
async close(): Promise<void> {
this.queue.close();
}
push(method: string, params: Record<string, unknown>): void {
this.queue.push({ method, params });
}
invoke(request: CodexRpcServerRequest): Promise<Record<string, unknown>> {
return this.handler(request);
}
}
const envelope = createPhase4TaskEnvelope({
objective: "Create hello.txt with the text hello.",
criteria: [{ id: "file", requirement: "hello.txt contains hello" }],
});
const result: PrpStructuredRunResult = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "done",
summary: "Created hello.txt.",
completionClaim: {
contractRevision: "phase4-demo-v1",
objectiveSatisfied: true,
criteria: [{ criterionId: "file", status: "satisfied", evidenceRefs: ["hello.txt"] }],
remainingWork: [],
},
evidence: [{ ref: "hello.txt" }],
verification: [{ commandOrCheck: "read hello.txt", status: "passed" }],
attentionRequests: [],
artifacts: [{ kind: "file", ref: "hello.txt" }],
};
function makeDriver(transports: FakeCodexTransport[], options: Record<string, unknown> = {}) {
let index = 0;
return new CodexAppServerDriver({
taskEnvelope: envelope,
environment: {
PATH: "/bin",
HOME: "/isolated/home",
CODEX_HOME: "/isolated/codex",
LANG: "C.UTF-8",
PAPERCLIP_API_KEY: "must-not-pass",
RANDOM_SKILL_PATH: "/skills/unrelated",
},
now: () => new Date("2026-08-08T12:00:00.000Z"),
transportFactory: () => transports[index++]!,
...options,
});
}
async function collectUntilTerminal(events: AsyncIterable<PrpEvent>): Promise<PrpEvent[]> {
const collected: PrpEvent[] = [];
for await (const event of events) {
collected.push(event);
if (event.eventType === "run.terminal") break;
}
return collected;
}
describe("Codex app-server Phase 4 driver", () => {
it("passes the common typed-event contract and produces exactly one terminal result", async () => {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport]);
const descriptor = await driver.descriptor();
const session = await driver.openSession({ runId: "run-1", workingDirectory: "/workspace" });
const turn = await session.startTurn({ message: { role: "user", text: "Do the safe task." } });
transport.push("turn/started", { threadId: "thread-1", turn: { id: turn.turnId, status: "inProgress" } });
transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "cmd-1", type: "commandExecution", command: "printf hello" } });
transport.push("item/commandExecution/outputDelta", { threadId: "thread-1", turnId: turn.turnId, itemId: "cmd-1", delta: "hello" });
transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "file-1", type: "fileChange", changes: [{ path: "hello.txt" }] } });
expect(await transport.invoke({
id: "request-1",
method: "item/tool/requestUserInput",
params: { threadId: "thread-1", turnId: turn.turnId, itemId: "question-1" },
})).toEqual({ answers: {} });
transport.push("thread/tokenUsage/updated", { threadId: "thread-1", turnId: turn.turnId, tokenUsage: { total: { inputTokens: 10, outputTokens: 4 }, modelContextWindow: 128000 } });
transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "answer-1", type: "agentMessage", text: JSON.stringify(result) } });
transport.push("turn/completed", { threadId: "thread-1", turn: { id: turn.turnId, status: "completed", items: [] } });
const events = await collectUntilTerminal(session.events());
expect(events.every((event) => validatePrpEvent(event).ok)).toBe(true);
expect(events.filter((event) => event.eventType === "run.result.proposed")).toHaveLength(1);
expect(events.filter((event) => event.eventType === "run.terminal")).toHaveLength(1);
expect(events.map((event) => event.eventType)).toEqual(expect.arrayContaining([
"session.started", "turn.started", "item.started", "item.delta", "item.completed", "run.result.proposed", "run.terminal",
"runtime_request.created", "runtime_request.resolved",
]));
const capabilities: PrpCapabilities = {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "reuse_per_issue",
driver: { kind: descriptor.kind, version: descriptor.version },
steer: true,
interrupt: true,
resume: true,
runtimeRequests: true,
structuredResult: true,
typedEvents: true,
};
const metadata = {
fixtureName: "phase4-conformance",
identity: { schema: "paperclip.prp.identity.v1" as const, companyId: "company-1", issueId: "issue-1", runId: "run-1", environmentLeaseId: "lease-1", runnerInstanceId: "runner-phase4", normalizedSessionId: "session:run-1" },
capabilities,
};
const live = events.reduce(applyPrpEvent, createSessionSnapshotFromMetadata(metadata));
const replay = events.reduce(applyPrpEvent, createSessionSnapshotFromMetadata(metadata));
expect(live).toEqual(replay);
expect(live.integrity).toBe("complete");
expect(await session.usage?.()).toMatchObject({ modelContextWindow: 128000 });
});
it("captures an exact skillless model/environment snapshot with credentials absent", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({ runId: "run-context", workingDirectory: "/workspace" });
const iterator = session.events()[Symbol.asyncIterator]();
const first = await iterator.next();
const context = first.value?.payload.context as Parameters<typeof isSkilllessContext>[0];
expect(context).toMatchObject({
codexVersion: "codex-cli/0.132.0",
model: "gpt-test",
modelProvider: "openai",
workingDirectory: "/workspace",
approvalPolicy: "never",
instructionSources: [],
instructionPolicy: {
skillInstructions: false,
appInstructions: false,
collaborationInstructions: false,
},
environmentKeys: ["CODEX_HOME", "HOME", "LANG", "PATH"],
envelope,
});
expect(isSkilllessContext(context)).toBe(true);
expect(JSON.stringify(context)).not.toContain("must-not-pass");
expect(JSON.stringify(transport.calls)).not.toContain("RANDOM_SKILL_PATH");
expect(transport.calls.find((call) => call.method === "thread/start")?.params).toMatchObject({
approvalPolicy: "never",
config: {
"skills.include_instructions": false,
include_apps_instructions: false,
include_collaboration_mode_instructions: false,
},
dynamicTools: [{ name: "paperclip_finish" }, { name: "paperclip_block" }],
});
expect(PHASE4_RESULT_OUTPUT_SCHEMA.properties.schema).toEqual({
type: "string",
const: "paperclip.run_result.v1",
});
expect(PHASE4_BLOCK_RESULT_OUTPUT_SCHEMA.properties.reportedWorkDisposition).toEqual({
type: "string",
const: "blocked",
});
});
it("steers and interrupts an active turn without replacing the session", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({ runId: "run-controls", workingDirectory: "/workspace" });
const { turnId } = await session.startTurn({ message: { role: "user", text: "Start." } });
await session.steer?.({ turnId, message: { role: "user", text: "Use a shorter answer." } });
await session.interrupt?.({ turnId, reason: "operator requested" });
expect(transport.calls.map((call) => call.method)).toEqual([
"initialize", "thread/start", "turn/start", "turn/steer", "turn/interrupt",
]);
expect(session.ids()).toEqual({ driverSessionId: "thread-1", providerSessionId: "provider-session-1", displayId: "thread-1" });
});
it("makes duplicate semantic completion idempotent and rejects changed payloads", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({ runId: "run-result", workingDirectory: "/workspace" });
await session.startTurn({ message: { role: "user", text: "Complete." } });
const request = { id: 1, method: "item/tool/call", params: { threadId: "thread-1", turnId: "turn-1", callId: "call-1", tool: "paperclip_finish", arguments: result } };
expect(await transport.invoke(request)).toMatchObject({ success: true });
expect(await transport.invoke({ ...request, id: 2 })).toMatchObject({ success: true });
const changed = structuredClone(result);
changed.summary = "Changed after commit.";
expect(await transport.invoke({ ...request, id: 3, params: { ...request.params, arguments: changed } })).toMatchObject({ success: false });
transport.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } });
const events = await collectUntilTerminal(session.events());
expect(events.filter((event) => event.eventType === "run.result.proposed")).toHaveLength(1);
});
it("normalizes finish and block tools into one canonical result contract", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({ runId: "run-tools", workingDirectory: "/workspace" });
await session.startTurn({ message: { role: "user", text: "Complete or block." } });
const blocked: PrpStructuredRunResult = {
...structuredClone(result),
reportedWorkDisposition: "blocked",
summary: "Waiting on a fixture owner.",
completionClaim: {
...structuredClone(result.completionClaim),
objectiveSatisfied: false,
criteria: [{ criterionId: "file", status: "not_satisfied", evidenceRefs: [] }],
remainingWork: [{ description: "Fixture owner must provide input.", blocksCompletion: true }],
},
blocker: {
reasonCode: "fixture_input_missing",
owner: { kind: "external", name: "fixture owner" },
unblockAction: "Provide the fixture input.",
scope: "task_wide",
},
artifacts: [],
};
const request = {
id: 10,
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-block",
tool: "paperclip_block",
arguments: blocked,
},
};
expect(await transport.invoke({
...request,
id: 9,
params: { ...request.params, tool: "paperclip_finish" },
})).toMatchObject({ success: false });
expect(await transport.invoke(request)).toMatchObject({ success: true });
expect(await transport.invoke({ ...request, id: 11 })).toMatchObject({ success: true });
transport.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } });
const events = await collectUntilTerminal(session.events());
expect(events.filter((event) => event.eventType === "run.result.proposed")).toHaveLength(1);
expect(events.find((event) => event.eventType === "run.result.proposed")?.payload)
.toMatchObject({ reportedWorkDisposition: "blocked" });
});
it("resumes and reconciles the exact provider thread after transport loss", async () => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const driver = makeDriver([first, second]);
const original = await driver.openSession({ runId: "run-recover", workingDirectory: "/workspace" });
await original.startTurn({ message: { role: "user", text: "Work." } });
const snapshot = await original.snapshot();
await original.close({ reason: "transport lost" });
const recovery = await driver.recoverSession?.(snapshot);
expect(recovery).toMatchObject({ recovered: true });
expect(recovery?.session?.ids()).toEqual(original.ids());
expect(await recovery?.session?.reconcile?.()).toMatchObject({ thread: { id: "thread-1" } });
expect(second.calls.map((call) => call.method)).toEqual(["initialize", "thread/resume", "thread/read"]);
expect((await recovery?.session?.snapshot())?.activeTurnId).toBe("turn-1");
});
it("degrades unsupported operations with explicit redacted diagnostics", async () => {
const transport = new FakeCodexTransport();
transport.rejectMethods.set("turn/steer", new Error("Bearer super-secret api_key=also-secret"));
const session = await makeDriver([transport]).openSession({ runId: "run-degrade", workingDirectory: "/workspace" });
const { turnId } = await session.startTurn({ message: { role: "user", text: "Start." } });
await expect(session.steer?.({ turnId, message: { role: "user", text: "Steer." } })).rejects.toBeInstanceOf(UnsupportedCodexOperationError);
const iterator = session.events()[Symbol.asyncIterator]();
const events: PrpEvent[] = [];
for (let index = 0; index < 5; index += 1) {
const next = await iterator.next();
if (next.value) events.push(next.value);
}
const diagnostic = events.find((event) => event.eventType === "harness.diagnostic");
expect(diagnostic?.payload).toMatchObject({ code: "unsupported_operation", operation: "steering" });
expect(JSON.stringify(diagnostic)).not.toContain("super-secret");
expect(JSON.stringify(diagnostic)).not.toContain("also-secret");
});
});
@@ -0,0 +1,873 @@
import { randomUUID } from "node:crypto";
import type {
HarnessDriver,
HarnessDriverDescriptor,
HarnessSession,
HarnessSessionRecoveryResult,
OpenHarnessSessionInput,
PersistedHarnessSession,
} from "../../contracts/harness-driver.js";
import {
PHASE4_BLOCK_RESULT_OUTPUT_SCHEMA,
PHASE4_BLOCK_TOOL_NAME,
PHASE4_CODEX_PROTOCOL_VERSION,
PHASE4_COMPLETION_TOOL_NAME,
PHASE4_RESULT_OUTPUT_SCHEMA,
PHASE4_SEMANTIC_TOOL_NAMES,
PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
type Phase4ModelContextSnapshot,
type Phase4TaskEnvelope,
} from "../../contracts/phase4.js";
import {
validatePrpStructuredRunResult,
type PrpEvent,
type PrpStructuredRunResult,
} from "../../protocol/phase1-contract.js";
import type { NativeUserMessage } from "../../contracts/types.js";
import {
ProcessCodexAppServerTransport,
createSanitizedCodexEnvironment,
redactCodexDiagnostic,
sanitizedEnvironmentKeys,
type CodexAppServerTransport,
type CodexRpcNotification,
type CodexRpcServerRequest,
} from "./app-server-transport.js";
const DRIVER_KIND = "codex_app_server";
const DRIVER_VERSION = "phase4-v1";
class AsyncQueue<T> implements AsyncIterable<T> {
#values: T[] = [];
#waiters: Array<(value: IteratorResult<T>) => void> = [];
#closed = false;
push(value: T): void {
if (this.#closed) return;
const waiter = this.#waiters.shift();
if (waiter === undefined) this.#values.push(value);
else waiter({ value, done: false });
}
close(): void {
if (this.#closed) return;
this.#closed = true;
for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: async () => {
const value = this.#values.shift();
if (value !== undefined) return { value, done: false };
if (this.#closed) return { value: undefined, done: true };
return new Promise((resolve) => this.#waiters.push(resolve));
},
};
}
}
export class UnsupportedCodexOperationError extends Error {
readonly operation: string;
constructor(operation: string, detail: string) {
super(`${operation} is unsupported: ${redactCodexDiagnostic(detail)}`);
this.name = "UnsupportedCodexOperationError";
this.operation = operation;
}
}
export interface CodexAppServerDriverOptions {
taskEnvelope: Phase4TaskEnvelope;
transportFactory?: () => CodexAppServerTransport;
environment?: NodeJS.ProcessEnv;
now?: () => Date;
runnerInstanceId?: string;
onDiagnostic?: (message: string) => void;
capabilities?: Partial<{
resume: boolean;
read: boolean;
steering: boolean;
interruption: boolean;
usage: boolean;
reconciliation: boolean;
dynamicTools: boolean;
}>;
}
type CodexCapabilities = Required<
NonNullable<CodexAppServerDriverOptions["capabilities"]>
>;
interface OpenedCodexThread {
threadId: string;
providerSessionId: string;
context: Phase4ModelContextSnapshot;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function text(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function itemFromParams(params: Record<string, unknown>): Record<string, unknown> {
return record(params.item);
}
function itemText(item: Record<string, unknown>): string {
for (const key of ["text", "aggregatedOutput", "patch", "delta"]) {
if (typeof item[key] === "string") return item[key];
}
return "";
}
function userInput(message: NativeUserMessage): Record<string, unknown> {
return { type: "text", text: message.text, text_elements: [] };
}
function terminalState(status: string): "completed" | "failed" | "interrupted" {
if (status === "failed") return "failed";
if (status === "interrupted") return "interrupted";
return "completed";
}
function dispositionRunState(
result: PrpStructuredRunResult,
): "succeeded" | "failed" {
return result.reportedWorkDisposition === "done" ? "succeeded" : "failed";
}
function tryParseResult(value: unknown): PrpStructuredRunResult | null {
let candidate = value;
if (typeof value === "string") {
try {
candidate = JSON.parse(value);
} catch {
return null;
}
}
const validation = validatePrpStructuredRunResult(candidate);
return validation.ok ? validation.result : null;
}
function finishToolSpec(): Record<string, unknown> {
return {
name: PHASE4_COMPLETION_TOOL_NAME,
description: "Return the one semantic completion result for this task.",
inputSchema: PHASE4_RESULT_OUTPUT_SCHEMA,
};
}
function blockToolSpec(): Record<string, unknown> {
return {
name: PHASE4_BLOCK_TOOL_NAME,
description: "Return the one semantic result when the task cannot continue.",
inputSchema: PHASE4_BLOCK_RESULT_OUTPUT_SCHEMA,
};
}
const SKILLLESS_THREAD_CONFIG = {
"skills.include_instructions": false,
include_apps_instructions: false,
include_collaboration_mode_instructions: false,
"features.apps": false,
"features.plugins": false,
"features.multi_agent": false,
"features.memories": false,
} as const;
export class CodexAppServerDriver implements HarnessDriver {
readonly #options: CodexAppServerDriverOptions;
readonly #caps: CodexCapabilities;
constructor(options: CodexAppServerDriverOptions) {
this.#options = options;
this.#caps = {
resume: true,
read: true,
steering: true,
interruption: true,
usage: true,
reconciliation: true,
dynamicTools: true,
...options.capabilities,
};
if (!this.#caps.read) this.#caps.reconciliation = false;
}
async descriptor(): Promise<HarnessDriverDescriptor> {
const unsupported = Object.entries(this.#caps)
.filter(([, supported]) => !supported)
.map(([operation]) => operation);
return {
kind: DRIVER_KIND,
displayName: "Codex app-server",
version: DRIVER_VERSION,
protocolVersion: PHASE4_CODEX_PROTOCOL_VERSION,
capabilities: {
resume: this.#caps.resume,
typedEvents: true,
steering: this.#caps.steering,
interruption: this.#caps.interruption,
structuredResult: true,
read: this.#caps.read,
reconciliation: this.#caps.reconciliation,
usage: this.#caps.usage,
dynamicTools: this.#caps.dynamicTools,
unsupported,
},
};
}
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
const transport = this.#transport();
const initialize = await this.#initialize(transport);
const response = await transport.request("thread/start", {
cwd: input.workingDirectory,
approvalPolicy: "never",
sandbox: "workspace-write",
config: SKILLLESS_THREAD_CONFIG,
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
dynamicTools: this.#caps.dynamicTools ? [finishToolSpec(), blockToolSpec()] : [],
experimentalRawEvents: false,
persistExtendedHistory: false,
});
const opened = this.#openedThread(response, initialize, input.workingDirectory);
return this.#session({
transport,
runId: input.runId,
normalizedSessionId: `session:${input.runId}`,
opened,
resumed: false,
sourceSequence: 0,
});
}
async recoverSession(
snapshot: PersistedHarnessSession,
): Promise<HarnessSessionRecoveryResult> {
if (!this.#caps.resume) {
return { recovered: false, reason: "resume capability is unavailable" };
}
if (!snapshot.runId || !snapshot.normalizedSessionId || !snapshot.driverSessionId) {
return { recovered: false, reason: "persisted session identity is incomplete" };
}
const transport = this.#transport();
try {
const initialize = await this.#initialize(transport);
const response = await transport.request("thread/resume", {
threadId: snapshot.driverSessionId,
config: SKILLLESS_THREAD_CONFIG,
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
persistExtendedHistory: false,
});
const opened = this.#openedThread(response, initialize, text(record(response.thread).cwd));
if (opened.threadId !== snapshot.driverSessionId) {
await transport.close();
return { recovered: false, reason: "provider resumed a different session" };
}
return {
recovered: true,
session: this.#session({
transport,
runId: snapshot.runId,
normalizedSessionId: snapshot.normalizedSessionId,
opened,
resumed: true,
activeTurnId: snapshot.activeTurnId ?? null,
sourceSequence: snapshot.lastSourceSequence ?? 0,
}),
};
} catch (error) {
await transport.close();
return { recovered: false, reason: redactCodexDiagnostic(String(error)) };
}
}
#transport(): CodexAppServerTransport {
return (
this.#options.transportFactory?.() ??
new ProcessCodexAppServerTransport({
environment: createSanitizedCodexEnvironment(this.#options.environment),
onDiagnostic: this.#options.onDiagnostic,
})
);
}
async #initialize(transport: CodexAppServerTransport): Promise<Record<string, unknown>> {
const initialized = await transport.request("initialize", {
clientInfo: { name: "paperclip-runner", title: "Paperclip Runner", version: DRIVER_VERSION },
capabilities: { experimentalApi: true, requestAttestation: false },
});
transport.notify("initialized");
return initialized;
}
#openedThread(
response: Record<string, unknown>,
initialize: Record<string, unknown>,
workingDirectory: string,
): OpenedCodexThread {
const thread = record(response.thread);
const threadId = text(thread.id);
if (threadId.length === 0) throw new Error("Codex thread response omitted thread.id");
return {
threadId,
providerSessionId: text(thread.sessionId, threadId),
context: {
protocolVersion: PHASE4_CODEX_PROTOCOL_VERSION,
codexVersion: text(initialize.userAgent, "unknown"),
clientInfo: {
name: "paperclip-runner",
title: "Paperclip Runner",
version: DRIVER_VERSION,
},
model: text(response.model, "unknown"),
modelProvider: text(response.modelProvider, text(thread.modelProvider, "unknown")),
workingDirectory: text(response.cwd, workingDirectory),
sandbox: response.sandbox ?? null,
approvalPolicy: response.approvalPolicy ?? "never",
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
instructionSources: Array.isArray(response.instructionSources)
? response.instructionSources.filter((value): value is string => typeof value === "string")
: [],
instructionPolicy: {
skillInstructions: false,
appInstructions: false,
collaborationInstructions: false,
},
environmentKeys: sanitizedEnvironmentKeys(this.#options.environment),
dynamicToolNames: this.#caps.dynamicTools ? [...PHASE4_SEMANTIC_TOOL_NAMES] : [],
modelInputKinds: ["text"],
envelope: structuredClone(this.#options.taskEnvelope),
},
};
}
#session(input: {
transport: CodexAppServerTransport;
runId: string;
normalizedSessionId: string;
opened: OpenedCodexThread;
resumed: boolean;
activeTurnId?: string | null;
sourceSequence: number;
}): CodexHarnessSession {
return new CodexHarnessSession({
...input,
taskEnvelope: this.#options.taskEnvelope,
now: this.#options.now ?? (() => new Date()),
runnerInstanceId: this.#options.runnerInstanceId ?? "runner-phase4",
capabilities: this.#caps,
});
}
}
class CodexHarnessSession implements HarnessSession {
readonly #transport: CodexAppServerTransport;
readonly #runId: string;
readonly #normalizedSessionId: string;
readonly #opened: OpenedCodexThread;
readonly #taskEnvelope: Phase4TaskEnvelope;
readonly #now: () => Date;
readonly #runnerInstanceId: string;
readonly #capabilities: CodexCapabilities;
readonly #events = new AsyncQueue<PrpEvent>();
#sourceSequence: number;
#activeTurnId: string | null;
#usage: Record<string, unknown> | null = null;
#result: PrpStructuredRunResult | null = null;
#resultFingerprint: string | null = null;
#terminal = false;
constructor(input: {
transport: CodexAppServerTransport;
runId: string;
normalizedSessionId: string;
opened: OpenedCodexThread;
taskEnvelope: Phase4TaskEnvelope;
resumed: boolean;
activeTurnId?: string | null;
sourceSequence: number;
now: () => Date;
runnerInstanceId: string;
capabilities: CodexCapabilities;
}) {
this.#transport = input.transport;
this.#runId = input.runId;
this.#normalizedSessionId = input.normalizedSessionId;
this.#opened = input.opened;
this.#taskEnvelope = input.taskEnvelope;
this.#activeTurnId = input.activeTurnId ?? null;
this.#sourceSequence = input.sourceSequence;
this.#now = input.now;
this.#runnerInstanceId = input.runnerInstanceId;
this.#capabilities = input.capabilities;
this.#transport.setServerRequestHandler((request) => this.#handleServerRequest(request));
this.#emit(input.resumed ? "session.resumed" : "session.started", {
driverSessionId: input.opened.threadId,
providerSessionId: input.opened.providerSessionId,
context: input.opened.context,
});
this.#emit("item.completed", {
kind: "model",
text: `${input.opened.context.model} (${input.opened.context.modelProvider})`,
model: {
name: input.opened.context.model,
provider: input.opened.context.modelProvider,
codexVersion: input.opened.context.codexVersion,
},
}, { itemId: `${input.opened.threadId}:model` });
void this.#pumpNotifications();
}
ids(): ReturnType<HarnessSession["ids"]> {
return {
driverSessionId: this.#opened.threadId,
providerSessionId: this.#opened.providerSessionId,
displayId: this.#opened.threadId,
};
}
contextSnapshot(): Phase4ModelContextSnapshot {
return structuredClone(this.#opened.context);
}
events(): AsyncIterable<PrpEvent> {
return this.#events;
}
async startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }> {
const taskText = JSON.stringify({ task: this.#taskEnvelope, message: input.message.text });
this.#emit("turn.submitted", { envelopeSchema: this.#taskEnvelope.schema });
const response = await this.#transport.request("turn/start", {
threadId: this.#opened.threadId,
input: [userInput({ role: "user", text: taskText })],
outputSchema: PHASE4_RESULT_OUTPUT_SCHEMA,
});
const turn = record(response.turn);
const turnId = text(turn.id);
if (turnId.length === 0) throw new Error("Codex turn response omitted turn.id");
this.#activeTurnId = turnId;
this.#emit("turn.accepted", { turnId }, { turnId });
return { turnId };
}
async steer(input: { turnId: string; message: NativeUserMessage }): Promise<void> {
this.#requireCapability("steering");
this.#requireActiveTurn(input.turnId);
try {
await this.#transport.request("turn/steer", {
threadId: this.#opened.threadId,
input: [userInput(input.message)],
expectedTurnId: input.turnId,
});
} catch (error) {
throw this.#unsupported("steering", error);
}
}
async interrupt(input: { turnId: string; reason?: string }): Promise<void> {
this.#requireCapability("interruption");
this.#requireActiveTurn(input.turnId);
try {
await this.#transport.request("turn/interrupt", {
threadId: this.#opened.threadId,
turnId: input.turnId,
});
} catch (error) {
throw this.#unsupported("interruption", error);
}
}
async read(): Promise<Record<string, unknown>> {
this.#requireCapability("read");
try {
return await this.#transport.request("thread/read", {
threadId: this.#opened.threadId,
includeTurns: true,
});
} catch (error) {
throw this.#unsupported("read", error);
}
}
async reconcile(): Promise<Record<string, unknown>> {
this.#requireCapability("reconciliation");
const snapshot = await this.read();
const thread = record(snapshot.thread);
const turns = Array.isArray(thread.turns) ? thread.turns.map(record) : [];
const active = turns.find((turn) => text(turn.status) === "inProgress");
this.#activeTurnId = active === undefined ? null : text(active.id);
this.#emit("session.reconciled", {
providerSessionId: this.#opened.providerSessionId,
turnCount: turns.length,
activeTurnId: this.#activeTurnId,
});
return snapshot;
}
async usage(): Promise<Record<string, unknown> | null> {
this.#requireCapability("usage");
return this.#usage === null ? null : structuredClone(this.#usage);
}
async snapshot(): Promise<PersistedHarnessSession> {
return {
driverKind: DRIVER_KIND,
driverSessionId: this.#opened.threadId,
providerSessionId: this.#opened.providerSessionId,
runId: this.#runId,
normalizedSessionId: this.#normalizedSessionId,
activeTurnId: this.#activeTurnId,
lastSourceSequence: this.#sourceSequence,
};
}
async close(): Promise<void> {
this.#events.close();
await this.#transport.close();
}
async #pumpNotifications(): Promise<void> {
try {
for await (const notification of this.#transport.notifications()) {
this.#mapNotification(notification);
}
} catch (error) {
this.#emit("harness.diagnostic", {
code: "notification_transport_failed",
message: redactCodexDiagnostic(String(error)),
});
}
}
#mapNotification(notification: CodexRpcNotification): void {
const params = notification.params;
const turn = record(params.turn);
const item = itemFromParams(params);
const turnId = text(params.turnId, text(turn.id, this.#activeTurnId ?? ""));
const itemId = text(item.id, text(params.itemId));
if (notification.method === "turn/started") {
this.#activeTurnId = turnId;
this.#emit("turn.started", { status: text(turn.status, "inProgress") }, { turnId });
return;
}
if (notification.method === "turn/completed") {
const status = text(turn.status, "completed");
const eventType = status === "failed" ? "turn.failed" : status === "interrupted" ? "turn.interrupted" : "turn.completed";
this.#captureResultFromTurn(turn);
this.#emit(eventType, { status, error: turn.error ?? null }, { turnId });
this.#activeTurnId = null;
this.#finalize(status);
return;
}
if (notification.method === "item/started") {
this.#emit("item.started", { kind: text(item.type, "unknown"), text: itemText(item), item }, { turnId, itemId });
return;
}
if (notification.method === "item/completed") {
this.#captureResultFromItem(item);
this.#emit("item.completed", { kind: text(item.type, "unknown"), text: itemText(item), item }, { turnId, itemId });
return;
}
const deltaKinds: Record<string, string> = {
"item/agentMessage/delta": "agentMessage",
"item/plan/delta": "plan",
"item/reasoning/summaryTextDelta": "reasoning",
"item/reasoning/textDelta": "reasoning",
"item/commandExecution/outputDelta": "commandExecution",
"item/fileChange/outputDelta": "fileChange",
"item/fileChange/patchUpdated": "fileChange",
"turn/diff/updated": "diff",
"turn/plan/updated": "plan",
};
const deltaKind = deltaKinds[notification.method];
if (deltaKind !== undefined) {
this.#emit("item.delta", {
kind: deltaKind,
text: text(params.delta, text(params.patch, text(params.output))),
update: params,
}, { turnId, itemId: itemId || `${turnId}:${deltaKind}` });
return;
}
if (notification.method === "thread/tokenUsage/updated") {
this.#usage = record(params.tokenUsage);
this.#emit("item.completed", { kind: "usage", usage: this.#usage }, {
turnId,
itemId: `${turnId}:usage:${this.#sourceSequence + 1}`,
});
return;
}
if (notification.method === "error" || notification.method === "warning" || notification.method === "configWarning") {
this.#emit("harness.diagnostic", {
code: notification.method.replaceAll("/", "_"),
message: redactCodexDiagnostic(text(params.message, JSON.stringify(params))),
}, turnId ? { turnId } : {});
}
}
async #handleServerRequest(request: CodexRpcServerRequest): Promise<Record<string, unknown>> {
if (request.method === "item/tool/call") {
const tool = text(request.params.tool);
if (!isSemanticTool(tool)) {
this.#diagnoseUnsupported(`dynamic tool ${tool}`);
return { success: false, contentItems: [{ type: "inputText", text: "Unsupported tool." }] };
}
const validation = validatePrpStructuredRunResult(request.params.arguments);
if (!validation.ok) {
return {
success: false,
contentItems: [{ type: "inputText", text: `Invalid semantic result: ${validation.issues.map((issue) => issue.message).join("; ")}` }],
};
}
if (!toolAcceptsDisposition(tool, validation.result.reportedWorkDisposition)) {
return {
success: false,
contentItems: [{
type: "inputText",
text:
tool === PHASE4_BLOCK_TOOL_NAME || tool === "paperclip.block"
? "paperclip_block requires reportedWorkDisposition=blocked."
: "paperclip_finish accepts only done or needs_review.",
}],
};
}
const fingerprint = JSON.stringify(validation.result);
if (this.#resultFingerprint !== null && this.#resultFingerprint !== fingerprint) {
return { success: false, contentItems: [{ type: "inputText", text: "A different semantic result was already committed." }] };
}
if (this.#result === null) {
this.#commitResult(
validation.result,
text(request.params.callId, randomUUID()),
text(request.params.turnId, this.#activeTurnId ?? ""),
);
}
return { success: true, contentItems: [{ type: "inputText", text: "Semantic completion accepted." }] };
}
const requestKind = request.method.includes("requestApproval") ? "approval" : "user_input";
const requestId = String(request.id);
this.#emit("runtime_request.created", {
request: {
requestId,
requestKind,
type: request.method,
status: "pending",
prompt: "Codex requires an external runtime decision.",
details: redactCodexValue(request.params),
},
}, {
turnId: text(request.params.turnId, this.#activeTurnId ?? ""),
itemId: text(request.params.itemId, requestId),
});
const response = safeRequestResponse(request.method);
this.#emit("runtime_request.resolved", {
requestId,
resolution: "declined_by_skillless_driver",
}, {
turnId: text(request.params.turnId, this.#activeTurnId ?? ""),
itemId: text(request.params.itemId, requestId),
});
return response;
}
#captureResultFromItem(item: Record<string, unknown>): void {
if (text(item.type) !== "agentMessage" || this.#result !== null) return;
const result = tryParseResult(item.text);
if (result !== null) this.#commitResult(result, text(item.id));
}
#captureResultFromTurn(turn: Record<string, unknown>): void {
if (this.#result !== null || !Array.isArray(turn.items)) return;
for (const value of turn.items.map(record).reverse()) {
if (text(value.type) !== "agentMessage") continue;
const result = tryParseResult(value.text);
if (result !== null) {
this.#commitResult(result, text(value.id));
return;
}
}
}
#commitResult(result: PrpStructuredRunResult, itemId: string, turnId?: string): void {
if (this.#result !== null) return;
this.#result = structuredClone(result);
this.#resultFingerprint = JSON.stringify(result);
result.verification.forEach((verification, index) => {
this.#emit("item.completed", {
kind: "verification",
text: `${verification.status}: ${verification.commandOrCheck}`,
verification,
}, {
turnId: turnId || this.#activeTurnId || undefined,
itemId: `${itemId || "semantic-result"}:verification:${index + 1}`,
});
});
this.#emit("run.result.proposed", result, {
turnId: turnId || this.#activeTurnId || undefined,
itemId: itemId || undefined,
});
}
#finalize(turnStatus: string): void {
if (this.#terminal) return;
if (this.#result === null) {
this.#commitResult({
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "needs_review",
summary: "Codex ended without a valid semantic completion.",
completionClaim: {
contractRevision: this.#taskEnvelope.completionContract.revision,
objectiveSatisfied: false,
criteria: this.#taskEnvelope.completionContract.criteria.map((criterion) => ({
criterionId: criterion.id,
status: "unknown",
evidenceRefs: [],
})),
remainingWork: [{ description: "Review the incomplete Codex turn.", blocksCompletion: true }],
},
evidence: [],
verification: [],
attentionRequests: [],
artifacts: [],
}, "missing-semantic-result");
}
const result = this.#result;
if (result === null) throw new Error("semantic result finalization invariant failed");
this.#terminal = true;
this.#emit("run.terminal", {
schema: "paperclip.prp.terminal.v1",
turnTerminalState: terminalState(turnStatus),
runTerminalState: dispositionRunState(result),
reportedWorkDisposition: result.reportedWorkDisposition,
});
}
#requireActiveTurn(turnId: string): void {
if (this.#activeTurnId !== turnId) {
throw this.#unsupported("active turn operation", "turn is no longer active");
}
}
#requireCapability(operation: keyof CodexCapabilities): void {
if (!this.#capabilities[operation]) throw this.#unsupported(operation, "capability not advertised");
}
#unsupported(operation: string, detail: unknown): UnsupportedCodexOperationError {
const error = new UnsupportedCodexOperationError(operation, String(detail));
this.#diagnoseUnsupported(operation, error.message);
return error;
}
#diagnoseUnsupported(operation: string, detail = "operation is not available"): void {
this.#emit("harness.diagnostic", {
code: "unsupported_operation",
operation,
message: redactCodexDiagnostic(detail),
});
}
#emit(
eventType: PrpEvent["eventType"],
payload: Record<string, unknown>,
refs: { turnId?: string; itemId?: string } = {},
): void {
const sourceSeq = ++this.#sourceSequence;
this.#events.push({
schema: "paperclip.prp.event.v1",
sourceEventId: `${this.#runnerInstanceId}:${this.#runId}:${sourceSeq}`,
sourceSeq,
sourceInstanceId: this.#runnerInstanceId,
sourceKind: "runner",
runId: this.#runId,
normalizedSessionId: this.#normalizedSessionId,
...(refs.turnId ? { turnId: refs.turnId } : {}),
...(refs.itemId ? { itemId: refs.itemId } : {}),
eventType,
schemaVersion: 1,
priority: eventType === "run.terminal" || eventType === "run.result.proposed" ? 0 : 1,
emittedAt: this.#now().toISOString(),
payload,
});
}
}
export function isSkilllessContext(context: Phase4ModelContextSnapshot): boolean {
const serialized = JSON.stringify(context).toLowerCase();
return (
context.instructionSources.length === 0 &&
context.baseInstructions === PHASE4_SKILLLESS_BASE_INSTRUCTIONS &&
context.modelInputKinds.length === 1 &&
context.modelInputKinds[0] === "text" &&
context.dynamicToolNames.length === PHASE4_SEMANTIC_TOOL_NAMES.length &&
context.dynamicToolNames.every((name) =>
PHASE4_SEMANTIC_TOOL_NAMES.includes(
name as (typeof PHASE4_SEMANTIC_TOOL_NAMES)[number],
),
) &&
context.instructionPolicy.skillInstructions === false &&
context.instructionPolicy.appInstructions === false &&
context.instructionPolicy.collaborationInstructions === false &&
!serialized.includes("paperclip_api_key") &&
!serialized.includes("authorization: bearer") &&
!serialized.includes("/api/issues/") &&
!serialized.includes('"type":"skill"')
);
}
function isSemanticTool(tool: string): boolean {
return (
tool === PHASE4_COMPLETION_TOOL_NAME ||
tool === PHASE4_BLOCK_TOOL_NAME ||
tool === "paperclip.finish" ||
tool === "paperclip.block"
);
}
function toolAcceptsDisposition(
tool: string,
disposition: PrpStructuredRunResult["reportedWorkDisposition"],
): boolean {
if (tool === PHASE4_BLOCK_TOOL_NAME || tool === "paperclip.block") {
return disposition === "blocked";
}
return disposition === "done" || disposition === "needs_review";
}
function redactCodexValue(value: unknown): unknown {
if (typeof value === "string") return redactCodexDiagnostic(value);
if (Array.isArray(value)) return value.map(redactCodexValue);
const object = record(value);
return Object.fromEntries(
Object.entries(object).map(([key, entry]) => [
key,
/(?:api[_-]?key|token|secret|password|authorization)/i.test(key)
? "[REDACTED]"
: redactCodexValue(entry),
]),
);
}
function safeRequestResponse(method: string): Record<string, unknown> {
if (method === "item/permissions/requestApproval") {
return { permissions: {} };
}
if (method === "mcpServer/elicitation/request") {
return { action: "decline", content: null };
}
if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
return { answers: {} };
}
if (method.includes("requestApproval")) {
return { decision: "decline" };
}
return {};
}
+4
View File
@@ -3,8 +3,12 @@ export * from "./contracts/harness-driver.js";
export * from "./contracts/native-session-backend.js";
export * from "./contracts/phase2.js";
export * from "./contracts/phase3.js";
export * from "./contracts/phase4.js";
export * from "./contracts/types.js";
export * from "./drivers/codex/app-server-transport.js";
export * from "./drivers/codex/codex-app-server-driver.js";
export * from "./mock-core/mock-control-plane-adapter.js";
export * from "./mock-core/phase4-codex-runner.js";
export * from "./mock-core/phase2-local-runner.js";
export * from "./mock-core/phase3-recovery.js";
export * from "./protocol/phase0-fixture.js";
@@ -0,0 +1,234 @@
import type { HarnessDriver, HarnessSession } from "../contracts/harness-driver.js";
import {
type Phase4RunMetadata,
type Phase4RunTrace,
type Phase4TaskEnvelope,
} from "../contracts/phase4.js";
import type { NativeUserMessage } from "../contracts/types.js";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
} from "../reducer/session-reducer.js";
import type {
PrpCapabilities,
PrpEvent,
PrpStructuredRunResult,
} from "../protocol/phase1-contract.js";
import {
UnsupportedCodexOperationError,
isSkilllessContext,
} from "../drivers/codex/codex-app-server-driver.js";
export interface Phase4CodexTracerInput {
driver: HarnessDriver;
taskEnvelope: Phase4TaskEnvelope;
workingDirectory: string;
message?: NativeUserMessage;
runId?: string;
companyId?: string;
issueId?: string;
environmentLeaseId?: string;
runnerInstanceId?: string;
steer?: string;
interrupt?: boolean;
timeoutMs?: number;
}
function prpCapabilities(
descriptor: Awaited<ReturnType<HarnessDriver["descriptor"]>>,
): PrpCapabilities {
return {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "reuse_per_issue",
driver: { kind: descriptor.kind, version: descriptor.version },
steer: descriptor.capabilities.steering,
interrupt: descriptor.capabilities.interruption,
resume: descriptor.capabilities.resume,
runtimeRequests: true,
structuredResult: descriptor.capabilities.structuredResult,
typedEvents: descriptor.capabilities.typedEvents,
...(descriptor.capabilities.unsupported?.length
? { unsupported: descriptor.capabilities.unsupported }
: {}),
};
}
function contextFrom(events: PrpEvent[]) {
const event = events.find(
(candidate) => candidate.eventType === "session.started" || candidate.eventType === "session.resumed",
);
const context = event?.payload.context;
if (typeof context !== "object" || context === null || Array.isArray(context)) {
throw new Error("Codex session did not emit its model-context snapshot");
}
return context as Phase4RunTrace["context"];
}
function resultFrom(events: PrpEvent[]): PrpStructuredRunResult {
const event = events.find((candidate) => candidate.eventType === "run.result.proposed");
if (event === undefined) throw new Error("Codex session emitted no semantic result");
return event.payload as PrpStructuredRunResult;
}
async function consumeToTerminal(
session: HarnessSession,
timeoutMs: number,
onEvent?: (event: PrpEvent) => void,
): Promise<PrpEvent[]> {
const consume = async () => {
const events: PrpEvent[] = [];
for await (const event of session.events()) {
events.push(event);
onEvent?.(event);
if (event.eventType === "run.terminal") return events;
}
throw new Error("Codex event stream closed before run.terminal");
};
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
consume(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Codex tracer timed out after ${timeoutMs}ms`)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
/**
* Small in-memory Phase 4 mock core. It accepts only the public driver contract,
* reduces events live, and replays the same canonical stream after completion.
*/
export async function runPhase4CodexTracer(input: Phase4CodexTracerInput): Promise<Phase4RunTrace> {
const runId = input.runId ?? "phase4-safe-run";
const normalizedSessionId = `session:${runId}`;
const descriptor = await input.driver.descriptor();
const capabilities = prpCapabilities(descriptor);
const metadata: Phase4RunMetadata = {
schema: "paperclip.runner.phase4.metadata.v1",
fixtureName: "phase-04-safe-codex-task",
identity: {
schema: "paperclip.prp.identity.v1",
companyId: input.companyId ?? "mock-company",
issueId: input.issueId ?? "mock-issue",
runId,
environmentLeaseId: input.environmentLeaseId ?? "mock-lease",
runnerInstanceId: input.runnerInstanceId ?? "runner-phase4",
normalizedSessionId,
},
capabilities,
};
const session = await input.driver.openSession({ runId, workingDirectory: input.workingDirectory });
let signalTurnStarted: (() => void) | undefined;
const turnStarted = new Promise<void>((resolve) => {
signalTurnStarted = resolve;
});
const consuming = consumeToTerminal(session, input.timeoutMs ?? 180_000, (event) => {
if (event.eventType === "turn.started") signalTurnStarted?.();
});
try {
const turn = await session.startTurn({
message: input.message ?? { role: "user", text: "Complete the supplied task envelope." },
});
if (input.steer !== undefined || input.interrupt) {
await Promise.race([
turnStarted,
consuming.then(() => {
throw new Error("Codex turn reached terminal state before accepting the control command");
}),
]);
}
if (input.steer !== undefined) {
if (session.steer === undefined) throw new Error("driver did not expose steering");
try {
await session.steer({ turnId: turn.turnId, message: { role: "user", text: input.steer } });
} catch (error) {
// A short real-model turn can finish between turn/start and turn/steer.
// The driver already emitted the explicit capability diagnostic; keep
// consuming the same session so the mock core records its terminal.
if (!(error instanceof UnsupportedCodexOperationError)) throw error;
}
}
if (input.interrupt) {
if (session.interrupt === undefined) throw new Error("driver did not expose interruption");
try {
await session.interrupt({ turnId: turn.turnId, reason: "Phase 4 tutorial interrupt" });
} catch (error) {
if (!(error instanceof UnsupportedCodexOperationError)) throw error;
}
}
const events = await consuming;
const ids = session.ids();
const identity = {
...metadata.identity,
driverSessionId: ids.driverSessionId,
...(ids.providerSessionId ? { providerSessionId: ids.providerSessionId } : {}),
};
metadata.identity = identity;
const liveSnapshot = events.reduce(
applyPrpEvent,
createSessionSnapshotFromMetadata({
fixtureName: metadata.fixtureName,
identity,
capabilities,
}),
);
const replaySnapshot = events.reduce(
applyPrpEvent,
createSessionSnapshotFromMetadata({
fixtureName: metadata.fixtureName,
identity,
capabilities,
}),
);
const context = contextFrom(events);
const result = resultFrom(events);
const terminalCount = events.filter((event) => event.eventType === "run.terminal").length;
const resultCount = events.filter((event) => event.eventType === "run.result.proposed").length;
const serialized = JSON.stringify(context).toLowerCase();
const itemEvents = events.filter((event) => event.eventType.startsWith("item."));
return {
schema: "paperclip.runner.phase4.trace.v1",
metadata,
context,
events,
result,
liveSnapshot,
replaySnapshot,
diagnostics: events
.filter((event) => event.eventType === "runner.diagnostic" || event.eventType === "harness.diagnostic")
.map((event) => String(event.payload.message ?? event.payload.code ?? "diagnostic")),
assertions: {
exactlyOneTerminalResult: terminalCount === 1 && resultCount === 1,
liveReplayParity: JSON.stringify(liveSnapshot) === JSON.stringify(replaySnapshot),
stableIdentity:
events.every(
(event) =>
event.runId === runId && event.normalizedSessionId === normalizedSessionId,
) && ids.driverSessionId.length > 0,
sourceSequenceContinuous: events.every(
(event, index) => event.sourceSeq === index + 1,
),
stableItemIdentity: itemEvents.every(
(event) => typeof event.itemId === "string" && event.itemId.length > 0,
),
contextIsSkillless: isSkilllessContext(context),
unrelatedSkillsAbsent:
context.instructionSources.length === 0 &&
context.instructionPolicy.skillInstructions === false &&
context.modelInputKinds.length === 1 &&
context.modelInputKinds[0] === "text",
credentialsAbsent:
!serialized.includes("paperclip_api_key") &&
!serialized.includes("authorization: bearer") &&
!serialized.includes("/api/issues/"),
},
};
} finally {
await session.close({ reason: "Phase 4 tracer complete" });
}
}
@@ -90,6 +90,11 @@ if (registeredEventValidator === undefined) {
throw new Error("PRP event validator was not registered");
}
const eventValidator = registeredEventValidator as ValidateFunction<PrpEvent>;
const registeredResultValidator = ajv.getSchema(resultSchema.$id);
if (registeredResultValidator === undefined) {
throw new Error("PRP structured result validator was not registered");
}
const resultValidator = registeredResultValidator as ValidateFunction<PrpStructuredRunResult>;
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -297,6 +302,24 @@ export function validatePrpEvent(value: unknown): EventValidationResult {
return { ok: true, event: value, issues: [] };
}
export type StructuredResultValidationResult =
| { ok: true; result: PrpStructuredRunResult; issues: [] }
| { ok: false; result: null; issues: ProtocolValidationIssue[] };
/** Validate a semantic completion independently from a complete replay fixture. */
export function validatePrpStructuredRunResult(
value: unknown,
): StructuredResultValidationResult {
if (!resultValidator(value)) {
return {
ok: false,
result: null,
issues: (resultValidator.errors ?? []).map(ajvIssue),
};
}
return { ok: true, result: value, issues: [] };
}
export function negotiateProtocolVersion(
runner: ProtocolVersionRange,
controller: ProtocolVersionRange,