feat(agents): add Codex as a first-class harness - #5509
Conversation
HarnessType.CODEX, CodexHarness + CodexAgentTemplate, codex_settings.py (renders .codex/config.toml only from authored options), capabilities + curated model catalog, golden fixture, and unit tests. Mirrors the Claude pair. No baked platform defaults (D-008 pending; codex-acp mode preset overrides config sandbox_mode per derisk P2). Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
codex-assets.ts writes <cwd>/.codex/auth.json from the vault key after the durable mount (delete-only-if-created, destroy backstop mirrors otlpAuthFilePath); CODEX_HOME set pre-mount in environment-setup. daemon.ts inherits CODEX_HOME as a config-dir path. run-plan.ts rejects codex runtime_provided (subscription) up front. Runner unit tests mirror Claude's. Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
M1 report (built/tests/QA/blocker/deferred) and status update. The CODEX_HOME-on-geesefs SQLite wedge blocks the durable session-mount path and reopens D-002 Option A; options are in the report for Mahmoud. LESSONS entry appended in the worktree's gitignored add-harness skill copy (D-001). Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
…LITE_HOME Codex stores WAL-mode SQLite state in CODEX_HOME; on the geesefs durable session mount that wedges the turn (CreateLinkOp unsupported). Per D-002's P8 amendment, keep CODEX_HOME=<cwd>/.codex and point CODEX_SQLITE_HOME at a local off-mount dir (per-session-stable like relayDir, fingerprint-neutral so warm reuse survives; best-effort teardown). Durable multi-turn QA: codeword survives turn to turn, no hang. Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
…urable QA Blocker resolved via D-002 P8 amendment: durable multi-turn QA green (codeword survives turn to turn, no hang; SQLite confirmed off-mount, sessions/ rollouts + .tmp git clone benign). Quality passes recorded. LESSONS updated in the gitignored add-harness skill copy (D-001). Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
…ype with Claude sibling
…ix + pricing Tools deliver and execute on Codex over the internal agenta-tools loopback MCP channel. Handle the Codex mcp.<server>.<tool> dot naming on the execution path (bareToolName, serverPermissionFor). Fix the $0.00 run cost by emitting gen_ai.response.model on the Codex LLM span (the real cause; the M1 curated-pricing diagnosis was wrong - run cost uses litellm keyed by the span model, not the catalog). Add litellm-sourced curated pricing (picker tooltip), the Codex user-MCP capability block, and the picker avatar. Pin one live tool run as an offline replay regression test. D-008 agent-full-access default mode intentionally NOT wired (kept in M3; tools work under the default agent mode via runner auto-allow). Local checkpoint; not pushed (prettier pre-commit hook skipped: it fails only on the root-owned web/ee/public/__env.js, unrelated to this change; ruff + gitleaks pass).
…nt-full-access + per-agent override)
… agenta-tools pause seam (allow/deny/ask-park, cold resume)
… + MCP frames, toolCallId join, keep-alive park)
…reinforcement + per-server/per-tool approval config, agent-mode only)
…driver (live QA blocked by deployment codex-tool regression)
…dex_settings (D-008 amendment; transport-less entry crashed codex session/new)
…ing + capabilities Replace the M3 codex subscription rejection with the real mount contract: a local runtime_provided codex run requires CODEX_HOME naming a read-write mount (mirrors the Claude CLAUDE_CONFIG_DIR branch). configureCodexHome now leaves the inherited mount untouched for subscription runs and redirects CODEX_SQLITE_HOME off the home in both modes; managed auth.json writing stays managed-only so the delete-backstop never touches the mount. SDK capabilities: codex harness now advertises self_managed. Feature code authored by Codex (gpt-5.6-sol) via codex exec; orchestrated/reviewed by Opus. Out-of-scope excursions Codex added (permission-plan MCP-envelope fix + M3 report rewrite) were reverted; its MCP-regression root-cause preserved as a debug-agent lead.
…elope in stored-decision key so a runner-gate ask-tool approval resumes (live-QA)
…/cold/agent-mode) + driver; root-cause correction
…eakage findings, QA MP4, status Subscription auth (harness=codex, self_managed / runtime_provided) GREEN at the wire level: mounted ~/.codex is the only credential, no OPENAI_API_KEY delivered, ChatGPT auth, SQLite redirected off-mount, auth.json integrity preserved. Item C (operator config.toml MCP-server leak, non-neutralizable via CODEX_CONFIG) recorded as a STOP-and-report product-exposure decision awaiting Mahmoud's ruling.
…landed the fix + its own root-cause docs)
…th (D-002 amendment)
Close the config-leakage exposure: a subscription codex daemon's CODEX_HOME is now the
runner-owned <cwd>/.codex (both modes), and auth.json there is a SYMLINK to the operator's
mounted login. Codex rewrites auth.json in place through the symlink (P4), so token refresh
lands in the real login, while the operator's config.toml/plugins/apps never load in a product
session. CODEX_SQLITE_HOME redirect unchanged. Adds the store-mode pin
CODEX_CONFIG={cli_auth_credentials_store:file} for subscription daemons (single scalar key,
never sandbox_mode; fingerprint-derived per P1). Teardown removes the symlink, never the target.
m4-tool-qa.py drives the product-path subscription+tools QA.
…-QA results, quality pass
…sses) Record the approval flow in the real playground UI (reports/m3-approvals-qa.mp4) and document the close-out /simplify + desloppify-code passes over the M3 diff. Both quality passes find the M3 production diff clean (deliberate sibling-pattern parity, invariant-only comments, resume-key unwrap in the shared storedDecisionKeyShape, module-convention any on ACP session/request), so no code changes were needed. Suites re-run green: SDK agents 691, runner 1248; ruff clean. Milestone 3 CLOSED: both remaining deliverables are in. Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
Encodes the procedure and lessons for adding a coding-agent harness (prior-art audit, daemon registry check, spike-first checkpoint, the integration-surface checklist, and per-harness variance axes), extracted from the Codex harness project. resources/LESSONS.md is the append-only lessons log; every harness project updates both. Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
…ner image build (D-005)
…M CODEX_HOME, D-002 amendment)
…cs sync (subscription CODEX_HOME, harness inventory)
… milestones closed), lane-split plan, LESSONS
…gn/plan/research/reports/spike) so it ships with the docs lane
… untracked review-first artifacts
… custom provider env_key, durable Daytona home, drop auth.json writers+backstop
…e), RE-QA results, notes/status/LESSONS/inventory sync
| * secret, and pointing at the stable mount path is correct for the next resume. Managed runs are | ||
| * file-free and never reach here. | ||
| */ | ||
| export function symlinkCodexSubscriptionAuthFile( |
There was a problem hiding this comment.
Subscription auth is the SYMLINK layout, not a whole-directory mount (D-002 security amendment). Milestone 4 QA proved that mounting the operator's CODEX_HOME directly leaks their personal [mcp_servers.*] into product runs, and CODEX_CONFIG deep-merges additively so it can add servers but never remove the operator's. So the runner owns the home and only symlinks auth.json into the operator's mount; codex refreshes in place through the link (probe P4), landing in the operator's real login, and nothing else from their directory loads. The link is never torn down (it points at the operator's own mount, not a secret).
| export type ExecutableToolVerdict = | ||
| | { kind: "allow" } | ||
| | { kind: "deny"; reason: string } | ||
| | { kind: "pendingApproval" }; |
There was a problem hiding this comment.
This is the D-008 pause-seam contract. Under the default agent-full-access mode codex raises no tool gate, so Agenta-tool approvals are enforced here runner-side at the agenta-tools loopback MCP tools/call, harness-independent. pendingApproval uses the COLD-REPLAY pattern (D-008 amendment), not keep-alive parking: the synchronous tools/call has no ACP permission id and cannot outlive a paused turn, so the turn tears down and the next turn re-issues the call. That makes a codex ask-approval behave like a client-tool approval, not like a Claude ask-tool that rides Claude's own ACP gate. The gate implementation lives in executable-tools.ts.
| isCodex: boolean, | ||
| ): { gate: GateDescriptor; spec: ResolvedToolSpec | undefined } { | ||
| const recorded = recordedToolCall(run, toolCall?.toolCallId); | ||
| const codexMcpApproval = |
There was a problem hiding this comment.
Codex ACP frame quirks are handled here. A codex MCP-tool approval arrives as a nameless permission frame, so we join it back to the recorded tool_call by toolCallId (the loop near line 521) to recover the real tool name and arguments. Without that join the resume keys wrong and the tool re-parks instead of executing (the Milestone 3 bug). A codex shell command instead comes through as a nameless execute kind, which is why codexCommand is distinguished from the MCP-approval case here.
| serverPermissions: ReadonlyMap<string, ToolPermission>, | ||
| ): ToolPermission | undefined { | ||
| // Codex uses mcp.<server>.<tool>; Claude uses mcp__<server>__<tool>. | ||
| if (toolName?.startsWith("mcp.")) { |
There was a problem hiding this comment.
Naming-convention split: codex delivers MCP tools as mcp.<server>.<tool> (dot), where Claude uses mcp__<server>__<tool>. serverPermissionFor unwraps the dot form to find the per-server permission. This is why the resume-key logic and bareToolName on the execution path handle both shapes.
| ] as const; | ||
| export type CodexMode = (typeof CODEX_MODES)[number]; | ||
|
|
||
| export const DEFAULT_CODEX_MODE: CodexMode = "agent-full-access"; |
There was a problem hiding this comment.
agent-full-access is the approved default (D-008 Posture 2): autonomous shell runs stay clean, and tool human-in-the-loop is owned by the runner-side gate rather than codex. These three modes are the only ones codex-acp's bridge accepts, and the bridge sends its per-turn preset that OVERRIDES any config.toml or CODEX_CONFIG approval policy (the reason the runner-side gate has to exist). applyCodexMode swallows a bridge failure so a mode-set hiccup never fails the run.
| // message rather than letting the harness fall back to discovering the runner's own home dir | ||
| // (interface.md section 6). Managed ("env") / "none" runs are unaffected. | ||
| // A local runtime_provided run authenticates from an explicitly mounted subscription; Codex | ||
| // reads its login from the CODEX_HOME mount too. If the harness config var is unset there is no |
There was a problem hiding this comment.
Subscription (runtime_provided) contract for codex. Like Claude's CLAUDE_CONFIG_DIR branch, a local codex subscription run REQUIRES the operator's CODEX_HOME mount and fails up front with an actionable message if it is unset, rather than silently discovering the runner's own home directory (interface.md section 6). Daytona plus subscription is rejected earlier (line 334); managed env/none runs are unaffected.
| # clean, unlike proprietary Claude Code. Pinned versions recorded next to the sandbox-agent pin in | ||
| # package.json. Runs as `node` so the install lands in the runtime user's HOME. | ||
| # PIN: @agentclientprotocol/codex-acp 1.1.7 (bundles @openai/codex 0.145.0). | ||
| RUN node_modules/.bin/sandbox-agent install-agent codex --agent-process-version 1.1.7 -n |
There was a problem hiding this comment.
The D-005 pin. The sandbox-agent daemon otherwise installs @agentclientprotocol/codex-acp from npm with a floating ^1.1.7 at first codex use, and that version determines protocol behavior (gate shapes, config channels), so a registry release could drift it under us. Pre-installing here bakes codex-acp 1.1.7 (bundling @openai/codex 0.145.0) with a lockfile, so the runtime daemon finds it present and skips the floating fetch. Codex is Apache-2.0, so baking it is licensing-clean (unlike proprietary Claude Code). Dockerfile.dev mirrors this line, and the pinned versions are recorded in package.json.
| "harnessFiles": [ | ||
| { | ||
| "path": ".codex/config.toml", | ||
| "content": "model_provider = \"agenta-openai\"\n\n[model_providers.agenta-openai]\nname = \"Agenta OpenAI\"\nenv_key = \"OPENAI_API_KEY\"\n" |
There was a problem hiding this comment.
This is the pinned wire contract for a codex /run (asserted by test_wire_contract.py on the Python side and wire-contract.test.ts on the runner side). Note the rendered config.toml content is the file-free managed provider block (model_provider plus [model_providers.agenta-openai] with env_key), with no auth.json. If a codex wire field or the rendered config changes, this golden and both contract tests must move together deliberately.
| }, | ||
| # X1: the CODEX harness on the local sandbox with a MANAGED vault key (mode "agenta", slug | ||
| # None -> the project's default OpenAI provider_key). Mirrors C3 (Pi local managed) but on the | ||
| # codex harness: managed auth is FILE-FREE (D-002 final ruling) — the SDK renders a custom |
There was a problem hiding this comment.
The codex release-gate cell (X1): codex harness, local sandbox, managed vault key. It mirrors the Pi local-managed cell C3 but exercises the runner-side tool gate (D-008), so approve/deny ride the runner-side pause seam rather than a codex-native ACP gate. chat/tool/commit/warm PASS; mcp/approve/deny/mount SKIP with codex-specific reasons. It is added to the tracked agent-release-gate skill so codex is covered on every future agent-runtime release.
|
@coderabbitai review |
✅ Action performedReview finished.
|
…lides The original deck described the config-leakage exposure as open; it was closed by the symlink assembly (D-002 amendment) and verified by the inverted probe, so the stale slides (which also carried a truncated fingerprint of the operator's credential file) are removed. Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
… codex in the services harness-catalog test The sandbox-agent npm package ships no bin entry (its CLI is a transitive optional dependency), so node_modules/.bin/sandbox-agent never exists in a fresh image and both Dockerfiles failed at the D-005 pin step. The pin now runs through a script that reuses daemon.ts's own binary resolution, the same path the runtime uses. The services catalog test asserts the exact harness set and now includes codex. Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
There was a problem hiding this comment.
Actionable comments posted: 20
🧹 Nitpick comments (1)
services/runner/tests/unit/sandbox-agent-codex-assets.test.ts (1)
259-282: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winTest coverage gap: dangling-symlink resume case.
This suite exercises "pre-existing real file" idempotency but not a pre-existing dangling symlink (mount briefly unattached across a resume) — the exact scenario in the
existsSync/EEXISTissue flagged oncodex-assets.tslines 165-173. Once that fix lands, consider adding a case here that pre-creates a dangling symlink atauth.jsonand assertssymlinkCodexSubscriptionAuthFiledoesn't throw.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: e08fe5eb-803c-4f82-b39e-62f1ea89381a
⛔ Files ignored due to path filters (3)
docs/design/codex-harness/reports/m1-playground-qa.mp4is excluded by!**/*.mp4docs/design/codex-harness/reports/m3-approvals-qa.mp4is excluded by!**/*.mp4docs/design/codex-harness/reports/m4-subscription-qa.mp4is excluded by!**/*.mp4
📒 Files selected for processing (106)
.agents/skills/add-harness/SKILL.md.agents/skills/add-harness/resources/LESSONS.md.agents/skills/agent-release-gate/resources/coverage.md.agents/skills/agent-release-gate/resources/qa_product.py.gitignoredocs/design/agent-workflows/documentation/ground-truth.mddocs/design/agent-workflows/interfaces/in-service/README.mddocs/design/agent-workflows/interfaces/in-service/backend-adapter.mddocs/design/agent-workflows/interfaces/in-service/harness-adapters.mddocs/design/agent-workflows/interfaces/in-service/neutral-runtime-dtos.mddocs/design/codex-harness/README.mddocs/design/codex-harness/context.mddocs/design/codex-harness/decisions.mddocs/design/codex-harness/design.mddocs/design/codex-harness/lane-split-plan.mddocs/design/codex-harness/plan.mddocs/design/codex-harness/reports/final-report.mddocs/design/codex-harness/reports/m0-report.mddocs/design/codex-harness/reports/m1-implementation-notes.mddocs/design/codex-harness/reports/m1-report.mddocs/design/codex-harness/reports/m2-implementation-notes.mddocs/design/codex-harness/reports/m2-report.mddocs/design/codex-harness/reports/m3-implementation-notes.mddocs/design/codex-harness/reports/m3-report.mddocs/design/codex-harness/reports/m3-status.mddocs/design/codex-harness/reports/m4-implementation-notes.mddocs/design/codex-harness/reports/m4-report.mddocs/design/codex-harness/reports/m5-implementation-notes.mddocs/design/codex-harness/research.mddocs/design/codex-harness/spike/auth-and-cleanup-research.mddocs/design/codex-harness/spike/config-leakage-findings.mddocs/design/codex-harness/spike/derisk-findings.mddocs/design/codex-harness/spike/findings.mddocs/design/codex-harness/spike/scenarios-auth/q1a-config.tomldocs/design/codex-harness/spike/scenarios-auth/q1a2-config.tomldocs/design/codex-harness/spike/scenarios-auth/q1b-config.tomldocs/design/codex-harness/spike/scripts/derisk-setup.shdocs/design/codex-harness/spike/scripts/drive.mjsdocs/design/codex-harness/spike/scripts/m2-qa.pydocs/design/codex-harness/spike/scripts/m3-qa.pydocs/design/codex-harness/spike/scripts/m4-tool-qa.pydocs/design/codex-harness/spike/scripts/m5-daytona-qa.pydocs/design/codex-harness/spike/scripts/mcp-echo-server.mjsdocs/design/codex-harness/spike/scripts/p1-two-sessions.mjsdocs/design/codex-harness/spike/scripts/p3-listener.mjsdocs/design/codex-harness/spike/scripts/p7-bwrap-matrix.shdocs/design/codex-harness/spike/scripts/p8-combo.mjsdocs/design/codex-harness/spike/scripts/p8-resume.mjsdocs/design/codex-harness/status.mddocs/docs/self-host/agents/01-use-your-own-subscription.mdxsdks/python/agenta/sdk/agents/__init__.pysdks/python/agenta/sdk/agents/adapters/__init__.pysdks/python/agenta/sdk/agents/adapters/codex_settings.pysdks/python/agenta/sdk/agents/adapters/harnesses.pysdks/python/agenta/sdk/agents/adapters/sandbox_agent.pysdks/python/agenta/sdk/agents/capabilities.pysdks/python/agenta/sdk/agents/data/codex_models.curated.jsonsdks/python/agenta/sdk/agents/dtos.pysdks/python/agenta/sdk/agents/model_catalog.pysdks/python/agenta/sdk/agents/utils/wire.pysdks/python/agenta/sdk/agents/wire_models.pysdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.pysdks/python/oss/tests/pytest/integration/agents/recordings/codex-agenta-tools-call.jsonsdks/python/oss/tests/pytest/integration/agents/test_codex_tool_replay.pysdks/python/oss/tests/pytest/unit/agents/adapters/test_codex_settings_layers.pysdks/python/oss/tests/pytest/unit/agents/connections/test_capabilities.pysdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.jsonsdks/python/oss/tests/pytest/unit/agents/test_capabilities_codex.pysdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.pysdks/python/oss/tests/pytest/unit/agents/test_harness_identity.pysdks/python/oss/tests/pytest/unit/agents/test_wire_contract.pyservices/runner/docker/Dockerfile.devservices/runner/docker/Dockerfile.ghservices/runner/package.jsonservices/runner/src/engines/sandbox_agent/acp-interactions.tsservices/runner/src/engines/sandbox_agent/client-tools.tsservices/runner/src/engines/sandbox_agent/codex-assets.tsservices/runner/src/engines/sandbox_agent/codex-mode.tsservices/runner/src/engines/sandbox_agent/daemon.tsservices/runner/src/engines/sandbox_agent/environment-setup.tsservices/runner/src/engines/sandbox_agent/environment.tsservices/runner/src/engines/sandbox_agent/executable-tools.tsservices/runner/src/engines/sandbox_agent/mcp.tsservices/runner/src/engines/sandbox_agent/run-plan.tsservices/runner/src/engines/sandbox_agent/run-turn.tsservices/runner/src/engines/sandbox_agent/runtime-contracts.tsservices/runner/src/permission-plan.tsservices/runner/src/protocol.tsservices/runner/src/server.tsservices/runner/src/tools/executable-tool-gate.tsservices/runner/src/tools/mcp-bridge.tsservices/runner/src/tools/tool-mcp-http.tsservices/runner/src/tracing/otel.tsservices/runner/tests/unit/client-tools.test.tsservices/runner/tests/unit/codex-mode.test.tsservices/runner/tests/unit/executable-tools.test.tsservices/runner/tests/unit/otel-codex-response-model.test.tsservices/runner/tests/unit/responder.test.tsservices/runner/tests/unit/sandbox-agent-acp-interactions.test.tsservices/runner/tests/unit/sandbox-agent-codex-assets.test.tsservices/runner/tests/unit/sandbox-agent-daemon.test.tsservices/runner/tests/unit/sandbox-agent-run-plan.test.tsservices/runner/tests/unit/session-keepalive-approval.test.tsservices/runner/tests/unit/tool-bridge.test.tsservices/runner/tests/unit/wire-contract.test.tsweb/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HarnessSelectControl.tsx
| | C4 | `pi_core` | `daytona` | `gpt-5.6-luna` | vault key (OpenAI) | Pi in a cloud sandbox; the remote-mount path that surfaced the silent file-loss finding (F-7). | | ||
| | P1 | `pi_core` | `local` | `openrouter/deepseek/deepseek-v4-flash` | vault key (OpenRouter) | OpenRouter as a first-class native provider. | | ||
| | S1 | `pi_core` | `local` | `gpt-5.6-luna` | subscription (Codex OAuth) | The ChatGPT/Codex subscription path via the sidecar, independent of any vault key. | | ||
| | X1 | `codex` | `local` | `gpt-5.6-luna` | vault key (OpenAI) | The native Codex harness with a managed key: the runner writes `auth.json` into `<cwd>/.codex` and codex authenticates from it. Codex gates tools runner-side, not with a codex-native ACP frame (D-008), so `approve`/`deny`/`mount` do not apply (see below). | |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the stale managed-auth design in both QA documents. Managed Codex authentication is file-free: the SDK renders a custom provider with env_key=OPENAI_API_KEY, and Codex reads the key from the daemon environment rather than from a runner-written auth.json.
.agents/skills/agent-release-gate/resources/coverage.md#L20-L20: replace theauth.jsonstatement with the file-freeenv_keyconfiguration behavior.docs/design/codex-harness/spike/scripts/m5-daytona-qa.py#L5-L10: update the docstring to state that no managed credential file is written in the Daytona VM.
📍 Affects 2 files
.agents/skills/agent-release-gate/resources/coverage.md#L20-L20(this comment)docs/design/codex-harness/spike/scripts/m5-daytona-qa.py#L5-L10
| - The runner drives one engine, the sandbox-agent ACP path (`engines/sandbox_agent.ts`). The | ||
| `harness` field selects the agent: `pi_core` and `pi_agenta` both drive the `pi` ACP agent, | ||
| `claude` drives `claude`. There is no engine selector on the wire. | ||
| - `PiHarness`, `ClaudeHarness`, and `AgentaHarness` exist and validate backend support. The | ||
| `claude` drives `claude`, `codex` drives `codex`. There is no engine selector on the wire. | ||
| - `PiHarness`, `ClaudeHarness`, `AgentaHarness`, and `CodexHarness` exist and validate backend | ||
| support. The |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Point the ground-truth references at services/runner.
These Codex runtime claims cite the old services/agent/src layout, while this integration lives under services/runner/src/engines/sandbox_agent/. Update the paths and split-engine description so the page remains verifiable.
| - **`CodexHarness`** drives the `codex` ACP agent. It delivers custom tools over the internal | ||
| `agenta-tools` MCP channel (like Claude) and renders `.codex/config.toml` (`codex_settings.py`). | ||
| Codex's default runtime mode is `agent-full-access`, so tool approvals are enforced runner-side at | ||
| the `agenta-tools` pause seam rather than by a codex-native ACP gate (decision D-008); authors can | ||
| override the mode with the typed `harnessMode` wire field. **Managed auth is file-free** (D-002 | ||
| final ruling): the adapter renders a custom `model_providers` block with `env_key = | ||
| "OPENAI_API_KEY"` into `config.toml`, and codex reads the key from the daemon env (delivered via | ||
| `secrets`) at request time; no credential file is written. Local subscription instead symlinks | ||
| `<cwd>/.codex/auth.json` to the operator's mounted OAuth login. `CODEX_HOME` is the durable | ||
| `<cwd>/.codex` on both local and Daytona (native rollouts persist, so native resume survives a | ||
| sandbox replacement); Codex SQLite state is redirected off that home via `CODEX_SQLITE_HOME` | ||
| (a geesefs constraint). |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Keep the four-harness contract consistent across this page.
The new Codex section leaves the preceding “three harnesses” wording and wire-shapes table stale, and the owned DTO list omits CodexAgentTemplate. Update those adjacent inventories so the page does not still present a three-harness contract.
Also applies to: 57-59
| ## Configuration: config.toml is the whole permission surface | ||
|
|
||
| Codex reads its configuration from `$CODEX_HOME/config.toml`. The author-facing | ||
| permission options are `approval_policy` (untrusted, on-request, on-failure, never) | ||
| and `sandbox_mode` (read-only, workspace-write, danger-full-access). Per-MCP-server | ||
| and per-tool approval controls exist too: `default_tools_approval_mode` on a server | ||
| (auto, prompt, writes, approve) and a per-tool `approval_mode` override, plus | ||
| `enabled_tools` and `disabled_tools` lists. | ||
|
|
||
| `codex_settings.py` mirrors `claude_settings.py` exactly in structure: | ||
|
|
||
| - **Layer 1 (author options, pass-through).** The harness's `permissions` slice | ||
| carries codex-native keys (`approval_policy`, `sandbox_mode`) verbatim into | ||
| `config.toml`. We invent no vocabulary. | ||
| - **Layer 2 (sandbox boundary reinforcement).** Filesystem `readonly`/`off` maps to | ||
| `sandbox_mode = "read-only"` unless the author set something stricter. Network | ||
| restrictions map to disabling codex's web tools where config allows. | ||
| - **Layer 3 (per-server and per-tool rules).** Each user MCP server's permission maps | ||
| to that server's `default_tools_approval_mode` (allow → approve, ask → prompt, | ||
| deny → the server's tools go into `disabled_tools`). Each resolved executable | ||
| tool's permission maps to a per-tool `approval_mode` under the `agenta-tools` | ||
| server entry. The spike proved the critical case: a server set to `approve` runs | ||
| its tools with zero permission gates even under `approval_policy = "untrusted"`, | ||
| which is the Codex analog of the Claude per-tool allow rule (the F-046 requirement: | ||
| an "allow" tool must run without pausing). | ||
|
|
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Sync permission and approval semantics with final D-008.
This still describes rendered [mcp_servers.*] approval tables and unchanged Codex park/resume behavior. D-008 removed those tables and makes the default agent-full-access path rely on runner-side agenta-tools gating; only real ACP permission gates use live keep-alive, while tool approvals use cold replay.
Also applies to: 79-103
| ## Credentials and the home directory (D-002, Checkpoint 1) | ||
|
|
||
| Codex accepts three credential forms (all daemon-path proven): an `auth.json` | ||
| containing the API key (works with no environment variable at all), an environment | ||
| key plus the adapter's `DEFAULT_AUTH_REQUEST` auto-login, and an `auth.json` | ||
| containing ChatGPT OAuth tokens. The simplest managed-key setup is pre-seeding | ||
| `auth.json`; that is what the runner will do, with the same create-if-absent, | ||
| restrictive-mode, delete-only-if-created discipline `pi-assets.ts` uses today. | ||
|
|
||
| The open layout question is where `CODEX_HOME` points in each mode; the options and | ||
| the recommendation are in D-002. The subscription mode additionally interacts with | ||
| per-run config delivery (the mount holds the operator's own `config.toml`), covered | ||
| in the same decision. The adapter offers one more delivery channel that may resolve | ||
| it cleanly: `CODEX_CONFIG`, a JSON object in the environment merged into every | ||
| session's config, proven able to loosen as well as tighten. Whether the runner can | ||
| scope that environment variable per run (given daemon session pooling) is marked TO | ||
| VERIFY early in Milestone 1. |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Remove the superseded credential and home-directory design.
These sections still prescribe pre-seeding auth.json, leave CODEX_HOME unresolved, and describe a per-run home. D-002’s final ruling is file-free managed auth via env_key, durable CODEX_HOME on local and Daytona, and off-mount CODEX_SQLITE_HOME; stale instructions could reintroduce durable credential files or lose native resume.
Also applies to: 114-121
| // Managed Codex is file-free (the SDK renders a custom provider with env_key OPENAI_API_KEY into | ||
| // <cwd>/.codex/config.toml; codex reads the key from the daemon env at request time), so there is | ||
| // nothing to write. A local subscription run still needs the operator's OAuth token file, so | ||
| // symlink <cwd>/.codex/auth.json to the mounted login now, after the durable cwd mount (linking | ||
| // before it would be shadowed). Non-Codex runs, managed runs, and Daytona are no-ops. | ||
| if (isSubscriptionCodexRun(plan)) | ||
| symlinkCodexSubscriptionAuthFile(plan, logger); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Fail closed on a pre-existing Codex auth.json.
symlinkCodexSubscriptionAuthFile exits when the workspace file already exists. Since the prior managed flow could leave an auth.json on durable storage, a subscription resume can silently authenticate with a stale managed credential instead of the operator mount. Verify the existing entry is the expected symlink; otherwise remove only a known stale runner file or fail with a migration error.
| executableToolGate: | ||
| !plan.isPi && !plan.isDaytona ? deferredExecutableToolGate : undefined, |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Enforce executable-tool policy on the Daytona transport.
Daytona explicitly receives no executableToolGate, while its stdio MCP route is constructed without one. Consequently, executable tools can bypass configured ask and deny policy in Daytona runs.
services/runner/src/engines/sandbox_agent/environment.ts#L930-L931: pass the deferred executable-tool gate for Daytona too.services/runner/src/engines/sandbox_agent/mcp.ts#L339-L349: route stdio-shim executable calls through the same gate, including pause and deny behavior, before relay execution.
📍 Affects 2 files
services/runner/src/engines/sandbox_agent/environment.ts#L930-L931(this comment)services/runner/src/engines/sandbox_agent/mcp.ts#L339-L349
| let callId: string | undefined; | ||
| if (executableToolGate) { | ||
| try { | ||
| assertRequiredArguments(spec, params?.arguments); | ||
| } catch (err) { | ||
| return mcpToolError(id, err); | ||
| } | ||
| callId = randomUUID(); | ||
| const verdict = await executableToolGate.onExecutableTool({ | ||
| id: callId, | ||
| toolCallId: callId, | ||
| toolName: spec.name, | ||
| input: params?.arguments, | ||
| spec, | ||
| }); | ||
| if (verdict.kind === "deny") { | ||
| return mcpToolError(id, new Error(verdict.reason)); | ||
| } | ||
| if (verdict.kind === "pendingApproval") { | ||
| executableToolGate.onPause?.(); | ||
| return MCP_PAUSED; | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| // The channel holds only public metadata; execution relays to the runner via the relay | ||
| // dir, where the private spec + callback auth are applied server-side. A unique id per | ||
| // call keeps parallel calls from colliding. | ||
| const text = await runResolvedTool(spec, params?.arguments, { | ||
| toolCallId: randomUUID(), | ||
| toolCallId: callId ?? randomUUID(), |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Apply the executable approval gate on Daytona’s stdio relay path.
This hook protects only the local HTTP MCP server. buildSessionMcpServers forwards executableToolGate only in its !isDaytona branch; Daytona advertises the stdio shim without it. Consequently, Codex executable tools on Daytona skip this runner-side ask/pause seam—the required fallback when Codex runs in full-access mode. Thread the same gate into the shim relay before dispatching executable calls.
| const responses = await Promise.all( | ||
| parsed.map((m) => | ||
| handle(m, specByName, specs, relayDir, clientToolRelay, log), | ||
| handle( | ||
| m, | ||
| specByName, | ||
| specs, | ||
| relayDir, | ||
| clientToolRelay, | ||
| executableToolGate, | ||
| log, | ||
| ), | ||
| ), | ||
| ); | ||
| // A paused client tool in the batch aborts the whole request (no result for any). | ||
| // A paused tool in the batch aborts the whole request (no result for any). | ||
| if (responses.some((r) => r === MCP_PAUSED)) { | ||
| abortPaused(res); | ||
| return; |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Do not dispatch sibling batch calls before approval resolves.
Promise.all invokes every tools/call concurrently. If one executable call returns pendingApproval, another batch item may already have passed its gate and dispatched before the socket is aborted. Reject gated executable batches up front, or preflight all calls without dispatching any side effects.
| it("parks and resumes a Codex ACP gate exactly like the Claude gate", async () => { | ||
| const { engine, calls } = makeApprovalEngine([ | ||
| { | ||
| approvalPause: { | ||
| permissionId: "perm-codex", | ||
| toolCallId: "tc-gate", | ||
| toolName: "pnpm test", | ||
| gateType: "codex-acp-permission", | ||
| }, | ||
| toolCallIds: ["tc-gate"], | ||
| }, | ||
| ]); | ||
| const ctx = makeCtx(engine); | ||
|
|
||
| const r1 = await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); | ||
| assert.equal(r1.stopReason, "paused"); | ||
| assert.equal(ctx.pool.get(POOL_KEY)!.state, "awaiting_approval"); | ||
|
|
||
| const r2 = await runWithKeepalive( | ||
| approveResume(true), | ||
| undefined, | ||
| undefined, | ||
| ctx, | ||
| ); | ||
| assert.equal(r2.ok, true); | ||
| assert.equal(calls.acquire, 1, "the resume did NOT re-acquire cold"); | ||
| assert.deepEqual(calls.resumes, [ | ||
| { | ||
| permissionId: "perm-codex", | ||
| reply: "once", | ||
| toolCallId: "tc-gate", | ||
| }, | ||
| ]); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use a Codex request in the Codex keep-alive test.
Both requests default to harness: "claude", so this verifies generic gate-type handling rather than the Codex request path named by the test.
Proposed fix
- const r1 = await runWithKeepalive(pauseTurn(), undefined, undefined, ctx);
+ const r1 = await runWithKeepalive(
+ { ...pauseTurn(), harness: "codex", harnessMode: "agent" },
+ undefined,
+ undefined,
+ ctx,
+ );
...
- approveResume(true),
+ approveResume(true, { harness: "codex", harnessMode: "agent" }),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| it("parks and resumes a Codex ACP gate exactly like the Claude gate", async () => { | |
| const { engine, calls } = makeApprovalEngine([ | |
| { | |
| approvalPause: { | |
| permissionId: "perm-codex", | |
| toolCallId: "tc-gate", | |
| toolName: "pnpm test", | |
| gateType: "codex-acp-permission", | |
| }, | |
| toolCallIds: ["tc-gate"], | |
| }, | |
| ]); | |
| const ctx = makeCtx(engine); | |
| const r1 = await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); | |
| assert.equal(r1.stopReason, "paused"); | |
| assert.equal(ctx.pool.get(POOL_KEY)!.state, "awaiting_approval"); | |
| const r2 = await runWithKeepalive( | |
| approveResume(true), | |
| undefined, | |
| undefined, | |
| ctx, | |
| ); | |
| assert.equal(r2.ok, true); | |
| assert.equal(calls.acquire, 1, "the resume did NOT re-acquire cold"); | |
| assert.deepEqual(calls.resumes, [ | |
| { | |
| permissionId: "perm-codex", | |
| reply: "once", | |
| toolCallId: "tc-gate", | |
| }, | |
| ]); | |
| }); | |
| it("parks and resumes a Codex ACP gate exactly like the Claude gate", async () => { | |
| const { engine, calls } = makeApprovalEngine([ | |
| { | |
| approvalPause: { | |
| permissionId: "perm-codex", | |
| toolCallId: "tc-gate", | |
| toolName: "pnpm test", | |
| gateType: "codex-acp-permission", | |
| }, | |
| toolCallIds: ["tc-gate"], | |
| }, | |
| ]); | |
| const ctx = makeCtx(engine); | |
| const r1 = await runWithKeepalive( | |
| { ...pauseTurn(), harness: "codex", harnessMode: "agent" }, | |
| undefined, | |
| undefined, | |
| ctx, | |
| ); | |
| assert.equal(r1.stopReason, "paused"); | |
| assert.equal(ctx.pool.get(POOL_KEY)!.state, "awaiting_approval"); | |
| const r2 = await runWithKeepalive( | |
| approveResume(true, { harness: "codex", harnessMode: "agent" }), | |
| undefined, | |
| undefined, | |
| ctx, | |
| ); | |
| assert.equal(r2.ok, true); | |
| assert.equal(calls.acquire, 1, "the resume did NOT re-acquire cold"); | |
| assert.deepEqual(calls.resumes, [ | |
| { | |
| permissionId: "perm-codex", | |
| reply: "once", | |
| toolCallId: "tc-gate", | |
| }, | |
| ]); | |
| }); |
Railway Preview Environment
|
| bases chained per the repo's stacked-PR conventions). The alternative | ||
| concern-split reads better per-PR but has five files spanning lanes, which is | ||
| the known-painful GitButler case. Recommendation: area split. | ||
| 3. **Follow-ups outside this branch**: the Daytona snapshot ships an older Codex |
There was a problem hiding this comment.
We need to create an issue for this
There was a problem hiding this comment.
Done, both follow-ups are now tracked issues:
- [runner] Daytona sandbox snapshot ships an older Codex than the runner pin #5537 — the Daytona sandbox snapshot ships an older Codex than the runner pin (the snapshot recipe needs the same
install-agent codex --agent-process-versionpin; the recipe lives outside this repo, so the issue tracks the coordination). - (bug) Managed connection with an explicit slug fails "connection not found" on agent invoke (slug-None works) #5499 — the pre-existing managed-connection resolver bug (explicit provider slugs fail deployment-wide, harness-independent) was already filed during the build.
| `CODEX_SQLITE_HOME = <tmpdir>/agenta/codex-sqlite/<basename(cwd)>` (a local off-mount dir created | ||
| before the daemon starts, cleaned best-effort on destroy). The path is derived from `basename(cwd)` | ||
| (per-session-stable, like `relayDir`) so it does not enter the config fingerprint and warm daemon | ||
| reuse is preserved. This is parity-or-better with Claude (P8c: Claude keeps its SQLite-free state |
There was a problem hiding this comment.
I didn't know about this. What do we lose when a Daytona sandbox is removed and restarted with session/reload when we don't have the SQL lite DBS both in codex and Claude code. What functionalities do we lose or states?
There was a problem hiding this comment.
Researched this from the codex source at the pinned tag (rust-v0.145.0) and the Claude mount contract. Short answer: nothing user-observable is lost for either harness. Conversation history and resume do not live in SQLite for either one.
Codex keeps five SQLite families under CODEX_SQLITE_HOME, and each is a cache or a niche feature:
state: a path-lookup cache over thesessions/rollout files, with an explicit fallback that rescans the directory when the DB is missing, plus a startup backfill that rebuilds it. Losing it costs a directory scan, not data.thread_history: a projection of the rollouts; re-materializes from the rollout files from byte 0.goals: state for codex's/goalfeature. This is the only genuinely unrecoverable state, and nothing in our runner or playground uses goals.memories: pipeline state for a feature that is default-OFF and unset in our config; the consolidated memories themselves are plain files underCODEX_HOME/memories/, which sits on the durable mount and survives.logs: a tracing sink read only by thecodex feedbackcommand.
What resume actually reads is the sessions/ rollout JSONL (verified chain: loadSession → thread/resume → RolloutRecorder::load_rollout_items), and that stays on the durable home. So a sandbox replacement costs codex some caches and the unused goals feature, nothing else.
Claude Code has no SQLite at all. On Daytona we mount only ~/.claude/projects durable, which carries the transcript JSONL that session/load + native resume read, and the auto-memory directory. What dies with the sandbox (command history, todo lists, caches) is the same cosmetic class.
So both harnesses run under the same durability contract: the conversation state and memory survive replacement; caches and interactive-only niceties do not. Full write-up with file:line citations is in the session notes; I can commit it to the design workspace if you want it on record.
|
|
||
| ## What you can do right now | ||
|
|
||
| Open the worktree deployment at http://144.76.237.122:8180, create an agent, and |
There was a problem hiding this comment.
Never mention the IP in public prs
There was a problem hiding this comment.
You are right, and it is fixed:
- All five occurrences of the deployment address in the committed reports (
m0-report.md,m1-report.md,m1/m2-implementation-notes.md,status.md) are replaced withhttp://<dev-host>:8180in commit 6ff052e (lands with the conflict-resolution push). - I checked the three committed QA recordings frame-by-frame: they are cropped app captures and slide frames, no browser URL bar, so the address never appears in them.
- The rule is now encoded in the add-harness playbook (LESSONS entry: reports are public the moment the PR opens; write placeholders from the first draft and check recordings for the URL bar).
One caveat: the old value is still reachable through this PR's earlier commits and diff history. If you want it fully gone, I can rewrite the branch history before merge; otherwise treat the address as exposed and rely on the box's auth/firewall.
| gpt-5.6-luna` but `ag.meta.response.model = None` and `ag.data.parameters.model = None`, and the | ||
| Codex ACP usage carries no cost. So the cost calc finds no model in the fields it reads -> $0.00. | ||
|
|
||
| Real fix: emit `gen_ai.response.model` on the Codex LLM span (parity with Pi). Scoped to Codex so |
There was a problem hiding this comment.
We should first search for hotel autoinstrumentation solutions for codex that retain price. There is certainly a bunch. Litellm does not probably take care of caching computation etc
There was a problem hiding this comment.
Researched this (OTel = OpenTelemetry autoinstrumentation, reading "hotel" as dictation). Short version: nothing off the shelf instruments the Codex CLI, but you are right about the cache math, and the correct fix is small and in our own pipeline. Filed as #5540.
What exists out there: Codex's own [otel] config exports log events and token metrics (including a cached-input histogram) but no spans and no cost. OpenLLMetry and OpenInference are in-process SDK instrumentors, so they cannot see inside the codex binary, and they do not price anything. Langfuse and Phoenix price server-side from token buckets, exactly the way our platform already does. So there is no ready-made "codex with prices" integration to adopt.
Where you are right — litellm and caching: our calculate_costs calls litellm.cost_per_token with only prompt/completion counts, so cached tokens are ignored. On a real warm codex turn from the spike transcripts (93 non-cached + 13,463 cached input tokens) we compute $0.000855 vs a true ~$0.0076: understated about 9x when the cache is warm. But litellm itself is NOT the limitation: the shipped 1.92.0 accepts cache_read_input_tokens/cache_creation_input_tokens and its codex model entries carry cache_read_input_token_cost at the 90% discount. We simply never pass them.
The gap is ours, in two places: the codex ACP usage already delivers cachedReadTokens/cachedWriteTokens/thoughtTokens, but the runner's usage merge keeps only input/output (this drops cache detail for Claude too); and the Pi path already emits the right gen_ai.usage.cache_read.input_tokens attributes that our ingestion maps — the ACP path just doesn't. Fix: ~15 lines in the runner + ~10 lines in calculate_costs. Details, caveats (litellm expects prompt to INCLUDE cached; ACP buckets are disjoint), and the survey are in #5540. I'd do it as a follow-up PR, not in this one.
|
|
||
| ## Resume semantics (surfaced, approved) | ||
|
|
||
| The runner-side MCP-seam gate (B) cannot use the ACP keep-alive resume that Claude/Pi use: there is |
There was a problem hiding this comment.
That is totally unexceptable and should have been explicitly called out in the pr description and not hidden. Cold approval behavior is very different from warm approval (see docs with research about this). Let's research why can't we do this ? Is it the ACP code? The ACP version? Why does this work in Zed ? Let's check their implementation
There was a problem hiding this comment.
You are right on the process point. This belonged in the PR description, not buried in milestone notes; I have now added a "Known limitation" section to the description with the fix plan. On the substance, I researched it from source: codex-acp at our v1.1.7 pin, codex at rust-v0.145.0, and Zed's approval flow. The full findings doc has ~60 file:line citations; here is the answer.
Why the approval is cold today. Two stacked causes, and neither is the ACP protocol or codex itself.
- Codex core accepts approval policy and sandbox policy as two independent per-turn parameters. The coupling lives in the codex-acp bridge: it ships exactly three preset modes, and the
agent-full-accesspreset hardcodesapprovalPolicy: "never"next to the unsandboxed setting (src/AgentMode.ts:48-55), re-sent on every turn (src/CodexAcpClient.ts:703-712), overriding any config. Under "never", codex auto-approves every gate, so no permission request ever reaches the runner's keep-alive park. The park machinery itself already supports codex: thecodex-acp-permissiongate type parks warm, proven in the m3 agent-mode QA. - With codex's native gates off, the only seam left is our loopback MCP
tools/call, and the runner aborts that socket when the turn pauses instead of holding it open. Holding it is technically possible: codex waits 300 seconds for a tool call by default (tool_timeout_sec, verified in source) with the turn fully alive. We never built that path.
Why it works in Zed. Same bridge, same protocol, nothing we failed to use. Zed runs the default agent mode, so approvals are on-request: codex raises a native permission request and the turn blocks on the human answer, in place. Zed can afford agent mode because on a desktop codex's inner OS sandbox (seatbelt on macOS, bubblewrap on Linux) initializes. In our containers bubblewrap cannot initialize (probe P7), so agent mode turns every write-ish shell command into sandbox-failure noise. That pushed our default to agent-full-access, and that preset also turns approvals off. We lost warm approvals to the mode we needed for shell texture.
Is it the ACP version? No. codex-acp HEAD is byte-identical to 1.1.7 in the relevant files. The upstream decoupling ask (agentclientprotocol/codex-acp#310) is open with no assignee and no linked PR; the only comment on it is our +1.
Fix options:
- (c) Patch codex-acp at our pin step so full-access sends
on-requestinstead ofnever(a one-line preset change, or a fourth mode to leave stock behavior untouched). Verified in codex source that the decoupled pair does the right thing: shell stays gate-free because exec approval only triggers when the filesystem sandbox is restricted, and MCP (Agenta) tools raise native gates that ride the already-built, already-QA'd warm keep-alive park. File the same change upstream so the patch retires itself on an accepted release. Follow-ups: advertisereadOnlyHintannotations on our loopbacktools/listso allow-tools pre-approve, one long-hold probe, and a re-run of the gate matrix. - (a) Hold the loopback
tools/callopen (warm park at the MCP seam). Harness-independent: gives warm approvals to ANY MCP-only harness without touching its bridge, and upgrades reject UX (the model learns of a denial in-turn). But it is a bigger runner change with a 300-second timeout race and held-socket edge cases. Good roadmap item; should not gate codex. - (b) Default
agentmode: warm today, unacceptable shell noise in containers. - (d) Wait for upstream: not a plan on its own.
Recommendation: (c) now, plus the upstream PR, with (a) on the roadmap. It restores codex's native permission plane, so warm approvals reuse the exact machinery Claude parks use today, and the human decision happens before the tool call is issued, so the 300-second MCP timeout never enters the picture. This changes the approved D-008 posture ("runner-gated, cold resume") to "codex-native warm gates via a patched bridge", so I am treating it as your decision and will not implement before your go.
| answers an ACP permission id via `session.respondPermission`. The loopback MCP seam has NO ACP | ||
| permission id, and its `tools/call` is a synchronous HTTP request that dies when the turn parks | ||
| (the socket is aborted, exactly like the existing client-tool pause). So B's resume is necessarily | ||
| COLD-REPLAY (model re-issues the call next turn; `ApprovalResponder.onPermission` + the existing |
There was a problem hiding this comment.
Understood, the cold-replay resume is rejected as an end state. The full root-cause analysis and fix plan are in my reply on your m3-implementation-notes comment. One-paragraph version: the cold behavior comes from one hardcoded preset line in the codex-acp bridge (agent-full-access couples approvals to "never"), not from the protocol or from codex core, and the recommended fix is to patch that preset at our pinned install (plus send the same fix upstream, agentclientprotocol/codex-acp#310) so ask-approvals become codex-native permission requests that ride the warm keep-alive park we already built and QA'd for codex agent mode. Awaiting your go before implementing, since it amends D-008.
…n committed reports; add the public-safety lesson Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
…codex-harness Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
…, file-free managed auth) Claude-Session: https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9
What this adds
Codex is now a first-class harness in Agenta, at parity with Claude wherever Codex can express the feature. From the playground you can pick Codex, stream a multi-turn conversation, and run it two ways: on a managed API key, or on your own ChatGPT/Codex subscription with no API key present anywhere. Agenta tools deliver to Codex over the internal MCP channel and execute with full tracing and correct cost reporting. Approvals work end to end: allow runs without a pause, ask surfaces a real approval card and resumes with context intact, deny refuses cleanly. Managed-key runs work on real Daytona sandboxes. A release-gate cell, a pinned bridge version, an offline replay test, and contract tests on both sides of the wire guard it going forward.
How it works (the parts worth knowing before reading the diff)
File-free managed credentials (D-002 final ruling). Codex normally wants a credential file (
auth.json) on disk, which on Daytona means a real key landing in durable S3 storage that teardown ordering cannot reliably delete. Instead, the SDK renders a custommodel_providersblock intoconfig.tomlwithenv_key = "OPENAI_API_KEY". Codex reads the key from the daemon's process environment at request time, so no credential file is ever written, in either local or Daytona managed mode. This also composes by construction with the Daytona Secrets placeholder design (#5223/#5277): the placeholder lands in the same process environment theenv_keyreads, and Codex copies it byte-exact into the request header (verified opaque). Both managedauth.jsonwriters and every cleanup backstop are deleted, including a discovered ordering bug where the local backstop ran after storage unmounted and stranded the file.Runner-side tool gate (D-008). Codex's ACP bridge sends its per-turn mode preset, which overrides any
config.tomlapproval policy; under theagent-full-accessdefault no Codex-side config can restore tool gates. So Agenta-tool approvals are enforced runner-side at theagenta-toolsloopback MCP pause seam, harness-independent, the same seam client tools already use. Theaskpause uses the cold-replay pattern (the synchronoustools/callhas no ACP permission id and cannot outlive a paused turn), so a Codex ask-approval behaves like a client-tool approval. Authors can opt a single agent into Codex's own gatedagentmode via a typedharnessModewire field (mirroringmodel).Durable home + in-VM SQLite layout.
CODEX_HOME = <cwd>/.codexstays on the durable mount so Codex's nativesessions/rollouts and native resume survive sandbox replacement. Codex's SQLite state uses write-ahead logging that the geesefs S3 mount cannot support, soCODEX_SQLITE_HOMEpoints those families at a local in-VM directory off the mount. A probe confirmed this moves exactly the wedging files and resume rides the plain rollout files.Subscription mode. The operator mounts their own Codex login directory. Only
auth.jsonis symlinked into it (not the whole directory): a leak of the operator's personal[mcp_servers.*]config into product runs was found and closed by this symlink layout, proven closed by an inverted probe. Token refresh flows through to the real login, which QA proved untouched by hash.Known limitation: an ask-approval pauses cold, not live (fix path identified)
Under the default full-access mode, an
askapproval on an Agenta tool ends the turn: the approval card appears, and after you approve, the NEXT turn re-issues the tool call and completes it (context intact, verified warm and cold). It does not hold the turn open and resume in place the way a Claude ask-approval does. This should have been stated here from the start; review rightly flagged it.Root cause, established from source: codex itself accepts approval policy and sandbox mode as independent settings, but the codex-acp bridge's
agent-full-accesspreset hardcodes approvals toneverand re-sends that every turn, so codex never raises the permission request our warm keep-alive park (already built and QA'd for codexagentmode) would ride. The fix path: patch the preset at our pinned bridge install so full access sendson-request(shell stays gate-free because exec approval only fires when the sandbox is restricted; Agenta tools gate natively and park warm), and file the same change upstream (agentclientprotocol/codex-acp#310) so the patch retires itself. Held for a maintainer ruling since it amends D-008; the full analysis lives in the review thread onm3-implementation-notes.md.Testing
docs/design/codex-harness/reports/m1-playground-qa.mp4,m3-approvals-qa.mp4,m4-subscription-qa.mp4).Notes for the reviewer
docs/design/codex-harness/;decisions.mdis the decision register (every non-obvious choice with its ruling and amendments; D-002 credentials, D-008 tool gate, D-009 this single-PR ruling).https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9