mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The Paperclip Runner starts Codex for native runs. On a remote
sandbox such as Daytona, the sandbox is the OS boundary.
> - A Codex planning run on Daytona failed in `session.open`. Codex's
read-only sandbox helper could not set up loopback in the container
(`bwrap: loopback: Failed RTM_NEWADDR`), and Codex loads `AGENTS.md`
through that helper.
> - The server treated that definite provider rejection as an
interrupted transport. Its same-run bootstrap retry went through remote
restart recovery and stopped on `ambiguous_remote_recovery_evidence`.
The task showed "Recovering saved work…" and no Retry, although no
provider work was done.
> - This pull request gives such startup rejections a named, permanent
outcome with a working Retry, and pins ask and standard runs to the
external sandbox policy.
> - The benefit is that a provider startup refusal ends as a clear
failure with Retry, never as an ownership hold.
## Linked Issues or Issue Description
**What happened?**
A Codex planning run on a Daytona sandbox failed in `session.open` with
`error creating thread: failed to load AGENTS.md instructions ... bwrap:
loopback: Failed RTM_NEWADDR: Operation not permitted`. The server
recorded `native_session_interrupted` with recovery mode
`bootstrap_retry`. Thirty seconds later, remote restart recovery blocked
the run with `native_restart_recovery_blocked` /
`ambiguous_remote_recovery_evidence`. No server restart happened. All
default-mode Codex runs on the same image succeeded that day; only the
planning run failed.
**Expected behavior**
A provider startup failure with no provider work must end as a clear
failure with a working Retry, or get a bounded automatic retry that also
ends with Retry. It must never become an ambiguous-ownership hold.
**Steps to reproduce**
1. `cd server && npx vitest run
src/services/native-runtime/native-startup-rejected-retry.integration.test.ts`.
On master the deterministic case is `retryable_failure` /
`native_session_interrupted`.
2. `npx vitest run
src/services/native-runtime/native-session-executor.test.ts -t "provider
itself rejected"`. On master the new code is not produced.
**Paperclip version or commit**
master at 84fc67317e.
## What Changed
- Server (`native-session-executor.ts`): new permanent code
`native_provider_startup_rejected`.
- A definite `session.open` failure maps to it when its startup
`providerFailure` is `requires_action` for `authentication`,
`permission` or `invalid_request`.
- On a remote execution target (`nativeRemoteStartupRejectionCode`), any
definite `session.open` failure with a startup `providerFailure` maps to
it. A remote same-run bootstrap after the runner launched cannot prove
ownership and stops on `ambiguous_remote_recovery_evidence`.
- The run ends as `terminal_failure` with a board owner and a plain next
action. The board offers Retry, which starts one new run with a fresh
provider session.
- On a local target, unknown, transient, rate-limit, quota and model
startup failures keep the bounded same-run retries and end as
`native_session_retry_exhausted` with Retry. That code is unchanged
because the chat-channel failed-run retry depends on it.
- Runner: planning runs keep Codex's
`paperclip-runner-workspace-read-only` profile, also in an external
sandbox. Codex 0.160.0's plan collaboration mode is a prompt ("Plan Mode
(Conversational)") and does not block writes or commands, so it cannot
replace that enforcement.
- Ask mode: an ask task builds `executionMode: "default"`. Its launch
has `readOnly: false`, the external sandbox profile, and no
collaboration mode. Ask mode restricts only Paperclip mutation tools,
not the Codex sandbox.
- Tests:
- `native-startup-rejected-retry.integration.test.ts` (new, embedded
Postgres, Codex provider): a deterministic rejection ends at once with
`canRetry: true`, and Retry starts exactly one new run. Unknown and
transient local failures stay `retryable_failure`. A spent retry budget
ends `terminal_failure` with `canRetry: true`.
- Unit tests for the deterministic, local-retry and remote rules.
- `runner-provider-launch.test.ts`: ask, standard and planning launches
in an external sandbox.
- The Rust external-sandbox test now fails if a non-planning turn sends
a collaboration mode (`--forbid-plan-collaboration` switch on the fake
app server).
- Docs: `packages/paperclip-runner/docs/codex-driver.md`,
`packages/paperclip-runner/docs/durable-recovery.md`,
`doc/execution-semantics.md`.
## Verification
- Red on master, green here: the commands in "Steps to reproduce".
- `npx vitest run
src/services/native-runtime/native-session-executor.test.ts`: 1364 pass.
- `npx vitest run src/live/runner-provider-launch.test.ts`
(packages/paperclip-runner): 22 pass.
- `cargo test -p paperclip-runner-core --test codex_provider
external_sandbox`: pass. `cargo fmt --check`: clean.
- `npx tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify` returned exit 2 (a global CI input changed). Required CI
supplies the broad coverage.
## Risks
- Codex planning on Daytona still fails at thread creation. With this
change it ends at once with the provider message and Retry instead of an
ownership hold. A follow-up must make Codex's read-only sandbox work in
that container, or add a verified Paperclip read-only guard. This PR
does not relax read-only enforcement.
- A remote startup failure that could clear by itself now needs a Retry
click instead of an automatic same-run retry. Before this change, that
retry ended in an ownership hold, so no working automatic path is lost.
- A false `native_provider_startup_rejected` costs automatic retries and
shows Retry earlier. It never replays provider work, because
`session.open` precedes every prompt.
- Saved state: no persisted shape or launch argument changes. A run that
an older release held on `ambiguous_remote_recovery_evidence` after the
same rejection already offers Retry once its saved workspace is
recovered. No migration is needed.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- Claude (Anthropic) Opus 5.5, model ID `claude-opus-5-5`, run in Claude
Code with tool use (shell, file edit, browser reads) and extended
reasoning.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
11 KiB
Markdown
211 lines
11 KiB
Markdown
# Codex Skillless Codex Driver
|
|
|
|
## Scope
|
|
|
|
Codex 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 | controller-owned mock-core input | A distinct identity, independent of the run and provider IDs, that stays 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, the exact active turn, committed semantic-result
|
|
content/call binding, observed terminal-turn fingerprints, and the last source
|
|
sequence. Recovery starts a new local app-server transport, reads the exact
|
|
persisted thread to validate identity and working directory, resumes that
|
|
thread, and then reads it again for reconciliation. Reconciliation considers
|
|
only the persisted active turn: it retains that turn when active, terminalizes
|
|
that turn when terminal, and fails recoverably when it is missing or a
|
|
different active turn appears. Historical terminal turns never substitute for
|
|
the persisted active turn, and a terminal already in the durable snapshot is
|
|
not emitted again.
|
|
|
|
## 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 `HarnessCapabilityUnavailableError`. |
|
|
| 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 and app instruction
|
|
blocks. Codex's built-in collaboration instructions are enabled by default so
|
|
interactive runs receive native commentary and tool preambles; a driver caller
|
|
may explicitly disable them for a specialized deterministic fixture. This
|
|
does not enable skills, apps, plugins, memories, or extra model-input kinds.
|
|
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 trusted app-server process has an allowlisted environment. It retains host
|
|
`HOME` and `CODEX_HOME` only so the provider can authenticate. Model-issued
|
|
commands have a separate boundary: an empty-by-default environment with no
|
|
`HOME` or `CODEX_HOME`, no network, and a named Codex
|
|
permission profile requesting read-only minimal runtime files, no host-home or
|
|
Codex-home access, and write access to the assigned workspace. The driver
|
|
refuses filesystem-root workspaces, workspaces containing host `HOME`, and any
|
|
workspace overlapping host `CODEX_HOME`. A workspace below host `HOME` is
|
|
valid, but when `PAPERCLIP_WORKSPACE_CWD` is present its canonical path must be
|
|
equal to or below that assigned workspace so sibling and symlink escapes fail
|
|
before provider startup.
|
|
|
|
When the execution target is itself the sandbox (a remote sandbox such as
|
|
Daytona sets `PAPERCLIP_RUNNER_EXTERNAL_SANDBOX=1`), standard and ask runs use
|
|
the default execution mode. The Runner starts their Codex threads and turns
|
|
with the external sandbox policy and sends no collaboration mode. Ask mode
|
|
restricts Paperclip mutation tools, not the Codex sandbox. Planning runs keep
|
|
the `paperclip-runner-workspace-read-only` profile everywhere, because Codex's
|
|
plan collaboration mode is a prompt and does not block writes or commands.
|
|
Inside a container without network-namespace rights, that profile's helper can
|
|
fail thread creation (`bwrap: loopback: Failed RTM_NEWADDR`); the run then ends
|
|
as a provider startup failure with Retry (see `durable-recovery.md`).
|
|
|
|
The returned sandbox facts remain authoritative. Codex 0.132.0 may inject a
|
|
provider-managed writable root such as `~/.codex/memories` after a first run,
|
|
even with `features.memories=false` and an explicit Codex-home deny. That makes
|
|
the Codex-home directory discoverable in a warmed environment, so Codex does
|
|
not claim whole-directory unreadability. Its authenticated proof instead
|
|
requires each readable `auth.json`/`config.toml` file and an unrelated host
|
|
secret to remain unreadable and unwritable, while recording any injected root
|
|
in `context.sandbox.legacyPolicy`.
|
|
|
|
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 proposed. Any canonically identical result retry is
|
|
idempotent even when the provider assigned a new call ID; the original call
|
|
binding remains persisted for audit, while changed content is rejected. Tool
|
|
calls and provider notifications must name the exact opened thread and active turn. Missing,
|
|
pre-turn, cross-thread, cross-turn, and post-terminal bindings fail the provider
|
|
session closed. Canonically identical terminal replays are no-ops; conflicting
|
|
terminal facts are rejected. Process exit or prose alone never implies
|
|
completion.
|
|
|
|
The provider cannot commit controller state. It emits `run.result.proposed` and
|
|
a provider turn terminal; the mock core validates the proposal against its
|
|
task envelope, emits `run.result.accepted` or `run.result.rejected`, and alone
|
|
emits `run.terminal`.
|
|
|
|
## 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;
|
|
- provider completion -> at most one `run.result.proposed` and one turn
|
|
terminal;
|
|
- controller decision -> one `run.result.accepted` or `run.result.rejected`,
|
|
followed by one `run.terminal`.
|
|
|
|
The JSON-RPC transport limits each input line, pending client requests,
|
|
in-flight server requests, queued notification count and bytes, diagnostic
|
|
lines, and retained provider payloads. Malformed or oversized messages close
|
|
the transport and reject pending work.
|
|
|
|
The existing Replay reducer consumes the live stream. Replay crosses a
|
|
serialized JSONL boundary that validates byte and event counts, line size,
|
|
schema, run/session binding, unique source event IDs, continuous per-source
|
|
sequence, and exactly one final run terminal before reducing. The Codex
|
|
tracer requires byte-equivalent live and replay snapshots.
|
|
|
|
## Runnable example
|
|
|
|
`trace:codex` starts a real local `codex app-server` session through the mock
|
|
core. Its safe task creates `hello.txt` with network disabled. The evidence
|
|
recorder additionally probes all readable host Codex credential/config files
|
|
and an unrelated host secret, requiring reads and writes to be denied while
|
|
workspace output and app-server authentication still succeed. It gates output
|
|
reads on an accepted `done` result and reports missing files by name rather
|
|
than surfacing a raw filesystem `ENOENT`. See the
|
|
[Codex tutorial](tutorials/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.
|
|
|
|
|
|
## Explicit assigned skills in native tasks
|
|
|
|
A task description can explicitly invoke an assigned skill with `/skill-name`
|
|
or `$skill-name`. The native Codex backend resolves these references against
|
|
that run's assigned runtime context; assignment alone does not invoke a skill.
|
|
Selections are recomputed from the current task description on each wake,
|
|
including approval replies and recovered sessions. Ordinary tasks without an
|
|
explicit reference receive no structured skill invocation.
|
|
|
|
The driver sends both the `$skill-name` text reference and Codex's structured
|
|
`{ type: "skill", name, path }` input on every requested turn. Runnerd validates
|
|
the assigned source path, maps it to the provider's isolated
|
|
`codex-home/skills/<name>/SKILL.md` (including remote runners), and preserves it
|
|
through the durable `turn.start` command. `turn.submitted.skillInputs` records
|
|
the controller's selected inputs for inspection; protocol tests separately
|
|
verify the outgoing provider request and mapped path.
|
|
|
|
`includeSkillInstructions` is forwarded to Codex on both `thread/start` and
|
|
`thread/resume`. Old persisted configurations without the field retain their
|
|
provider default until a fresh, settled run supplies an explicit value. That
|
|
one-time upgrade reopens the same thread with the new setting. OpenCode does
|
|
not receive Codex's skill configuration or structured skill inputs.
|
|
|
|
These are delivery guarantees, not guarantees of model adherence. The onboarding
|
|
`first-task` skill uses this generic mechanism; its prompt and persona are not
|
|
changed by the wiring.
|