Skip to content

feat(agents): add Codex as a first-class harness - #5509

Open
mmabrouk wants to merge 40 commits into
release/v0.106.1from
feat/codex-harness
Open

feat(agents): add Codex as a first-class harness#5509
mmabrouk wants to merge 40 commits into
release/v0.106.1from
feat/codex-harness

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 25, 2026

Copy link
Copy Markdown
Member

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 custom model_providers block into config.toml with env_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 the env_key reads, and Codex copies it byte-exact into the request header (verified opaque). Both managed auth.json writers 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.toml approval policy; under the agent-full-access default no Codex-side config can restore tool gates. So Agenta-tool approvals are enforced runner-side at the agenta-tools loopback MCP pause seam, harness-independent, the same seam client tools already use. The ask pause uses the cold-replay pattern (the synchronous tools/call has 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 gated agent mode via a typed harnessMode wire field (mirroring model).

Durable home + in-VM SQLite layout. CODEX_HOME = <cwd>/.codex stays on the durable mount so Codex's native sessions/ rollouts and native resume survive sandbox replacement. Codex's SQLite state uses write-ahead logging that the geesefs S3 mount cannot support, so CODEX_SQLITE_HOME points 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.json is 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 ask approval 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-access preset hardcodes approvals to never and re-sends that every turn, so codex never raises the permission request our warm keep-alive park (already built and QA'd for codex agent mode) would ride. The fix path: patch the preset at our pinned bridge install so full access sends on-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 on m3-implementation-notes.md.

Testing

  • Suites green: 696 SDK agent unit tests, 1248 runner tests (81 files), runner typecheck, ruff format + check, and the golden wire contract.
  • Live QA per milestone, each producing a regression test or structural fix: managed-key playground (M1), tools + tracing + cost (M2), approvals allow/ask/deny warm and cold (M3), subscription with the leak-closure probe (M4), Daytona managed + release-gate cell (M5).
  • Three watchable QA recordings live in the design workspace (docs/design/codex-harness/reports/m1-playground-qa.mp4, m3-approvals-qa.mp4, m4-subscription-qa.mp4).
  • An upstream ask to decouple approval policy from the full-access preset is filed as a comment on codex-acp issue improve delete rows in testset #293  #310.

Notes for the reviewer

https://claude.ai/code/session_01TNqjpdGV3SBZUazJj7AgA9

mmabrouk added 30 commits July 24, 2026 19:45
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
…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).
… 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.
…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.
…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
…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
… 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(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.")) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread services/runner/docker/Dockerfile.gh Outdated
# 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

🧹 Nitpick comments (1)
services/runner/tests/unit/sandbox-agent-codex-assets.test.ts (1)

259-282: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Test 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/EEXIST issue flagged on codex-assets.ts lines 165-173. Once that fix lands, consider adding a case here that pre-creates a dangling symlink at auth.json and asserts symlinkCodexSubscriptionAuthFile doesn't throw.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e08fe5eb-803c-4f82-b39e-62f1ea89381a

📥 Commits

Reviewing files that changed from the base of the PR and between cb095f7 and ecb0d13.

⛔ Files ignored due to path filters (3)
  • docs/design/codex-harness/reports/m1-playground-qa.mp4 is excluded by !**/*.mp4
  • docs/design/codex-harness/reports/m3-approvals-qa.mp4 is excluded by !**/*.mp4
  • docs/design/codex-harness/reports/m4-subscription-qa.mp4 is 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
  • .gitignore
  • docs/design/agent-workflows/documentation/ground-truth.md
  • docs/design/agent-workflows/interfaces/in-service/README.md
  • docs/design/agent-workflows/interfaces/in-service/backend-adapter.md
  • docs/design/agent-workflows/interfaces/in-service/harness-adapters.md
  • docs/design/agent-workflows/interfaces/in-service/neutral-runtime-dtos.md
  • docs/design/codex-harness/README.md
  • docs/design/codex-harness/context.md
  • docs/design/codex-harness/decisions.md
  • docs/design/codex-harness/design.md
  • docs/design/codex-harness/lane-split-plan.md
  • docs/design/codex-harness/plan.md
  • docs/design/codex-harness/reports/final-report.md
  • docs/design/codex-harness/reports/m0-report.md
  • docs/design/codex-harness/reports/m1-implementation-notes.md
  • docs/design/codex-harness/reports/m1-report.md
  • docs/design/codex-harness/reports/m2-implementation-notes.md
  • docs/design/codex-harness/reports/m2-report.md
  • docs/design/codex-harness/reports/m3-implementation-notes.md
  • docs/design/codex-harness/reports/m3-report.md
  • docs/design/codex-harness/reports/m3-status.md
  • docs/design/codex-harness/reports/m4-implementation-notes.md
  • docs/design/codex-harness/reports/m4-report.md
  • docs/design/codex-harness/reports/m5-implementation-notes.md
  • docs/design/codex-harness/research.md
  • docs/design/codex-harness/spike/auth-and-cleanup-research.md
  • docs/design/codex-harness/spike/config-leakage-findings.md
  • docs/design/codex-harness/spike/derisk-findings.md
  • docs/design/codex-harness/spike/findings.md
  • docs/design/codex-harness/spike/scenarios-auth/q1a-config.toml
  • docs/design/codex-harness/spike/scenarios-auth/q1a2-config.toml
  • docs/design/codex-harness/spike/scenarios-auth/q1b-config.toml
  • docs/design/codex-harness/spike/scripts/derisk-setup.sh
  • docs/design/codex-harness/spike/scripts/drive.mjs
  • docs/design/codex-harness/spike/scripts/m2-qa.py
  • docs/design/codex-harness/spike/scripts/m3-qa.py
  • docs/design/codex-harness/spike/scripts/m4-tool-qa.py
  • docs/design/codex-harness/spike/scripts/m5-daytona-qa.py
  • docs/design/codex-harness/spike/scripts/mcp-echo-server.mjs
  • docs/design/codex-harness/spike/scripts/p1-two-sessions.mjs
  • docs/design/codex-harness/spike/scripts/p3-listener.mjs
  • docs/design/codex-harness/spike/scripts/p7-bwrap-matrix.sh
  • docs/design/codex-harness/spike/scripts/p8-combo.mjs
  • docs/design/codex-harness/spike/scripts/p8-resume.mjs
  • docs/design/codex-harness/status.md
  • docs/docs/self-host/agents/01-use-your-own-subscription.mdx
  • sdks/python/agenta/sdk/agents/__init__.py
  • sdks/python/agenta/sdk/agents/adapters/__init__.py
  • sdks/python/agenta/sdk/agents/adapters/codex_settings.py
  • sdks/python/agenta/sdk/agents/adapters/harnesses.py
  • sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py
  • sdks/python/agenta/sdk/agents/capabilities.py
  • sdks/python/agenta/sdk/agents/data/codex_models.curated.json
  • sdks/python/agenta/sdk/agents/dtos.py
  • sdks/python/agenta/sdk/agents/model_catalog.py
  • sdks/python/agenta/sdk/agents/utils/wire.py
  • sdks/python/agenta/sdk/agents/wire_models.py
  • sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py
  • sdks/python/oss/tests/pytest/integration/agents/recordings/codex-agenta-tools-call.json
  • sdks/python/oss/tests/pytest/integration/agents/test_codex_tool_replay.py
  • sdks/python/oss/tests/pytest/unit/agents/adapters/test_codex_settings_layers.py
  • sdks/python/oss/tests/pytest/unit/agents/connections/test_capabilities.py
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json
  • sdks/python/oss/tests/pytest/unit/agents/test_capabilities_codex.py
  • sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py
  • sdks/python/oss/tests/pytest/unit/agents/test_harness_identity.py
  • sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py
  • services/runner/docker/Dockerfile.dev
  • services/runner/docker/Dockerfile.gh
  • services/runner/package.json
  • services/runner/src/engines/sandbox_agent/acp-interactions.ts
  • services/runner/src/engines/sandbox_agent/client-tools.ts
  • services/runner/src/engines/sandbox_agent/codex-assets.ts
  • services/runner/src/engines/sandbox_agent/codex-mode.ts
  • services/runner/src/engines/sandbox_agent/daemon.ts
  • services/runner/src/engines/sandbox_agent/environment-setup.ts
  • services/runner/src/engines/sandbox_agent/environment.ts
  • services/runner/src/engines/sandbox_agent/executable-tools.ts
  • services/runner/src/engines/sandbox_agent/mcp.ts
  • services/runner/src/engines/sandbox_agent/run-plan.ts
  • services/runner/src/engines/sandbox_agent/run-turn.ts
  • services/runner/src/engines/sandbox_agent/runtime-contracts.ts
  • services/runner/src/permission-plan.ts
  • services/runner/src/protocol.ts
  • services/runner/src/server.ts
  • services/runner/src/tools/executable-tool-gate.ts
  • services/runner/src/tools/mcp-bridge.ts
  • services/runner/src/tools/tool-mcp-http.ts
  • services/runner/src/tracing/otel.ts
  • services/runner/tests/unit/client-tools.test.ts
  • services/runner/tests/unit/codex-mode.test.ts
  • services/runner/tests/unit/executable-tools.test.ts
  • services/runner/tests/unit/otel-codex-response-model.test.ts
  • services/runner/tests/unit/responder.test.ts
  • services/runner/tests/unit/sandbox-agent-acp-interactions.test.ts
  • services/runner/tests/unit/sandbox-agent-codex-assets.test.ts
  • services/runner/tests/unit/sandbox-agent-daemon.test.ts
  • services/runner/tests/unit/sandbox-agent-run-plan.test.ts
  • services/runner/tests/unit/session-keepalive-approval.test.ts
  • services/runner/tests/unit/tool-bridge.test.ts
  • services/runner/tests/unit/wire-contract.test.ts
  • web/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). |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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 the auth.json statement with the file-free env_key configuration 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

Comment on lines 39 to +43
- 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Comment on lines +31 to +42
- **`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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

Comment on lines +27 to +52
## 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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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

Comment on lines +61 to +77
## 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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

Comment on lines +871 to +877
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

Comment on lines +930 to +931
executableToolGate:
!plan.isPi && !plan.isDaytona ? deferredExecutableToolGate : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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

Comment on lines +216 to +245
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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

Comment on lines 413 to 429
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

Comment on lines +374 to +407
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",
},
]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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",
},
]);
});

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-production-9c8c.up.railway.app/w
Project agenta-oss-pr-5509
Image tag pr-5509-304c475
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-07-28T16:51:33.600Z

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to create an issue for this

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, both follow-ups are now tracked issues:

`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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the sessions/ 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 /goal feature. 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 under CODEX_HOME/memories/, which sits on the durable mount and survives.
  • logs: a tracing sink read only by the codex feedback command.

What resume actually reads is the sessions/ rollout JSONL (verified chain: loadSessionthread/resumeRolloutRecorder::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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never mention the IP in public prs

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with http://<dev-host>:8180 in 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. 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-access preset hardcodes approvalPolicy: "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: the codex-acp-permission gate type parks warm, proven in the m3 agent-mode QA.
  2. 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-request instead of never (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: advertise readOnlyHint annotations on our loopback tools/list so allow-tools pre-approve, one long-hold probe, and a re-run of the gate matrix.
  • (a) Hold the loopback tools/call open (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 agent mode: 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mmabrouk
mmabrouk changed the base branch from main to release/v0.106.1 July 28, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature Request New feature or request size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants