feat(providers): Codex CLI provider — OpenAI via ModelProvider.OPENAI (#659)

* feat(providers): Codex CLI provider — OpenAI via ModelProvider.OPENAI

Mirrors the grok blueprint end to end: CodexCliProvider (RO ~/.codex
mount, ANTHROPIC_* blanked), an orchestrator-side codex_auth.py
refresher (JWT-exp staleness, atomic rewrite, lock-serialized single-use
rotation, --check backstop; the CLI's own in-process refresh write
no-ops on the RO mount by design — margins keep the orchestrator ahead
of the CLI's 5-minute window), config.toml rendering with required=true
gateway MCP servers, execpolicy deny rules (forbidden-only), per-role
--sandbox (developer=workspace-write, review/doc roles read-only),
codex exec --json with pinned ROBOCO_CODEX_CLI_MODEL (gpt-5.3-codex),
usage summed from typed turn.completed events priced via the real
4-bucket split, dedicated image + entrypoint, registry/park/finalize/
compose/release wiring. V1 excludes interactive intake/secretary.

Per adversarial review: migration 083 seeds the openai provider row
enabled=True (without it every routing path 404'd — the whole feature
was operationally dead code; grok needed the same seed in 039), the
panel picker gained the OpenAI catalog group it silently lacked, and
exit classification is structural — only stderr and error.message
fields from error events are sniffed (word-boundaried patterns, exact
auth phrases, bare 'login' dropped), so the model echoing on-topic
words can never false-park the provider fleet-wide, proven by a
benign-transcript test. Known open risk flagged, not claimed: whether
codex's workspace-write OS sandbox excludes /app is unverified, and no
hook mechanism exists to port the bash-guard defense-in-depth.

* fix(providers): containment barrier on usage.json reads (code scanning)

CodeQL flagged the codex usage read as path injection — correctly:
os.path.basename does not neutralize '..', and the upstream segment
validator isn't in CodeQL's taint model. The grok/codex reads collapse
into one _read_usage_json_contained helper that resolves the built path
and refuses anything outside the resolved usage root — a hostile id can
never escape regardless of upstream drift. Traversal + containment
regression tests added; a stray noqa in the test file replaced with a
named constant per repo rule.

* fix(providers): use realpath+startswith containment CodeQL recognizes

The is_relative_to() guard was a real barrier but not in CodeQL's
py/path-injection sanitizer model, so the alert persisted. Switch to
the canonical os.path.realpath + startswith(root + os.sep) form, which
CodeQL recognizes as a path-traversal barrier; behavior is identical
(refuse any candidate resolving outside the usage root).

* fix(providers): regexp-allowlist the usage-id segment (CodeQL barrier)

Neither is_relative_to nor realpath+startswith was recognized by
CodeQL's py/path-injection sanitizer model across the str->Path->open
flow. Sanitize the tainted component at the source instead: the id must
fullmatch a strict slug token ([A-Za-z0-9][A-Za-z0-9._-]*, no
separators, no '..'), which CodeQL recognizes as a path-injection
barrier; the realpath+startswith containment stays as defense-in-depth.

* fix(providers): standalone regexp guard so CodeQL recognizes the barrier

The sanitizer was one disjunct of a compound 'or' condition, which
CodeQL's guard analysis does not trace as a barrier. Split the regexp
fullmatch into its own single-condition guard (the redundant '..' check
is dropped — the required alphanumeric first char already excludes it).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-23 03:20:29 +02:00
committed by GitHub
co-authored by Renn F
parent 165892dc62
commit c70ff3cf9a
33 changed files with 3316 additions and 33 deletions
+1
View File
@@ -116,6 +116,7 @@ jobs:
[roboco-agent-pr-reviewer]=docker/agent-pr-reviewer.Dockerfile
[roboco-agent-grok-prompter]=docker/agent-grok-prompter.Dockerfile
[roboco-agent-grok-secretary]=docker/agent-grok-secretary.Dockerfile
[roboco-agent-codex]=docker/agent-codex.Dockerfile
)
for name in "${!IMAGES[@]}"; do
echo "::group::build ${name}"
@@ -0,0 +1,84 @@
"""Idempotently seed the Codex (OpenAI) provider row.
The ``modelprovider`` enum has carried ``'openai'`` since migration
`004_provider_routing`, but no row was ever seeded for it (unlike GROK's
`039_seed_grok_provider`) — so any assignment of a catalog model whose
`provider_type` is OPENAI (`gpt-5.3-codex`) raised `NotFoundError` out of
`ModelRoutingService._get_seeded_provider`, making the whole Codex provider
unreachable via the panel the moment an operator tried to route an agent to
it. This migration is that missing seed.
Unlike GROK's row (seeded `enabled=false`, flipped to `true` only by the
dedicated `apply_mode="grok"` write path), this row seeds `enabled=true`
directly: there is no `apply_mode="codex"` button — the only way to route to
Codex is "mix" mode's per-agent picker, which has no equivalent enable step.
Seeding disabled would leave `resolve_for_agent` silently falling back to the
legacy Anthropic path forever (`resolved.provider.enabled` gates the route),
reproducing the exact "silently unreachable" failure this migration exists to
fix. Codex authenticates via a mounted ChatGPT-subscription `~/.codex`
directory (see `roboco.llm.providers.codex.CodexCliProvider`), not a stored
API key, so there is no secret to withhold behind a disabled row anyway —
`base_url` is seeded for display parity with GROK's row but is blanked before
the container mount just the same (never used for auth).
Revision ID: 083_seed_openai_provider
Revises: 082_routing_presets
Create Date: 2026-07-23
Note: chains onto ``082_routing_presets``, a sibling branch's revision that
does not exist in this worktree (this branch was cut before it landed) — the
same expected-failure posture ``081_doctrine_version`` reported for
``080_task_project_budgets``. The local migration-graph AND enum-parity tests
are expected to fail here until this branch integrates alongside 082:
`test_migration_graph_integrity.py` (dangling down_revision, two heads, an
unreachable-root walk) and `test_enum_migration_parity.py` (which shells out
to `alembic upgrade head --sql` and hits the same missing revision id as a
subprocess `KeyError`, not just a static graph-file check). Re-verify the
chain resolves to one head at merge time.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "083_seed_openai_provider"
down_revision = "082_routing_presets"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.execute(
sa.text(
"""
INSERT INTO provider_configs
(id, name, type, base_url, auth_token_encrypted, enabled, created_at)
VALUES
(
gen_random_uuid(),
'Codex (OpenAI)',
'openai',
'https://api.openai.com/v1',
NULL,
true,
now()
)
ON CONFLICT (name) DO NOTHING
"""
)
)
def downgrade() -> None:
# Drop model_assignments pointing at the Codex row first to avoid a FK
# RESTRICT violation on provider_configs.id.
op.execute(
sa.text(
"DELETE FROM model_assignments "
"WHERE provider_config_id IN ("
" SELECT id FROM provider_configs WHERE name = 'Codex (OpenAI)'"
")"
)
)
op.execute(sa.text("DELETE FROM provider_configs WHERE name = 'Codex (OpenAI)'"))
+14
View File
@@ -278,6 +278,13 @@ services:
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok-secretary image present'"]
restart: "no"
# Codex (OpenAI, official CLI) — one-shot delivery roles only, no
# interactive prompter/secretary variant in V1.
agent-codex-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-codex:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-codex image present'"]
restart: "no"
# Sandbox PG (kitchen-sink) — pulled by the provisioner when a venture opts
# into pg extensions. Bare sandboxes use the upstream postgres image, so this
# is only needed by extension-using projects.
@@ -349,6 +356,9 @@ services:
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}
# ChatGPT-subscription auth (host ~/.codex) for Codex-CLI agents — same
# shape as ROBOCO_HOST_GROK_DIR. Run `codex login` on the host.
ROBOCO_HOST_CODEX_DIR: ${ROBOCO_HOST_CODEX_DIR:-${HOME}/.codex}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/opt/roboco/data}
# Reachable base URL for commit-trailer links — set to your host's LAN
# address or domain so the links in commit bodies resolve.
@@ -450,6 +460,8 @@ services:
# auto-refreshes the ~6h token in place (grok_auth.refresh_if_stale) so
# agents never mount a dead credential; the agent's own mount stays RO.
- ${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}:${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}
# Codex CLI auth — same shape as the SuperGrok mount above.
- ${ROBOCO_HOST_CODEX_DIR:-${HOME}/.codex}:${ROBOCO_HOST_CODEX_DIR:-${HOME}/.codex}
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
@@ -457,6 +469,8 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Per-agent GROK usage capture (usage.json -> finalizer).
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
# Per-agent CODEX usage capture — same shape as grok-usage above.
- ${ROBOCO_DATA_DIR:-./data}/codex-usage:/data/codex-usage
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
# video engine: NAS-only bind mount, off by default in public registry.
# Uncomment + set ROBOCO_VIDEO_ENGINE_ENABLED=true in .env to arm.
+23
View File
@@ -391,6 +391,20 @@ services:
depends_on:
- agent-grok-image
# ==========================================================================
# Agent Codex Image Builder (OpenAI via the official codex CLI). One-shot
# delivery roles only in V1 — no interactive prompter/secretary variant.
# ==========================================================================
agent-codex-image:
build:
context: .
dockerfile: docker/agent-codex.Dockerfile
image: roboco-agent-codex
entrypoint: ["/bin/sh", "-c", 'echo "Agent Codex image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Sandbox PG Image Builder (kitchen-sink postgres for parameterized dev DBs)
# Only pulled by the provisioner when a venture requests pg extensions; bare
@@ -474,6 +488,9 @@ services:
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
# ChatGPT-subscription auth (host ~/.codex) for Codex-CLI agents — same
# shape as ROBOCO_HOST_GROK_DIR. Run `codex login` on the host.
ROBOCO_HOST_CODEX_DIR: ${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
# unusable links in commit message bodies; set to NAS LAN IP so
@@ -669,6 +686,10 @@ services:
# (grok_auth.refresh_if_stale) so agents never mount a dead credential;
# each agent's own auth.json mount stays read-only.
- ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
# Codex CLI auth — same shape as the SuperGrok mount above. Read-WRITE:
# the orchestrator auto-refreshes the access token in place
# (codex_auth.refresh_if_stale); each agent's own mount stays read-only.
- ${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex}:${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex}
# Shared config directory for MCP configs (writable)
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
@@ -681,6 +702,8 @@ services:
# Per-agent GROK usage capture: each Grok agent writes usage.json under
# <agent_id>/; the finalizer reads the captured tokens/cost back here.
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
# Per-agent CODEX usage capture — same shape as grok-usage above.
- ${ROBOCO_DATA_DIR:-./data}/codex-usage:/data/codex-usage
# Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`.
+23
View File
@@ -391,6 +391,20 @@ services:
depends_on:
- agent-grok-image
# ==========================================================================
# Agent Codex Image Builder (OpenAI via the official codex CLI). One-shot
# delivery roles only in V1 — no interactive prompter/secretary variant.
# ==========================================================================
agent-codex-image:
build:
context: .
dockerfile: docker/agent-codex.Dockerfile
image: roboco-agent-codex
entrypoint: ["/bin/sh", "-c", 'echo "Agent Codex image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Sandbox PG Image Builder (kitchen-sink postgres for parameterized dev DBs)
# Only pulled by the provisioner when a venture requests pg extensions; bare
@@ -474,6 +488,9 @@ services:
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
# ChatGPT-subscription auth (host ~/.codex) for Codex-CLI agents — same
# shape as ROBOCO_HOST_GROK_DIR. Run `codex login` on the host.
ROBOCO_HOST_CODEX_DIR: ${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
# unusable links in commit message bodies; set to NAS LAN IP so
@@ -669,6 +686,10 @@ services:
# (grok_auth.refresh_if_stale) so agents never mount a dead credential;
# each agent's own auth.json mount stays read-only.
- ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
# Codex CLI auth — same shape as the SuperGrok mount above. Read-WRITE:
# the orchestrator auto-refreshes the access token in place
# (codex_auth.refresh_if_stale); each agent's own mount stays read-only.
- ${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex}:${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex}
# Shared config directory for MCP configs (writable)
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
@@ -681,6 +702,8 @@ services:
# Per-agent GROK usage capture: each Grok agent writes usage.json under
# <agent_id>/; the finalizer reads the captured tokens/cost back here.
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
# Per-agent CODEX usage capture — same shape as grok-usage above.
- ${ROBOCO_DATA_DIR:-./data}/codex-usage:/data/codex-usage
# Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`.
+52
View File
@@ -0,0 +1,52 @@
# Codex (OpenAI) Agent Image
# =============================================================================
# Runs OpenAI's Codex agent through the official `codex` CLI, authenticated by a
# ChatGPT subscription via a mounted ~/.codex/auth.json — the parity analogue of
# the Grok path's mounted ~/.grok (no metered API key). Reuses the base image's
# roboco venv + uv + the RoboCo MCP gateway servers. The entrypoint renders
# ~/.codex/config.toml (the gateway) + the execpolicy deny rules + the per-role
# sandbox flag from the mounted mcp-config.json (see
# roboco.llm.providers.codex_cli_config) and runs the CLI headless. One runtime
# image serves every one-shot delivery role — role behaviour comes from the
# mounted system prompt / manifest / mcp-config, exactly as on the grok path.
#
# V1 scope: no interactive intake/secretary variant of this image exists (unlike
# grok's agent-grok-prompter / agent-grok-secretary) — Codex is one-shot delivery
# roles only for now.
# =============================================================================
FROM roboco-agent-base
USER root
# Install the official codex CLI for the agent user. Pinned — untrusted model
# output runs under it, so bump the version deliberately, never float. Download
# the installer to a file first (a `curl | bash` pipe hides a curl failure as a
# silent no-op) and verify the binary installed AND runs, so a broken install
# fails the build here, not at spawn. (curl/bash from the base.)
ARG CODEX_CLI_VERSION=0.145.0
RUN su agent -s /bin/bash -c "set -euo pipefail; export HOME=/home/agent; \
curl -fsSL https://chatgpt.com/codex/install.sh -o /tmp/codex-install.sh; \
bash /tmp/codex-install.sh ${CODEX_CLI_VERSION}; \
test -x /home/agent/.codex/bin/codex || command -v codex; \
codex --version" \
&& rm -rf /tmp/*
# Entrypoint: render ~/.codex/config.toml + execpolicy rules + the per-role
# sandbox flag, then run codex headless (overrides the base image's `claude`
# entrypoint). ~/.codex is already agent:agent-owned (installed above via
# `su agent`), so no chown needed here.
COPY docker/scripts/codex-cli-agent-entrypoint.sh /app/scripts/codex-cli-agent-entrypoint.sh
RUN chmod 0755 /app/scripts/codex-cli-agent-entrypoint.sh
USER agent
# codex installs to ~/.codex/bin (or ~/.local/bin, depending on the installer);
# put both ahead of the venv on PATH so the entrypoint finds `codex` (and still
# resolves `python` to /app/.venv/bin).
ENV PATH="/home/agent/.codex/bin:/home/agent/.local/bin:/app/.venv/bin:$PATH"
LABEL role="codex-cli-runtime"
LABEL description="Codex (OpenAI) agent runtime — Codex Build via the official codex CLI"
ENTRYPOINT ["/app/scripts/codex-cli-agent-entrypoint.sh"]
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Entrypoint for the roboco-agent-codex image (one-shot delivery roles only —
# see docker/agent-codex.Dockerfile for the V1 scope note).
#
# Runs an agent on OpenAI's official `codex` CLI, authenticated by a ChatGPT
# subscription via a mounted ~/.codex/auth.json — the parity analogue of the
# grok-cli entrypoint's mounted ~/.grok. The gateway, identity, and workspace
# are mounted by the orchestrator's shared container assembly (the same that
# wires Claude/grok); this entrypoint renders the codex runtime config from
# that mount and runs the CLI headless.
set -euo pipefail
# Render ~/.codex/config.toml (the MCP gateway) + the execpolicy deny rules +
# the combined system+task prompt + the per-role sandbox flag. Run from /app so
# `python -m` resolves the INSTALLED roboco package: dev/doc/qa agents run at
# their workspace-clone cwd, whose own roboco/ dir would shadow it on the
# sys.path front (the same ModuleNotFound lesson the grok entrypoint documents).
( cd /app && python -m roboco.llm.providers.codex_cli_config )
CODEX_ARGS_FILE="${ROBOCO_CODEX_ARGS_FILE:-/tmp/roboco-codex-args}"
mapfile -t CODEX_ARGS < "$CODEX_ARGS_FILE"
CODEX_PROMPT_FILE="${ROBOCO_CODEX_PROMPT_FILE:-/tmp/roboco-codex-prompt.txt}"
# Prompt-injection guard (parity with the Claude/grok path): the task prompt is
# DATA, not instructions — refuse a poisoned one before the model ever sees the
# combined prompt file the render step above wrote. Screens the RAW task
# prompt only (the composed role blueprint folded into that file is already
# trusted), same scope as the grok guard call. Run from /app too.
if ! ( cd /app && python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}" ); then
echo "Refusing to run: task prompt matched a prompt-injection pattern." >&2
exit 1
fi
# Auth fail-fast guard. The Codex CLI self-refreshes the access token in-process
# when it notices the JWT is within 5 minutes of expiry, but our per-agent mount
# is read-only, so that in-container write silently fails and only the
# in-memory token survives for this one run. The orchestrator refreshes the
# host token on a loop; this is the in-container backstop: exit 78 (EX_CONFIG)
# immediately so _handle_stopped_container surfaces it, instead of the CLI
# hanging or failing deep into the run on an expired credential.
#
# The orchestrator mounts the host ~/.codex DIRECTORY read-only at
# /home/agent/.codex-auth-ro (a single-file bind mount would pin the inode, so
# the atomic auth.json refresh would never reach a running container — same
# concern the grok entrypoint documents). Symlink ~/.codex/auth.json at that RO
# mount so codex + the --check backstop read the LIVE credential, while
# codex's own writable state (config.toml, rules/, sessions/) still lands in
# the image's own ~/.codex. `rm -f` first in case the image baked a stub.
rm -f /home/agent/.codex/auth.json
ln -s /home/agent/.codex-auth-ro/auth.json /home/agent/.codex/auth.json
if ! ( cd /app && python -m roboco.llm.providers.codex_auth --check ); then
echo "[codex] auth token missing or expired — refusing to run. Refresh" \
"~/.codex/auth.json (orchestrator auto-refresh or 'codex login' on the" \
"host)." >&2
exit 78
fi
# Run the agent. `< /dev/null` keeps the headless run from blocking on stdin.
# We do NOT `exec`: the script regains control to inspect the result + exit
# code. The container's cwd is already the agent's workspace (the orchestrator
# sets it via docker run -w, mirroring the Claude/grok path) — no --cwd flag is
# passed. The combined system+task prompt (from the render step above) is read
# via command substitution into a single quoted argv token, never re-parsed by
# the shell — the same injection-safety property as the grok path's env-var
# prompt passing.
RUN_LOG="/tmp/codex-run.jsonl"
ERR_LOG="/tmp/codex-run.err"
COMBINED_PROMPT="$(cat "$CODEX_PROMPT_FILE" 2>/dev/null || true)"
# `--json` streams typed JSONL to stdout; `tee` shows it live via `docker logs`
# (parity with the Claude/grok path's live streaming) while ALSO capturing it
# to RUN_LOG for the usage-capture read below. stderr goes to ERR_LOG and is
# surfaced after the run.
set +e
codex exec "$COMBINED_PROMPT" \
-m "${ROBOCO_AGENT_MODEL:-gpt-5.3-codex}" \
--json \
"${CODEX_ARGS[@]}" \
< /dev/null 2> "$ERR_LOG" | tee "$RUN_LOG"
run_rc=${PIPESTATUS[0]}
set -e
[ -s "$ERR_LOG" ] && cat "$ERR_LOG" >&2
# Capture token usage from the run's own captured JSONL (turn.completed.usage
# carries a real input/output/cache split — see codex_cli_usage). Best-effort;
# never fails the run. Run from /app for the same module-resolution reason as
# the render above.
( cd /app && ROBOCO_CODEX_RUN_LOG="$RUN_LOG" \
python -m roboco.llm.providers.codex_cli_usage ) || true
# Codex has NO exit-code taxonomy — every failure exits 1, so a rate-limit or
# an expired-mid-run auth failure looks identical to any other error at the
# process level. Classify the run WITHOUT scanning the full transcript: the
# model's own on-topic prose can false-positive a raw grep by construction
# (this repo's own role prompts use the phrase "quota-limited"; a commit hash
# or item content can contain "429"; the panel has a literal login page) —
# codex_cli_sniff extracts ONLY the JSONL error.message fields (turn.failed /
# any error-bearing event) plus stderr and classifies THAT, never stdout's
# echoed model output. Mirrors the grok entrypoint's exit-75/78 convention so
# the orchestrator's existing park-and-probe logic, scoped by provider_type,
# handles both providers identically:
# - rate-limit/quota -> exit 75 (EX_TEMPFAIL): the orchestrator PARKS the
# provider instead of the dispatcher respawning the same task every tick.
# - auth failure (an expired/rotated token discovered mid-run, past the
# --check backstop above) -> exit 78 (EX_CONFIG): parked the same way as a
# pre-run auth miss.
SNIFF="$( (cd /app && python -m roboco.llm.providers.codex_cli_sniff "$RUN_LOG" "$ERR_LOG") 2>/dev/null || true)"
if [ "$SNIFF" = "rate_limit" ]; then
echo "[codex] rate-limited — exiting 75 so the orchestrator parks the" \
"provider; the task is retried when the limit lifts." >&2
exit 75
fi
if [ "$SNIFF" = "auth" ]; then
echo "[codex] auth failure detected mid-run — exiting 78 so the" \
"orchestrator parks the provider until the token is refreshed." >&2
exit 78
fi
# A graceful exit without a terminal verb is handled server-side by the
# orchestrator (_handle_stopped_container substitutes the still-owned task) —
# the codex-cli runtime needs no in-container SDK server for that.
exit "$run_rc"
@@ -45,6 +45,11 @@ const {
provider_type: "grok",
display_name: "Grok Build 0.1",
},
{
model_name: "gpt-5.3-codex",
provider_type: "openai",
display_name: "GPT-5.3 Codex",
},
]),
getOllamaKey: vi.fn(async () => ({ has_key: false, enabled: true })),
setOllamaKey: vi.fn(async () => ({ has_key: true, enabled: true })),
@@ -276,6 +276,10 @@ export function AIRoutingCard() {
(c: { provider_type: ModelProvider }) =>
c.provider_type === ModelProvider.GROK,
);
const catalogOpenaiOnly = catalog.filter(
(c: { provider_type: ModelProvider }) =>
c.provider_type === ModelProvider.OPENAI,
);
const catalogAnthropicOnly = catalog.filter(
(c: { provider_type: ModelProvider }) =>
c.provider_type === ModelProvider.ANTHROPIC,
@@ -1033,6 +1037,29 @@ export function AIRoutingCard() {
</SelectGroup>
)}
{/* Codex (OpenAI) models */}
{catalogOpenaiOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="openai" />
Codex (OpenAI)
</SelectLabel>
{catalogOpenaiOnly.map(
(c: {
model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Ollama Cloud models */}
{catalogOllamaOnly.length > 0 && (
<SelectGroup>
@@ -1247,19 +1274,21 @@ function errMsg(e: unknown): string {
function ProviderBadge({
variant,
}: {
variant: "anthropic" | "grok" | "ollama" | "self-hosted";
variant: "anthropic" | "grok" | "openai" | "ollama" | "self-hosted";
}) {
const styles: Record<string, string> = {
anthropic: "bg-blue-500/20 text-blue-700 dark:text-blue-400",
ollama: "bg-violet-500/20 text-violet-700 dark:text-violet-400",
"self-hosted": "bg-purple-500/20 text-purple-700 dark:text-purple-400",
grok: "bg-teal-500/20 text-teal-700 dark:text-teal-400",
openai: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-400",
};
const labels: Record<string, string> = {
anthropic: "A",
ollama: "O",
"self-hosted": "S",
grok: "G",
openai: "C",
};
return (
<span
+6
View File
@@ -26,9 +26,15 @@ export interface ModelAssignment {
model_name: string;
}
// "codex" is READ-only (derive_mode can report it for a pure-OPENAI global
// assignment) — there is no apply_mode="codex" write path, so no UI ever
// constructs an ApplyModePayload with this value. One shared type (not a
// split read/write pair) keeps this file small; nothing calls applyMode with
// mode: "codex" since no button exists for it.
export type RoutingMode =
| "anthropic"
| "grok"
| "codex"
| "ollama"
| "self_hosted"
| "mix"
+13 -3
View File
@@ -201,9 +201,17 @@ class ApplyModeRequest(BaseModel):
class ModeResponse(BaseModel):
"""Server-side view of the current mode + a snapshot of active rules."""
"""Server-side view of the current mode + a snapshot of active rules.
mode: Literal["anthropic", "grok", "ollama", "mix", "self_hosted", "cost_tiered"]
Read-only ``mode`` values are a superset of what ``ApplyModeRequest``
accepts: "codex" (OPENAI) can come back from `derive_mode()` (a pure-Codex
global assignment), but there is no `apply_mode="codex"` write path — mix
mode's per-agent picker is the only way to route to it.
"""
mode: Literal[
"anthropic", "grok", "codex", "ollama", "mix", "self_hosted", "cost_tiered"
]
assignments: list[AssignmentResponse]
@@ -267,7 +275,9 @@ class RoutingPresetApplyResponse(BaseModel):
`ModeResponse`) plus any per-entry skip notes (e.g. a since-removed
catalog model) — never a partial/silent apply."""
mode: Literal["anthropic", "grok", "ollama", "mix", "self_hosted", "cost_tiered"]
mode: Literal[
"anthropic", "grok", "codex", "ollama", "mix", "self_hosted", "cost_tiered"
]
assignments: list[AssignmentResponse]
skipped: list[str]
+8 -1
View File
@@ -8,7 +8,9 @@ Pricing is provider-aware. A model name resolves to one of four cases:
* **Anthropic** — priced from the table below by substring match.
* **Priced non-Anthropic** — xAI Grok (``grok-build-*``, billed per token via
the xAI API) is priced from the table too. Match by substring like the rest.
the xAI API) and OpenAI Codex (``gpt-5.3-codex``, a ChatGPT-subscription CLI
priced here for cost attribution, not because the subscription itself is
metered) are priced from the table too. Match by substring like the rest.
* **Free non-Anthropic** — local self-hosted Ollama models (``ollama/`` prefix
or bare model tags) and Ollama Cloud models (``:cloud`` tag). These have **no
per-token cost**: local inference runs on owned hardware, and Ollama Cloud
@@ -64,6 +66,11 @@ _PRICING: list[tuple[str, float, float, float, float]] = [
# read is $0.20/1M; xAI publishes no cache-write premium, so cache_write is
# the normal input rate. https://docs.x.ai/developers/models
("grok-build", 1.00, 2.00, 0.20, 1.00),
# OpenAI Codex — priced non-Anthropic (per-token, ChatGPT-subscription CLI
# but priced as if metered for cost attribution). Cached-input read is
# $0.175/1M; OpenAI publishes no cache-write premium, so cache_write is
# the normal input rate (same convention as grok-build above).
("gpt-5.3-codex", 1.75, 14.00, 0.175, 1.75),
# Short aliases used in ROLE_MODEL_MAP / MODEL_MAP
("opus", 5.00, 25.00, 0.50, 6.25),
("sonnet", 3.00, 15.00, 0.30, 0.75),
+23
View File
@@ -11,6 +11,7 @@ import os
import posixpath
from collections.abc import Callable
from functools import lru_cache
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
@@ -1838,6 +1839,28 @@ class Settings(BaseSettings):
"0 disables. Override via ROBOCO_GROK_MAX_COST_USD"
),
)
# Host directory holding the Codex CLI's ChatGPT-subscription auth.json
# (from `codex login`), mounted read-only into each Codex agent — the
# parity analogue of ROBOCO_HOST_GROK_DIR. Unlike the grok path (a raw
# os.environ read in grok.py), this is a real Settings field per the
# Codex build directive, so it shows up in the settings schema.
host_codex_dir: str = Field(
default_factory=lambda: str(Path.home() / ".codex"),
description=(
"Host directory holding the Codex CLI subscription auth.json "
"(from `codex login`); mounted read-only into each Codex agent. "
"Override via ROBOCO_HOST_CODEX_DIR"
),
)
# The codex CLI model id pinned at spawn (`codex exec -m <id>`). Codex has
# no reliable default model, so this must always be set explicitly.
codex_cli_model: str = Field(
default="gpt-5.3-codex",
description=(
"Codex CLI model id passed to `codex exec -m`; override via "
"ROBOCO_CODEX_CLI_MODEL"
),
)
# An interactive intake/secretary chat the human abandoned (closed the tab
# without confirming/stopping) otherwise leaks its container until the
# orchestrator restarts. The sweeper reaps a live session whose
+5
View File
@@ -10,16 +10,21 @@ Backends:
also serves Ollama Cloud / self-hosted via ``ANTHROPIC_BASE_URL`` injection).
- :class:`GrokCliProvider` — xAI Grok Build via the official ``grok`` CLI on the
SuperGrok subscription (mounted ``~/.grok`` auth, parity with the Claude path).
- :class:`CodexCliProvider` — OpenAI via the official ``codex`` CLI on a ChatGPT
subscription (mounted ``~/.codex`` auth, same shape). One-shot delivery roles
only in V1 — no interactive intake/secretary support.
"""
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
from roboco.llm.providers.claude_code import ClaudeCodeProvider
from roboco.llm.providers.codex import CodexCliProvider
from roboco.llm.providers.grok import GrokCliProvider
from roboco.llm.providers.registry import ProviderNotRegisteredError, ProviderRegistry
__all__ = [
"AgentProvider",
"ClaudeCodeProvider",
"CodexCliProvider",
"GrokCliProvider",
"ProviderError",
"ProviderNotRegisteredError",
+239
View File
@@ -0,0 +1,239 @@
"""Codex CLI provider — OpenAI via the official ``codex`` CLI.
OpenAI ships an official terminal coding agent (the ``codex`` CLI) authenticated
by a ChatGPT subscription. RoboCo runs Codex agents on it the same way it runs
Grok agents on ``grok``: the orchestrator's shared container assembly mounts
the RoboCo MCP gateway (``mcp-config.json``), the agent HMAC identity, and the
git context; this provider adds the subscription auth mount (``~/.codex``) and
the runtime env the codex-cli entrypoint reads, then launches the
``roboco-agent-codex`` image — whose entrypoint renders ``~/.codex/config.toml``
+ execpolicy rules + the per-role sandbox flag (see
:mod:`roboco.llm.providers.codex_cli_config`) and runs ``codex exec --json``
headless.
Two things differ from the Claude Code spawn (mirroring
:mod:`roboco.llm.providers.grok`):
1. **Auth** — the host's ``~/.codex`` (subscription credential from ``codex
login``) is mounted instead of relying on a provider key; no OpenAI API
key is used. The provider routing fields are blanked before the shared
mount step so the shared builder never injects them as ``ANTHROPIC_*``
(the wrong runtime) — codex authenticates from the mounted ``~/.codex``.
2. **Runtime** — the ``roboco-agent-codex`` image (codex CLI) instead of
``claude``.
The initial prompt is passed via an **env var, not a positional CLI arg**
(the entrypoint folds it into the rendered combined-prompt file), which
structurally avoids a flag-injection vector.
**V1 scope**: one-shot delivery roles only (developer / qa / documenter /
cell_pm / main_pm / pr_reviewer / board). No interactive intake/secretary
support — there is no ``roboco-agent-codex-prompter`` / ``-secretary`` image,
unlike grok's interactive pair.
"""
from __future__ import annotations
import asyncio
import dataclasses
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Protocol
from roboco.config import settings
from roboco.llm.providers._docker import container_running, stop_container
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
if TYPE_CHECKING:
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
_log = logging.getLogger(__name__)
# The Codex agent image (own image, like every other agent role).
_DEFAULT_CODEX_IMAGE = "roboco-agent-codex:latest"
# The codex CLI model id, pinned — codex has no reliable default model.
_CODEX_CLI_MODEL = settings.codex_cli_model
# Host directory holding the ChatGPT-subscription auth (from `codex login`).
# Mounted into the agent's ~/.codex like the grok path mounts ~/.grok.
CODEX_AUTH_HOST_PATH = settings.host_codex_dir
# In-container paths.
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
# The host ~/.codex DIRECTORY (not a single auth.json file) is mounted RO
# here: a single-file bind mount pins the inode, so the orchestrator's atomic
# tmp+rename refresh (codex_auth.refresh_if_stale) never reaches a running
# container — the exact concern grok's auth mount documents. The entrypoint
# symlinks ~/.codex/auth.json -> this RO mount; codex's writable state
# (config.toml, rules/, sessions/) lives in the image's own ~/.codex.
_CODEX_AUTH_DIR_IN_CONTAINER = "/home/agent/.codex-auth-ro"
# Per-agent data dir (the host side is reused from the shared assembly): the
# entrypoint writes the captured token usage here so the orchestrator reads it
# back at finalize, the codex analogue of the mounted Claude transcript.
_CODEX_USAGE_DIR_IN_CONTAINER = "/home/agent/.codex-usage"
_CODEX_USAGE_FILE_IN_CONTAINER = f"{_CODEX_USAGE_DIR_IN_CONTAINER}/usage.json"
def _container_name(agent_id: str) -> str:
return f"roboco-agent-{agent_id}"
class _CodexHost(Protocol):
"""The orchestrator surface CodexCliProvider reuses for container assembly.
Typed as a Protocol so this module never imports ``AgentOrchestrator`` (no
import cycle) and is trivially mockable in tests.
"""
async def _remove_container(
self, container_name: str, *, stop_reason: str | None = None
) -> None: ...
def _ensure_codex_usage_dir(self, agent_id: str) -> None: ...
def _resolve_host_paths(
self, config: AgentConfig, agent_settings_path: Path | None
) -> dict[str, str | None]: ...
def _build_mount_args(
self,
container_name: str,
config: AgentConfig,
hosts: dict[str, str | None],
) -> list[str]: ...
def _append_agent_auth_env(self, cmd: list[str], config: AgentConfig) -> None: ...
def _append_git_context_env(self, cmd: list[str], config: AgentConfig) -> None: ...
class CodexCliProvider(AgentProvider):
"""Spawn a Codex (OpenAI, official CLI) agent as a gateway-wired container."""
def __init__(self, host: _CodexHost, image: str | None = None) -> None:
self._host = host
self._image = image or _DEFAULT_CODEX_IMAGE
async def spawn(
self,
config: AgentConfig,
initial_prompt: str | None = None,
agent_settings_path: Path | None = None,
) -> SpawnResult:
if not config.mcp_config_path:
raise ProviderError(
"OPENAI spawn requires an MCP config (gateway access).",
agent_id=config.agent_id,
)
container_name = _container_name(config.agent_id)
await self._host._remove_container(
container_name, stop_reason="pre_spawn_stale_clear"
)
# Pre-create the per-agent data dir (world-writable) before the bind
# mount so the non-root agent can write the usage file (else EACCES).
self._host._ensure_codex_usage_dir(config.agent_id)
# Reuse the orchestrator's mount/auth/git assembly so the agent gets
# the full MCP gateway + identity wiring. Blank the provider routing
# fields first: otherwise the shared builder would inject the
# provider endpoint as ANTHROPIC_BASE_URL/AUTH_TOKEN — codex
# authenticates from the mounted ~/.codex, not a provider key.
mount_config = dataclasses.replace(
config, provider_base_url=None, provider_auth_token=None
)
hosts = self._host._resolve_host_paths(config, agent_settings_path)
cmd = self._host._build_mount_args(container_name, mount_config, hosts)
self._host._append_agent_auth_env(cmd, config)
self._host._append_git_context_env(cmd, config)
self._append_codex_auth_mount(cmd)
self._append_usage_mount(cmd, hosts)
self._append_codex_env(cmd, config, initial_prompt)
cmd.append(self._image)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise ProviderError(
f"Failed to start Codex container: {stderr.decode().strip()}",
agent_id=config.agent_id,
)
return SpawnResult(
instance_id=container_name,
extra={"container_id": stdout.decode().strip(), "model": _CODEX_CLI_MODEL},
)
@staticmethod
def _append_codex_auth_mount(cmd: list[str]) -> None:
"""Mount the host's ChatGPT-subscription ``~/.codex`` directory (read-only).
The mount is the DIRECTORY, not the single ``auth.json`` file — a
single-file bind mount pins the inode, so the orchestrator's atomic
refresh (rename within the host ``~/.codex``) would never reach a
running container. See ``_CODEX_AUTH_DIR_IN_CONTAINER``.
"""
auth_dir = Path(CODEX_AUTH_HOST_PATH)
if (auth_dir / "auth.json").exists():
cmd.extend(["-v", f"{auth_dir}:{_CODEX_AUTH_DIR_IN_CONTAINER}:ro"])
else:
# The mount is the codex subscription credential — without it the
# container starts but the entrypoint `--check` backstop refuses
# to run (exit 78) and the agent is doomed. Fail loud at spawn
# time so the operator sees the missing credential immediately.
_log.warning(
"codex host auth.json not found at %s — spawn will start the "
"container but it is doomed to exit 78 (no Codex credential). "
"Run `codex login` on the host (or set ROBOCO_HOST_CODEX_DIR to "
"the directory holding auth.json) before spawning Codex agents.",
auth_dir / "auth.json",
)
@staticmethod
def _append_usage_mount(cmd: list[str], hosts: dict[str, str | None]) -> None:
"""Mount the per-agent data dir so the orchestrator reads usage back.
Reuses the shared per-agent host dir (``hosts["codex_usage"]``); the
entrypoint writes ``usage.json`` here after the run. Without it a
Codex agent finalizes at 0 tokens / $0.
"""
data_host = hosts.get("codex_usage")
if data_host:
cmd.extend(["-v", f"{data_host}:{_CODEX_USAGE_DIR_IN_CONTAINER}"])
def _append_codex_env(
self, cmd: list[str], config: AgentConfig, initial_prompt: str | None
) -> None:
"""Append the runtime env the codex-cli entrypoint + renderer read.
``ROBOCO_AGENT_ID`` lets the renderer compute the per-role sandbox
flag; ``ROBOCO_MCP_CONFIG`` points it at the mounted gateway config;
the prompt travels as an env var (never an argv positional) and the
renderer folds it into the combined system+task prompt file.
"""
cmd.extend(
[
"-e",
f"ROBOCO_AGENT_ID={config.agent_id}",
"-e",
f"ROBOCO_AGENT_MODEL={_CODEX_CLI_MODEL}",
"-e",
f"ROBOCO_MCP_CONFIG={_MCP_CONFIG_IN_CONTAINER}",
"-e",
f"ROBOCO_INITIAL_PROMPT={initial_prompt or ''}",
"-e",
f"ROBOCO_CODEX_USAGE_FILE={_CODEX_USAGE_FILE_IN_CONTAINER}",
]
)
async def stop(self, instance_id: str, graceful: bool = True) -> None:
await stop_container(instance_id, graceful)
async def health_check(self, instance_id: str) -> bool:
return await container_running(instance_id)
async def remove(self, instance_id: str) -> None:
await self._host._remove_container(instance_id)
+298
View File
@@ -0,0 +1,298 @@
"""Keep the Codex CLI credential live so headless codex agents never hit an
expired token.
The Codex CLI (``codex``, OpenAI's official terminal coding agent) stores its
ChatGPT-subscription credential at ``~/.codex/auth.json``: ``{auth_mode,
OPENAI_API_KEY, tokens: {id_token, access_token, refresh_token, account_id},
last_refresh}``. ``access_token`` is a JWT whose ``exp`` claim is the
authoritative expiry — unlike grok's bundle, this file carries no separate
``expires_at`` field, so staleness is decided by decoding the JWT itself. The
CLI self-refreshes IN-PROCESS when it notices the token is within 5 minutes of
expiry, but that only helps a container that can write back to its own
``auth.json`` — our per-agent mount is read-only (the same inode-pinning
concern as grok's mount, see :mod:`roboco.llm.providers.codex`), so an
in-container refresh writes silently fail and the container falls back to the
now-stale in-memory token for the rest of that one run only. The orchestrator
owns the durable refresh: it holds the host file read-write and calls
:func:`refresh_if_stale` on a loop, exactly like ``grok_auth``.
The refresh-token grant posts to ``https://auth.openai.com/oauth/token``
(verified). The grant's ``client_id`` is NOT part of the auth.json struct we
were handed, so :data:`_DEFAULT_OAUTH_CLIENT_ID` is a best-effort default
(the Codex CLI's own public, non-secret OAuth client id) — override with
``ROBOCO_CODEX_OAUTH_CLIENT_ID`` if OpenAI rotates it; this is the one value
in this module not drawn from the verified build facts, flagged here and in
the build report for a human to confirm.
The agent entrypoint calls ``--check`` as a backstop: if the mounted token is
missing/expired it exits non-zero immediately instead of hanging at an
interactive login flow.
"""
from __future__ import annotations
import base64
import contextlib
import json
import os
import shutil
import sys
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import httpx
import structlog
if TYPE_CHECKING:
from collections.abc import Callable
logger = structlog.get_logger(__name__)
# Process-wide serialisation for the single-use refresh-token grant (parity
# with grok_auth's #94 fix): two concurrent refreshes would have the loser
# submit the now-invalidated old refresh_token and burn the credential. The
# lock + re-load-and-recheck inside it makes the loser find the winner's
# refreshed token and return "fresh" instead of re-rotating.
_refresh_lock = threading.Lock()
_TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token"
# Not part of the verified auth.json struct — see module docstring.
_DEFAULT_OAUTH_CLIENT_ID = os.environ.get(
"ROBOCO_CODEX_OAUTH_CLIENT_ID", "app_EMoamEEZ73f0CkXaXp7hrann"
)
# Refresh when the access token expires within this window: a run that starts
# inside it could outlive the token, so refresh proactively rather than at the
# last second. Mirrors grok_auth's REFRESH_SKEW_SECONDS.
REFRESH_SKEW_SECONDS = int(os.environ.get("ROBOCO_CODEX_AUTH_REFRESH_SKEW", "1800"))
# A JWT is header.payload.signature; fewer parts means it isn't one.
_MIN_JWT_PARTS = 2
def default_auth_path() -> Path:
"""The Codex ``auth.json`` for the current process HOME (``~/.codex``)."""
return Path.home() / ".codex" / "auth.json"
def _load(auth_path: Path) -> dict[str, Any] | None:
try:
data = json.loads(auth_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _tokens(bundle: dict[str, Any]) -> dict[str, Any]:
tokens = bundle.get("tokens")
return tokens if isinstance(tokens, dict) else {}
def _exp_from_jwt(token: str) -> datetime | None:
"""Decode the JWT ``exp`` claim (unix seconds) from an access token.
The Codex access token is a JWT whose ``exp`` is the ONLY expiry signal —
unlike grok's bundle there is no sibling ``expires_at`` field, so every
staleness check in this module goes through this decode. Returns ``None``
for an unparseable / non-JWT / claim-less token.
"""
parts = token.split(".")
if len(parts) < _MIN_JWT_PARTS:
return None
payload_b64 = parts[1]
padding = "=" * (-len(payload_b64) % 4)
try:
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
except (ValueError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
exp = payload.get("exp")
if not isinstance(exp, (int, float)):
return None
return datetime.fromtimestamp(float(exp), tz=UTC)
def seconds_until_expiry(
auth_path: Path, *, now: datetime | None = None
) -> float | None:
"""Seconds until the access token expires, or ``None`` if unreadable/absent."""
bundle = _load(auth_path)
if bundle is None:
return None
access_token = _tokens(bundle).get("access_token")
if not isinstance(access_token, str) or not access_token:
return None
expires_at = _exp_from_jwt(access_token)
if expires_at is None:
return None
return (expires_at - (now or datetime.now(UTC))).total_seconds()
def is_valid(
auth_path: Path, *, skew_seconds: int = 0, now: datetime | None = None
) -> bool:
"""True when a token exists and has more than ``skew_seconds`` of life left."""
remaining = seconds_until_expiry(auth_path, now=now)
return remaining is not None and remaining > skew_seconds
def _post_token(url: str, form: dict[str, str]) -> dict[str, Any]:
"""POST the OAuth token request; return the parsed JSON body."""
response = httpx.post(url, data=form, timeout=30.0)
response.raise_for_status()
body = response.json()
return body if isinstance(body, dict) else {}
def _atomic_write(auth_path: Path, bundle: dict[str, Any]) -> None:
"""Rewrite ``auth.json`` atomically, preserving the original file mode.
The rotated refresh_token is single-use (OpenAI invalidates the old one the
instant it issues the new one), so losing this write loses the credential
permanently — the same F006 concern grok_auth documents. Atomic tmp+replace
first; a direct-write fallback if that fails, so the rotated token still
lands on disk. Only if BOTH fail does the OSError propagate.
"""
payload = json.dumps(bundle)
tmp = auth_path.with_name(auth_path.name + ".refresh.tmp")
try:
tmp.write_text(payload, encoding="utf-8")
with contextlib.suppress(OSError):
shutil.copymode(auth_path, tmp)
tmp.replace(auth_path)
return
except OSError as exc:
logger.warning(
"codex auth atomic write failed; trying direct write", error=str(exc)
)
auth_path.write_text(payload, encoding="utf-8")
def _is_stale(tokens: dict[str, Any], now: datetime, skew_seconds: int) -> bool:
"""True when the token is unparseable or within ``skew_seconds`` of expiry."""
access_token = tokens.get("access_token")
if not isinstance(access_token, str) or not access_token:
return True
expires_at = _exp_from_jwt(access_token)
return expires_at is None or (expires_at - now).total_seconds() <= skew_seconds
def _apply_refreshed_token(tokens: dict[str, Any], token: dict[str, Any]) -> None:
"""Write the new access/refresh/id token into the ``tokens`` sub-object."""
tokens["access_token"] = token["access_token"]
if token.get("refresh_token"):
tokens["refresh_token"] = token["refresh_token"]
if token.get("id_token"):
tokens["id_token"] = token["id_token"]
def _do_refresh(
auth_path: Path,
bundle: dict[str, Any],
now: datetime,
post: Callable[[str, dict[str, str]], dict[str, Any]],
) -> str:
"""Run the refresh-token grant and persist the result; returns the status."""
tokens = _tokens(bundle)
refresh_token = tokens.get("refresh_token")
if not refresh_token:
return "no_refresh_token"
try:
token = post(
_TOKEN_ENDPOINT,
{
"grant_type": "refresh_token",
"refresh_token": str(refresh_token),
"client_id": _DEFAULT_OAUTH_CLIENT_ID,
},
)
except Exception as exc:
logger.warning("codex auth refresh request failed", error=str(exc))
return "failed"
if not token.get("access_token"):
logger.warning("codex auth refresh returned no access_token")
return "failed"
_apply_refreshed_token(tokens, token)
bundle["tokens"] = tokens
bundle["last_refresh"] = now.astimezone(UTC).isoformat().replace("+00:00", "Z")
try:
_atomic_write(auth_path, bundle)
except OSError as exc:
logger.warning("codex auth refresh write failed", error=str(exc))
return "failed"
logger.info("codex auth refreshed")
return "refreshed"
def _recheck_or_refresh(
auth_path: Path,
now: datetime,
skew_seconds: int,
post: Callable[[str, dict[str, str]], dict[str, Any]] | None,
) -> str:
"""Re-load + re-check staleness, then refresh — the locked body of refresh_if_stale.
Run inside ``_refresh_lock`` so a concurrent caller that waited on the lock
re-reads the bundle a single-writer just refreshed and returns ``fresh``
instead of re-POSTing the single-use refresh grant.
"""
bundle = _load(auth_path)
if bundle is None:
return "missing"
tokens = _tokens(bundle)
if not tokens.get("refresh_token"):
return "no_refresh_token"
if not _is_stale(tokens, now, skew_seconds):
return "fresh"
return _do_refresh(auth_path, bundle, now, post or _post_token)
def refresh_if_stale(
auth_path: Path,
*,
skew_seconds: int = REFRESH_SKEW_SECONDS,
now: datetime | None = None,
post: Callable[[str, dict[str, str]], dict[str, Any]] | None = None,
) -> str:
"""Mint a fresh access token from the refresh token if expiry is near.
Returns a status string: ``fresh`` (still valid, nothing done), ``refreshed``
(a new token was written), ``missing`` (no auth.json), ``no_refresh_token``
(an API-key-mode auth.json, or no usable credential), or ``failed`` (the
refresh request errored). Best-effort: never raises.
"""
bundle = _load(auth_path)
if bundle is None:
return "missing"
tokens = _tokens(bundle)
if not tokens.get("refresh_token"):
return "no_refresh_token"
now = now or datetime.now(UTC)
if not _is_stale(tokens, now, skew_seconds):
return "fresh"
# Single-use refresh token: hold the lock and re-load + re-check inside it
# so a concurrent caller that waited on the lock finds the refreshed token
# and returns "fresh" instead of re-POSTing the grant (which would use the
# already-invalidated old token and burn the credential).
with _refresh_lock:
return _recheck_or_refresh(auth_path, now, skew_seconds, post)
def main(argv: list[str] | None = None) -> int:
"""CLI: ``--check`` for the entrypoint backstop, else refresh-if-stale.
``--check`` exits non-zero when the mounted token is missing or expired (so
the agent entrypoint can refuse to run instead of hanging at an interactive
login flow). With no flag it refreshes the host token if stale.
"""
args = argv if argv is not None else sys.argv[1:]
auth_path = default_auth_path()
if "--check" in args:
return 0 if is_valid(auth_path) else 1
status = refresh_if_stale(auth_path)
return 0 if status in {"fresh", "refreshed"} else 1
if __name__ == "__main__":
raise SystemExit(main())
+277
View File
@@ -0,0 +1,277 @@
"""Render a Codex CLI agent's runtime config + per-role flags at container start.
The ``roboco-agent-codex`` image's entrypoint runs ``python -m
roboco.llm.providers.codex_cli_config`` to turn the mounted Claude Code
``mcp-config.json`` into ``~/.codex/config.toml`` (``[mcp_servers.<name>]``),
write the execpolicy deny rules the git-push/package-manager parity needs, and
compose the combined system+task prompt ``codex exec`` runs against. Keeping
the translation in importable Python (not a shell heredoc) makes it
unit-testable, mirroring :mod:`roboco.llm.providers.grok_cli_config`.
Parity notes (where Codex's runtime model differs from grok's / Claude's):
* **tool removal** — the Codex CLI exposes no per-built-in-tool
allow/disallow flags (unlike grok's ``--disallowed-tools``). Tool scoping
is coarser: a ``--sandbox`` level per role (see :func:`sandbox_level_for_role`)
plus the execpolicy rules file below.
* **git / package-manager mutation** — codex's execpolicy is Starlark
``prefix_rule(pattern=[...], decision=...)`` with only ``allow`` /
``forbidden`` decisions available headless (a ``prompt`` decision would
block on an approval prompt that never arrives in a one-shot run). One
shared ``~/.codex/rules/default.rules`` file encodes the same git-mutation
/ destructive / raw-package-manager denials grok expresses as
``--deny`` rules — applied to every role (the sandbox level, not a
per-role rules variant, is what actually differs role to role).
* **system prompt** — the Codex CLI has no verified global instruction-file
mechanism (unlike grok's ``~/.grok/AGENTS.md``), so the composed role
blueprint is prepended to the task prompt itself and the RESULT is what
``codex exec`` receives as its positional prompt argument (see
:func:`render_combined_prompt`). The prompt-injection guard in the
entrypoint still screens only the raw task prompt (the blueprint is
already trusted), matching grok's guard scope.
* **no hooks** — Codex's ``config.toml`` exposes no hook mechanism in the
verified build facts, so neither the bash-guard exfiltration hook nor the
Fable honesty-nudge hook is ported here (a V1 gap vs. the grok path).
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
import tomli_w
from roboco.agents_config import get_agent_role
# codex reads its global config from $HOME/.codex/config.toml.
CODEX_CONFIG_PATH = Path.home() / ".codex" / "config.toml"
# Execpolicy rules file (Starlark prefix_rule, allow/forbidden only).
CODEX_RULES_DIR = Path.home() / ".codex" / "rules"
CODEX_RULES_PATH = CODEX_RULES_DIR / "default.rules"
# The composed role blueprint the orchestrator mounts into every agent container.
SYSTEM_PROMPT_PATH = Path(
os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md")
)
# The combined system+task prompt the entrypoint feeds to `codex exec` as its
# positional argument (see the module docstring — no verified system-prompt
# file mechanism exists for Codex, so the blueprint travels IN the prompt).
CODEX_PROMPT_PATH = Path(
os.environ.get("ROBOCO_CODEX_PROMPT_FILE")
or Path(tempfile.gettempdir()) / "roboco-codex-prompt.txt"
)
# The entrypoint reads the computed per-role flags (one token per line) from
# this file, mirroring grok_cli_config's GROK_ARGS_PATH handoff.
CODEX_ARGS_PATH = Path(
os.environ.get("ROBOCO_CODEX_ARGS_FILE")
or Path(tempfile.gettempdir()) / "roboco-codex-args"
)
# The gateway pair MUST come up or the agent has no verb surface at all — set
# required=true so a gateway-init failure fails the codex session fast instead
# of silently running with no tools. Every other MCP server (git-readonly,
# optimal, docs, playwright) is best-effort.
_REQUIRED_MCP_SERVERS = frozenset({"roboco-flow", "roboco-do"})
# Only `developer` gets a writable sandbox in Codex V1 — narrower than grok's
# per-role `allows_write` (role_config says documenter also writes). Documenter
# writes ride the roboco-docs MCP server (a network call, not a local sandboxed
# file edit), so a read-only sandbox does not block its actual job; qa /
# pr_reviewer / cell_pm / main_pm never write code either way. Loosen this set
# if a role's real workflow needs local file writes under Codex.
_WORKSPACE_WRITE_ROLES = frozenset({"developer"})
_SANDBOX_WORKSPACE_WRITE = "workspace-write"
_SANDBOX_READ_ONLY = "read-only"
# Deny parity with grok's --deny rules (roboco/llm/providers/grok_cli_config's
# _GIT_MUTATE_DENY / _DESTRUCTIVE_DENY / _RAW_PM_DENY), expressed as execpolicy
# command prefixes instead of glob strings. Applied via ONE shared rules file
# for every role (the sandbox level is what varies per role, not this list) —
# skipped a per-role rules variant, add one if a role needs a narrower/broader
# command surface than the rest.
_GIT_MUTATE_PREFIXES: tuple[tuple[str, ...], ...] = (
("git", "push"),
("git", "fetch"),
("git", "pull"),
("git", "clone"),
("git", "commit"),
("git", "remote"),
("git", "reset"),
("git", "ls-remote"),
("git", "checkout"),
("git", "merge"),
("git", "rebase"),
("git", "cherry-pick"),
("git", "revert"),
("git", "update-ref"),
("git", "tag", "-d"),
("git", "reflog", "delete"),
)
_DESTRUCTIVE_PREFIXES: tuple[tuple[str, ...], ...] = (("rm", "-rf"),)
_RAW_PM_PREFIXES: tuple[tuple[str, ...], ...] = (
("uv", "run"),
("uv", "sync"),
("uv", "pip", "install"),
("uv", "pip", "uninstall"),
("uv", "lock"),
("uv", "add"),
("uv", "remove"),
("pip", "install"),
("pip3", "install"),
("pip", "uninstall"),
("conda", "install"),
("conda", "create"),
("conda", "run"),
("poetry", "run"),
("poetry", "install"),
("poetry", "add"),
)
def render_config_toml(mcp_config: dict[str, Any]) -> str:
"""Translate Claude Code ``mcpServers`` into codex's ``[mcp_servers]`` TOML.
``{"command": "uv", "args": [...], "env": {...}}`` becomes a
``[mcp_servers.<name>]`` table with the same fields, plus ``required =
true`` for the gateway pair (``roboco-flow`` / ``roboco-do``) so a
gateway-init failure fails the codex session fast. Returns an empty string
when there are no servers.
"""
servers: dict[str, dict[str, Any]] = {}
for name, spec in (mcp_config.get("mcpServers") or {}).items():
block: dict[str, Any] = {
"command": str(spec.get("command", "")),
"args": [str(a) for a in (spec.get("args") or [])],
}
env = spec.get("env") or {}
if env:
block["env"] = {str(k): str(v) for k, v in env.items()}
if str(name) in _REQUIRED_MCP_SERVERS:
block["required"] = True
servers[str(name)] = block
return tomli_w.dumps({"mcp_servers": servers}) if servers else ""
def sandbox_level_for_role(role: str) -> str:
"""The ``--sandbox`` level for a role (see ``_WORKSPACE_WRITE_ROLES``)."""
return (
_SANDBOX_WORKSPACE_WRITE
if role in _WORKSPACE_WRITE_ROLES
else _SANDBOX_READ_ONLY
)
def codex_cli_args_for_role(role: str) -> list[str]:
"""The per-role ``codex exec`` flag tokens (excludes ``-m``/``--json``).
Role-invariant flags (``--json``, the model) are hardcoded in the
entrypoint shell script instead of here, since they never vary by role —
only the sandbox level does.
"""
return ["--sandbox", sandbox_level_for_role(role), "--skip-git-repo-check"]
def codex_cli_args(agent_id: str) -> list[str]:
"""The per-role codex flags for an agent, resolving its role from the id."""
return codex_cli_args_for_role(get_agent_role(agent_id) or "")
def _prefix_rule(prefix: tuple[str, ...], decision: str = "forbidden") -> str:
args = ", ".join(json.dumps(token) for token in prefix)
return f'prefix_rule(pattern = [{args}], decision = "{decision}")'
def render_execpolicy_rules() -> str:
"""The Starlark execpolicy rules text (git-mutation + destructive + raw-PM).
``allow`` / ``forbidden`` decisions only — a ``prompt`` decision blocks on
an approval prompt that never arrives in a headless ``codex exec`` turn,
failing the turn instead of gracefully adapting.
"""
lines = [
"# Generated by roboco.llm.providers.codex_cli_config — do not hand-edit.",
"# Git network/branch/history mutation: agents commit/push via the",
"# gateway verbs, never raw git.",
]
lines += [_prefix_rule(p) for p in _GIT_MUTATE_PREFIXES]
lines.append("# Destructive shell.")
lines += [_prefix_rule(p) for p in _DESTRUCTIVE_PREFIXES]
lines.append("# Raw package-manager / lockfile commands — use `make` instead.")
lines += [_prefix_rule(p) for p in _RAW_PM_PREFIXES]
return "\n".join(lines) + "\n"
def write_execpolicy_rules(*, dest: Path = CODEX_RULES_PATH) -> None:
"""Write the shared execpolicy rules file (always; content is generated)."""
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(render_execpolicy_rules(), encoding="utf-8")
def render_combined_prompt(system_prompt: str, task_prompt: str) -> str:
"""Compose the blueprint + task prompt into one ``codex exec`` argument.
No verified Codex system-prompt-file mechanism exists (see module
docstring), so the trusted blueprint is prepended to the (guard-screened,
by the entrypoint) task prompt rather than mounted separately.
"""
system_prompt = system_prompt.strip()
task_prompt = task_prompt.strip()
if not system_prompt:
return task_prompt
if not task_prompt:
return system_prompt
return f"{system_prompt}\n\n---\n\n{task_prompt}"
def write_combined_prompt(
*,
task_prompt: str,
source: Path = SYSTEM_PROMPT_PATH,
dest: Path = CODEX_PROMPT_PATH,
) -> bool:
"""Write the combined prompt file; returns True iff a blueprint was found.
A missing/unreadable blueprint degrades to the task prompt alone rather
than failing the render.
"""
try:
blueprint = source.read_text(encoding="utf-8")
except OSError:
blueprint = ""
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(render_combined_prompt(blueprint, task_prompt), encoding="utf-8")
return bool(blueprint)
def _load_mcp_config(path: str) -> dict[str, Any]:
"""Load the mounted mcp-config.json, tolerating a missing / invalid file."""
try:
with Path(path).open(encoding="utf-8") as fh:
loaded = json.load(fh)
return loaded if isinstance(loaded, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def main() -> int:
"""Entrypoint: write config.toml + execpolicy rules + prompt + per-role args."""
agent_id = os.environ.get("ROBOCO_AGENT_ID", "")
mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json")
CODEX_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CODEX_CONFIG_PATH.write_text(
render_config_toml(_load_mcp_config(mcp_path)), encoding="utf-8"
)
write_execpolicy_rules()
write_combined_prompt(task_prompt=os.environ.get("ROBOCO_INITIAL_PROMPT", ""))
CODEX_ARGS_PATH.write_text(
"\n".join(codex_cli_args(agent_id)) + "\n", encoding="utf-8"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+124
View File
@@ -0,0 +1,124 @@
"""Classify a Codex CLI run's terminal state from ONLY its machine-relevant
output — never the full transcript.
Codex has no exit-code taxonomy (every failure exits 1), so the entrypoint
must sniff the run's output to tell an OpenAI rate-limit / auth failure apart
from any other error. Sniffing the FULL captured JSONL stdout is unsafe: the
model's own on-topic prose can false-positive by construction — this repo's
own role prompts use the phrase "quota-limited", a commit hash or item content
can contain the substring "429", ... A prior cut of this entrypoint grepped
the whole transcript and would have false-parked the entire OPENAI provider on
any of that ordinary, on-topic output.
The fix is structural, not a pattern tweak: extract ONLY —
- the ``error.message`` field of any JSONL event carrying an ``error`` key
(``turn.failed`` is the documented shape; the check is structural, not
gated on ``type``, so any other error-bearing event works too), and
- the run's raw stderr,
and sniff THAT text. The model's own echoed stdout content can never reach
the classifier, so it can never trigger a false park by construction.
Patterns (mirroring grok's own proven, word-boundaried set — see
``docker/scripts/grok-cli-agent-entrypoint.sh``):
- rate-limit: ``\\b429\\b``, ``rate.?limit``, "too many requests", "quota",
"insufficient_quota".
- auth failure: exact phrases only — "refresh token has expired", "not
signed in". Deliberately NOT the bare word "login" (the panel itself has
a login page; any transcript mentioning it would false-park the whole
provider).
The entrypoint calls this as ``python -m roboco.llm.providers.codex_cli_sniff
<run_log> [err_log]``, printing ``rate_limit`` / ``auth`` / an empty line.
"""
from __future__ import annotations
import contextlib
import json
import re
import sys
from pathlib import Path
from typing import Any
_RATE_LIMIT_PATTERN = re.compile(
r"(\b429\b|rate.?limit|too many requests|quota|insufficient_quota)",
re.IGNORECASE,
)
_AUTH_FAILURE_PATTERN = re.compile(
r"(refresh token has expired|not signed in)", re.IGNORECASE
)
def extract_error_text(run_log: Path) -> str:
"""Pull ONLY the ``error.message`` text from JSONL events in *run_log*.
Scans every line for a dict carrying an ``error`` sub-object with a
``message`` string (the ``turn.failed`` shape); every other event
(``turn.completed``, ``item.*``, plain assistant text, ...) is ignored
regardless of its content — the model's own prose never reaches this
text. Best-effort: a missing/unreadable file returns "".
"""
messages: list[str] = []
try:
with run_log.open(encoding="utf-8") as fh:
for raw in fh:
text = raw.strip()
if not text:
continue
try:
event: Any = json.loads(text)
except json.JSONDecodeError:
continue
if not isinstance(event, dict):
continue
error = event.get("error")
if isinstance(error, dict):
message = error.get("message")
if isinstance(message, str) and message:
messages.append(message)
except OSError:
return ""
return "\n".join(messages)
def is_rate_limited(text: str) -> bool:
"""True if the (already-extracted, machine-only) *text* names a 429/quota error."""
return bool(_RATE_LIMIT_PATTERN.search(text))
def is_auth_failure(text: str) -> bool:
"""True if the (already-extracted, machine-only) *text* names an auth failure."""
return bool(_AUTH_FAILURE_PATTERN.search(text))
def classify(run_log: Path, err_log: Path | None = None) -> str:
"""Return ``"rate_limit"`` / ``"auth"`` / ``""`` for a captured Codex run.
Sniffs ONLY the extracted JSONL ``error.message`` text plus the raw
stderr — never the full stdout transcript (see module docstring).
"""
text = extract_error_text(run_log)
if err_log is not None:
with contextlib.suppress(OSError):
text = f"{text}\n{err_log.read_text(encoding='utf-8')}"
if is_rate_limited(text):
return "rate_limit"
if is_auth_failure(text):
return "auth"
return ""
def main(argv: list[str] | None = None) -> int:
"""CLI: prints the classification for ``<run_log> [err_log]``."""
args = argv if argv is not None else sys.argv[1:]
if not args:
print("")
return 0
run_log = Path(args[0])
err_log = Path(args[1]) if len(args) > 1 else None
print(classify(run_log, err_log))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+184
View File
@@ -0,0 +1,184 @@
"""Capture token usage from a Codex CLI run for the usage / cost dashboard.
``codex exec --json`` streams typed JSONL to stdout; each completed turn
emits one ``turn.completed`` event carrying a real ``usage`` object —
``{input_tokens, cached_input_tokens, cache_write_input_tokens, output_tokens,
reasoning_output_tokens}``. Unlike grok (a single cumulative total with no
split, folded entirely into the output rate), Codex reports a genuine
input/output/cache split, so this reader prices it properly through
:func:`roboco.billing.pricing.calculate_cost`'s four-bucket formula instead of
grok's output-only fallback.
``cached_input_tokens`` is a SUBSET of ``input_tokens`` (OpenAI's usage
convention: cached tokens are part of the prompt, not additional to it), so
the "fresh" input handed to ``calculate_cost`` is ``input_tokens -
cached_input_tokens`` — treating the full ``input_tokens`` as "fresh" would
double-charge the cached portion at both the full and the cached rate.
``reasoning_output_tokens`` is folded into output (reasoning is billed at the
output rate, the same convention grok's usage reader documents for its own
reasoning tokens).
Multiple ``turn.completed`` events can appear in one run's JSONL (the model
can take more than one turn to finish); this reader sums usage across all of
them, per the build directive to prefer the captured ``--json`` stdout over
the on-disk ``~/.codex/sessions`` rollout files.
The agent entrypoint runs ``python -m roboco.llm.providers.codex_cli_usage``
after the run to write ``usage.json`` (same shape grok_cli_usage produces plus
the real input/output split) into a per-agent dir the orchestrator reads back
at finalize.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
from roboco.billing.pricing import calculate_cost
logger = logging.getLogger(__name__)
# Where the entrypoint writes the captured usage for the orchestrator to read.
USAGE_OUT_PATH = Path(
os.environ.get("ROBOCO_CODEX_USAGE_FILE")
or Path(tempfile.gettempdir()) / "roboco-codex-usage.json"
)
_TURN_COMPLETED = "turn.completed"
_USAGE_FIELDS = (
"input_tokens",
"cached_input_tokens",
"cache_write_input_tokens",
"output_tokens",
"reasoning_output_tokens",
)
def _as_int(value: object) -> int:
return int(value) if isinstance(value, (int, float)) else 0
def _usage_from_event(event: dict[str, Any]) -> dict[str, int] | None:
"""Pull the raw usage fields from one ``turn.completed`` JSONL event.
Returns ``None`` for any other event type (``thread.started``,
``turn.started``, ``turn.failed``, ``item.*``, ...).
"""
event_type = event.get("type")
if event_type != _TURN_COMPLETED:
return None
usage = event.get("usage")
if not isinstance(usage, dict):
return None
return {field: _as_int(usage.get(field, 0)) for field in _USAGE_FIELDS}
def aggregate_usage_from_jsonl(run_log: Path) -> dict[str, int]:
"""Sum usage across every ``turn.completed`` event in a captured JSONL log.
Returns the summed raw fields plus ``turns`` (the ``turn.completed``
count). Best-effort: a missing/unreadable/empty file returns all zeros —
usage capture never fails the run.
"""
totals = dict.fromkeys(_USAGE_FIELDS, 0)
turns = 0
try:
with run_log.open(encoding="utf-8") as fh:
for raw in fh:
text = raw.strip()
if not text:
continue
try:
event = json.loads(text)
except json.JSONDecodeError:
continue
if not isinstance(event, dict):
continue
usage = _usage_from_event(event)
if usage is None:
continue
turns += 1
for field in _USAGE_FIELDS:
totals[field] += usage[field]
except OSError:
pass
totals["turns"] = turns
return totals
def usage_and_cost(model: str, agg: dict[str, int]) -> tuple[int, int, int, int, float]:
"""Return ``(input, output, cache_read, cache_write, cost_usd)``.
``cached_input_tokens`` is a subset of ``input_tokens`` (not additional),
so the "fresh" input is the difference; reasoning tokens fold into output.
"""
cached = agg.get("cached_input_tokens", 0)
fresh_input = max(0, agg.get("input_tokens", 0) - cached)
output = agg.get("output_tokens", 0) + agg.get("reasoning_output_tokens", 0)
cache_write = agg.get("cache_write_input_tokens", 0)
cost = calculate_cost(
model,
tokens_input=fresh_input,
tokens_output=output,
tokens_cache_read=cached,
tokens_cache_write=cache_write,
)
return fresh_input, output, cached, cache_write, cost
def capture_run_usage(
*, run_log: Path, model: str, out_path: Path
) -> tuple[int, int, int, int]:
"""Write ``usage.json`` for one codex run; return the token 4-tuple.
Best-effort: never raises (returns all zeros and writes nothing on any
IO/lookup failure).
"""
try:
agg = aggregate_usage_from_jsonl(run_log)
tin, tout, cr, cw, cost = usage_and_cost(model, agg)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(
json.dumps(
{
"model": model,
"tokens_input": tin,
"tokens_output": tout,
"tokens_cache_read": cr,
"tokens_cache_write": cw,
"cost_usd": cost,
"turns": agg.get("turns", 0),
}
),
encoding="utf-8",
)
return tin, tout, cr, cw
except OSError:
return 0, 0, 0, 0
def main() -> int:
"""Entrypoint: write ``usage.json`` (tokens split + cost) for the run."""
model = os.environ.get("ROBOCO_AGENT_MODEL", "gpt-5.3-codex")
run_log = os.environ.get("ROBOCO_CODEX_RUN_LOG", "")
if not run_log:
logger.warning("ROBOCO_CODEX_RUN_LOG not set; usage will read 0")
return 0
tin, tout, _cr, _cw = capture_run_usage(
run_log=Path(run_log), model=model, out_path=USAGE_OUT_PATH
)
if not tin and not tout:
logger.warning(
"codex agent finalized with no readable usage "
"(0 tokens / $0) — check the run log mount: run_log=%s",
run_log,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -1
View File
@@ -191,7 +191,10 @@ class ModelProvider(StrEnum):
Messages API, so GROK agents run through a dedicated OpenAI-protocol
provider (roboco.llm.providers.grok), not ANTHROPIC_BASE_URL injection.
The xAI key is set via PUT /api/providers/grok/key.
`OPENAI` is reserved for future use.
`OPENAI` routes through the official Codex CLI on a ChatGPT subscription
(roboco.llm.providers.codex.CodexCliProvider), mirroring GROK's shape: a
mounted subscription credential (`~/.codex`), not a metered API key.
One-shot delivery roles only — no interactive intake/secretary support.
"""
ANTHROPIC = "anthropic"
+4
View File
@@ -80,6 +80,10 @@ MODEL_CATALOG: tuple[CatalogEntry, ...] = (
# Routes to the GROK provider → GrokCliProvider spawn (api.x.ai/v1). The xAI
# key is set via PUT /api/providers/grok/key.
CatalogEntry("grok-build-0.1", ModelProvider.GROK, "Grok Build 0.1"),
# --- Codex (OpenAI, official CLI) ---
# Routes to the OPENAI provider → CodexCliProvider spawn. Subscription auth
# (~/.codex, from `codex login`), no metered API key — parity with Grok.
CatalogEntry("gpt-5.3-codex", ModelProvider.OPENAI, "GPT-5.3 Codex"),
)
+290 -25
View File
@@ -17,6 +17,7 @@ import contextlib
import hashlib
import json
import os
import re
import shutil
import tempfile
import time
@@ -362,6 +363,9 @@ DATA_HOST_PATH = os.environ.get("ROBOCO_HOST_DATA_DIR", "")
# (the grok analogue of reading the Claude transcript from the mounted ~/.claude).
# Override for local runs.
GROK_USAGE_DATA_DIR = os.environ.get("ROBOCO_GROK_USAGE_DIR", "/data/grok-usage")
# Same shape for CODEX agents (roboco.llm.providers.codex_cli_usage writes
# usage.json here; the finalizer reads it back — see _codex_usage_json).
CODEX_USAGE_DATA_DIR = os.environ.get("ROBOCO_CODEX_USAGE_DIR", "/data/codex-usage")
# Interactive Grok images (grok-CLI conversation drivers) — selected for the
# intake / secretary roles when their route resolves to GROK, instead of the
@@ -401,6 +405,21 @@ _GROK_REPARK_EPISODE_GAP_S = 1500.0 # 25min — > the capped ~16min cycle
_GROK_AUTH_EXIT_CODE = 78
_GROK_AUTH_RETRY_AFTER_S = 60.0
# A one-shot Codex container exits with these SAME codes for the SAME reasons
# (its entrypoint mirrors grok's exit-code convention — see
# docker/scripts/codex-cli-agent-entrypoint.sh): 75 (EX_TEMPFAIL) on a
# detected OpenAI rate-limit / quota error, 78 (EX_CONFIG) when the
# codex_auth --check backstop finds the mounted ChatGPT-subscription token
# missing/expired. Numeric reuse is fine — the checks are scoped by
# provider_type (ModelProvider.OPENAI vs .GROK), never by exit code alone.
# Unlike grok's rate-limit park, Codex has no observed re-park storm to back
# off against yet, so this parks at a flat retry_after (no exponential
# backoff bookkeeping) — add if operators see a repark cycle in practice.
_CODEX_RATE_LIMIT_EXIT_CODE = 75
_CODEX_RATE_LIMIT_RETRY_AFTER_S = 60.0
_CODEX_AUTH_EXIT_CODE = 78
_CODEX_AUTH_RETRY_AFTER_S = 60.0
# =============================================================================
# ORCHESTRATOR
@@ -1524,6 +1543,47 @@ class AgentOrchestrator:
error=str(exc),
)
@staticmethod
def _codex_usage_root() -> Path:
"""The base dir all per-agent codex usage dirs live under (no agent id).
Same compose-vs-local branch as :meth:`_grok_usage_root`.
"""
if PROJECT_HOST_PATH:
return Path(CODEX_USAGE_DATA_DIR)
return Path(tempfile.gettempdir()) / "roboco-codex-usage"
@staticmethod
def _codex_usage_dir(agent_id: str) -> Path:
"""Per-agent codex usage dir under :meth:`_codex_usage_root`.
Single source of truth for BOTH the pre-create/mount side
(``_ensure_codex_usage_dir``) and the finalize read side
(``_codex_usage_json``), mirroring ``_grok_usage_dir``.
"""
return AgentOrchestrator._codex_usage_root() / (
AgentOrchestrator._safe_agent_path_segment(agent_id)
)
def _ensure_codex_usage_dir(self, agent_id: str) -> None:
"""Pre-create the agent's codex usage dir (world-writable) before the mount.
Same EACCES concern as ``_ensure_grok_usage_dir``: a missing bind
source is auto-created ``root:root`` on Linux, which the non-root
``agent`` user can't write into.
"""
target = self._codex_usage_dir(agent_id)
try:
target.mkdir(parents=True, exist_ok=True)
target.chmod(0o777)
except OSError as exc:
logger.warning(
"could not pre-create codex usage dir; codex agent may EACCES",
agent_id=agent_id,
path=str(target),
error=str(exc),
)
async def _ensure_image_present(
self, bare_image: str, dockerfile_path: str, build_context: str
) -> None:
@@ -2834,6 +2894,8 @@ class AgentOrchestrator:
# captured tokens back at finalize via the shared data volume
# (see GROK_USAGE_DATA_DIR).
"grok_usage": f"{DATA_HOST_PATH}/grok-usage/{config.agent_id}",
# Per-agent codex usage dir (OPENAI only); same shape.
"codex_usage": f"{DATA_HOST_PATH}/codex-usage/{config.agent_id}",
"prompt": (
f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md"
),
@@ -2856,6 +2918,9 @@ class AgentOrchestrator:
"grok_usage": str(
Path(tempfile.gettempdir()) / "roboco-grok-usage" / config.agent_id
),
"codex_usage": str(
Path(tempfile.gettempdir()) / "roboco-codex-usage" / config.agent_id
),
"prompt": str(
Path(tempfile.gettempdir())
/ "roboco-prompts"
@@ -3225,20 +3290,31 @@ class AgentOrchestrator:
"""Build (once) the registry of dedicated provider backends.
Only providers that need a runtime other than the built-in Claude Code
container are registered. Today that is GROK (xAI, OpenAI protocol).
container are registered. Today that is GROK (xAI) and OPENAI (Codex
CLI) both OpenAI-protocol-shaped subscription CLIs.
"""
if self._provider_registry is None:
from roboco.llm.providers import GrokCliProvider, ProviderRegistry
from roboco.llm.providers import (
CodexCliProvider,
GrokCliProvider,
ProviderRegistry,
)
from roboco.models.base import ModelProvider
registry = ProviderRegistry()
# Qualify the grok image with the registry namespace + tag so it
# Qualify each image with the registry namespace + tag so it
# resolves in both local-build and registry deploys (parity with
# get_agent_image for the Claude path).
registry.register(
ModelProvider.GROK,
GrokCliProvider(self, image=_qualify_agent_image("roboco-agent-grok")),
)
registry.register(
ModelProvider.OPENAI,
CodexCliProvider(
self, image=_qualify_agent_image("roboco-agent-codex")
),
)
self._provider_registry = registry
return self._provider_registry
@@ -6114,13 +6190,36 @@ class AgentOrchestrator:
branched dir the writers mount (``_grok_usage_dir``). Returns ``None`` when
absent / unreadable.
"""
# os.path.basename keeps only the final path component of the agent id
# before the path is built — the path-injection sanitizer CodeQL models,
# applied here in the read's own scope. _grok_usage_dir's guard rejects
# '.' / '..' / separators / NUL upstream (a bad id raises -> None here).
return self._read_usage_json_contained(self._grok_usage_root(), agent_id)
@staticmethod
def _read_usage_json_contained(base: Path, agent_id: str) -> dict[str, Any] | None:
"""Read ``<base>/<agent_id>/usage.json`` behind a containment barrier.
Agent ids are orchestrator-assigned slugs/uuids and the dir builders
already validate them (``_safe_agent_path_segment``), but the read
applies the resolve-and-contain check anyway: the id is reduced to its
final path component, the full path resolved, and any result outside
the resolved usage root refused a hostile id can never escape the
root regardless of upstream drift. Returns ``None`` when refused,
absent, or unreadable.
"""
# Barrier (CWE-022): the id must be a single allowlisted token — the
# orchestrator only ever assigns slug/uuid ids ([A-Za-z0-9._-]), none
# of which can contain a path separator or ``..`` traversal (the
# required alphanumeric first char already rejects ``.``/``..``). This
# standalone regexp fullmatch is the primary sanitizer; the
# realpath+startswith containment below is defense-in-depth.
segment = os.path.basename(agent_id)
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", segment):
return None
try:
usage_json = self._grok_usage_dir(os.path.basename(agent_id)) / "usage.json"
data = json.loads(usage_json.read_text(encoding="utf-8"))
root = os.path.realpath(base)
candidate = os.path.realpath(base / segment / "usage.json")
if candidate != root and not candidate.startswith(root + os.sep):
return None
with Path(candidate).open(encoding="utf-8") as handle:
data = json.loads(handle.read())
except (OSError, ValueError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
@@ -6159,6 +6258,58 @@ class AgentOrchestrator:
except (TypeError, ValueError):
return 0.0
def _codex_usage_json(self, agent_id: str) -> dict[str, Any] | None:
"""Read an OPENAI agent's ``usage.json`` (mirrors ``_grok_usage_json``).
Written by the codex-cli entrypoint (one-shot, post-run) to the
per-agent dir under ``_codex_usage_dir``. Returns ``None`` when
absent / unreadable.
"""
return self._read_usage_json_contained(self._codex_usage_root(), agent_id)
def _codex_usage_tokens(self, agent_id: str) -> tuple[int, int, int, int]:
"""An OPENAI agent's token usage from its ``usage.json``.
Unlike grok's single cumulative total, codex reports a real
input/output/cache split (see ``codex_cli_usage``), so this returns
the genuine 4-tuple instead of folding everything into output. A
WARNING logs on a missing/zero read (a silent mount/uid failure is
otherwise indistinguishable from a genuine zero-cost run).
"""
data = self._codex_usage_json(agent_id)
tokens = (0, 0, 0, 0)
if data:
try:
tokens = (
int(data.get("tokens_input", 0)),
int(data.get("tokens_output", 0)),
int(data.get("tokens_cache_read", 0)),
int(data.get("tokens_cache_write", 0)),
)
except (TypeError, ValueError):
tokens = (0, 0, 0, 0)
if not tokens[0] and not tokens[1]:
logger.warning(
"OPENAI (codex) agent finalized with no readable usage "
"(0 tokens / $0) — check the data dir mount",
agent_id=agent_id,
)
return tokens
def _codex_usage_turns(self, agent_id: str) -> int:
"""An OPENAI agent's turn count from its ``usage.json`` (0 if none).
Codex's JSONL carries a real ``turn.completed`` count (unlike grok,
which has no turn signal at all) see ``codex_cli_usage``.
"""
data = self._codex_usage_json(agent_id)
if not data:
return 0
try:
return int(data.get("turns", 0))
except (TypeError, ValueError):
return 0
async def _enforce_grok_cost_budget(self) -> None:
"""Kill a live GROK container whose captured cost exceeds the cap.
@@ -6226,17 +6377,21 @@ class AgentOrchestrator:
) -> tuple[int, int, int, int]:
"""Resolve final token counts for a stopping agent.
For a GROK agent, reads the captured ``usage.json`` (no SDK server /
Claude transcript exists). Otherwise tries the live SDK ``/usage/status``
first; if that misses the SDK's in-memory counts race container teardown
for short-lived agents it falls back to the agent's Claude Code
transcript, which is durable and mounted into this container. Returns
For a GROK or OPENAI (codex) agent, reads the captured ``usage.json``
(no SDK server / Claude transcript exists for either). Otherwise tries
the live SDK ``/usage/status`` first; if that misses the SDK's
in-memory counts race container teardown for short-lived agents it
falls back to the agent's Claude Code transcript, which is durable and
mounted into this container. Returns
``(input, output, cache_read, cache_write)``.
"""
from roboco.models.base import ModelProvider
if self.get_provider_for_agent(agent_id) == ModelProvider.GROK.value:
provider = self.get_provider_for_agent(agent_id)
if provider == ModelProvider.GROK.value:
return self._grok_usage_tokens(agent_id)
if provider == ModelProvider.OPENAI.value:
return self._codex_usage_tokens(agent_id)
tokens = (0, 0, 0, 0)
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
@@ -6276,12 +6431,17 @@ class AgentOrchestrator:
assistant-message count) for short-lived agents whose SDK counts race
teardown; ``tool_calls`` has no transcript equivalent and stays 0 ("n/a")
when the SDK misses. Grok agents have neither returns ``(0, 0)``.
Best-effort: any failure degrades to zeros, never blocks finalize.
Codex agents have a real ``turn.completed`` count (from its usage.json)
but no tool-call signal returns ``(turns, 0)``. Best-effort: any
failure degrades to zeros, never blocks finalize.
"""
from roboco.models.base import ModelProvider
if self.get_provider_for_agent(agent_id) == ModelProvider.GROK.value:
provider = self.get_provider_for_agent(agent_id)
if provider == ModelProvider.GROK.value:
return (0, 0)
if provider == ModelProvider.OPENAI.value:
return (self._codex_usage_turns(agent_id), 0)
turns = tool_calls = 0
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
@@ -6470,19 +6630,23 @@ class AgentOrchestrator:
Tries the agent SDK's ``/usage/status`` first; on a zero/miss falls
back to the durable transcript (the SDK can report zero mid-run, the
same race the finalize path handles). Returns ``None`` when neither
source has any usage yet. GROK has no SDK server or Claude transcript,
so it routes to its ``usage.json`` the same early return the finalize
path uses, so live USAGE_SNAPSHOT reflects grok agents mid-run too.
source has any usage yet. GROK / OPENAI (codex) have no SDK server or
Claude transcript, so each routes to its own ``usage.json`` the same
early return the finalize path uses, so live USAGE_SNAPSHOT reflects
grok/codex agents mid-run too.
"""
instance = self._instances.get(agent_id)
is_grok = (
instance is not None
and instance.config is not None
and instance.config.provider_type == ModelProvider.GROK.value
provider = (
instance.config.provider_type
if instance is not None and instance.config is not None
else None
)
if is_grok:
if provider == ModelProvider.GROK.value:
grok_tokens = self._grok_usage_tokens(agent_id)
return grok_tokens if any(grok_tokens) else None
if provider == ModelProvider.OPENAI.value:
codex_tokens = self._codex_usage_tokens(agent_id)
return codex_tokens if any(codex_tokens) else None
tokens = await self._fetch_agent_tokens(client, agent_id)
if tokens is not None:
return tokens
@@ -8091,6 +8255,16 @@ Start by:
if self._is_grok_auth_exit(instance, exit_code):
await self._park_grok_auth_unavailable(agent_id, instance)
return
# Codex 429/auth parking: same exit-code convention as grok (see
# _CODEX_RATE_LIMIT_EXIT_CODE / _CODEX_AUTH_EXIT_CODE), scoped to
# ModelProvider.OPENAI so a numeric-code collision with another
# provider's crash can never mis-park.
if self._is_codex_rate_limit_exit(instance, exit_code):
await self._park_codex_rate_limited(agent_id, instance)
return
if self._is_codex_auth_exit(instance, exit_code):
await self._park_codex_auth_unavailable(agent_id, instance)
return
graceful = exit_code == 0
# Park the provider on a session/usage limit or a server overload detected
# in the dead container's output instead of crash-retrying into it. The
@@ -9489,6 +9663,33 @@ Start by:
and instance.config.provider_type == ModelProvider.GROK.value
)
@staticmethod
def _is_codex_rate_limit_exit(instance: Any, exit_code: int | None) -> bool:
"""True for a one-shot codex container that exited 75 (OpenAI 429)."""
from roboco.models.base import ModelProvider
return (
exit_code == _CODEX_RATE_LIMIT_EXIT_CODE
and instance.config is not None
and instance.config.provider_type == ModelProvider.OPENAI.value
)
@staticmethod
def _is_codex_auth_exit(instance: Any, exit_code: int | None) -> bool:
"""True for a one-shot codex container that exited 78 (auth missing/expired).
The entrypoint runs ``codex_auth --check`` as a backstop and exits 78
when the ChatGPT-subscription token is missing or expired see
``_CODEX_AUTH_EXIT_CODE``.
"""
from roboco.models.base import ModelProvider
return (
exit_code == _CODEX_AUTH_EXIT_CODE
and instance.config is not None
and instance.config.provider_type == ModelProvider.OPENAI.value
)
@staticmethod
async def _tail_container_logs(container_name: str, lines: int = 80) -> str:
"""Return the last ``lines`` of a container's combined output, '' on error.
@@ -9718,6 +9919,41 @@ Start by:
kind="auth_missing",
)
async def _park_codex_rate_limited(self, agent_id: str, instance: Any) -> None:
"""Park a codex agent whose run hit an OpenAI 429 (entrypoint exit 75).
Flat retry_after (no exponential re-park backoff like grok's — see
``_CODEX_RATE_LIMIT_EXIT_CODE``): add the same backoff bookkeeping if
Codex is observed re-parking in a tight cycle in practice.
"""
from roboco.models.base import ModelProvider
await self._park_provider_unavailable(
agent_id,
instance,
provider=ModelProvider.OPENAI.value,
retry_after=_CODEX_RATE_LIMIT_RETRY_AFTER_S,
kind="rate_limited",
)
async def _park_codex_auth_unavailable(self, agent_id: str, instance: Any) -> None:
"""Park a codex agent whose token was missing/expired (entrypoint exit 78).
Same park-and-probe shape as the grok auth path: the agent cannot
start without a valid token, so crash-retrying burns tokens for zero
progress. The dispatcher loop's ``_refresh_codex_auth`` revives the
task once ``codex_auth.refresh_if_stale`` mints a fresh token.
"""
from roboco.models.base import ModelProvider
await self._park_provider_unavailable(
agent_id,
instance,
provider=ModelProvider.OPENAI.value,
retry_after=_CODEX_AUTH_RETRY_AFTER_S,
kind="auth_missing",
)
@staticmethod
def _too_early_to_probe(state: dict[str, Any]) -> bool:
"""True while the estimated lift time (activated_at + retry_after) is future.
@@ -10894,6 +11130,7 @@ Start now: evidence(task_id="{task_id}")
)
self._dispatch_wake.clear()
await self._refresh_grok_auth()
await self._refresh_codex_auth()
await self._dispatch_all_work()
await self._emit_dispatcher_heartbeat()
except asyncio.CancelledError:
@@ -10932,6 +11169,34 @@ Start now: evidence(task_id="{task_id}")
except Exception as exc:
logger.error("grok auth refresh hook error", error=str(exc))
async def _refresh_codex_auth(self) -> None:
"""Keep the host Codex CLI credential live (parity with ``_refresh_grok_auth``).
Same rationale as the grok refresh: the per-agent mount is read-only,
so the orchestrator refreshes the host ``auth.json`` itself before the
access JWT expires. Best-effort, throttled, and serial (run once per
dispatch tick). Never breaks the loop.
"""
now = datetime.now(UTC)
next_check = getattr(self, "_codex_auth_next_check", None)
if next_check is not None and now < next_check:
return
self._codex_auth_next_check = now + timedelta(seconds=60)
try:
from roboco.llm.providers import codex_auth
from roboco.llm.providers.codex import CODEX_AUTH_HOST_PATH
auth_path = Path(CODEX_AUTH_HOST_PATH) / "auth.json"
status = await asyncio.to_thread(codex_auth.refresh_if_stale, auth_path)
if status == "refreshed":
logger.info("codex auth token refreshed")
elif status == "failed":
logger.warning(
"codex auth refresh failed; agents may hit an expired token"
)
except Exception as exc:
logger.error("codex auth refresh hook error", error=str(exc))
async def _reconcile_orphan_claims_on_startup(self) -> None:
"""Roll back tasks left in CLAIMED/IN_PROGRESS without a branch.
+9 -1
View File
@@ -379,7 +379,7 @@ class ModelRoutingService(BaseService):
async def derive_mode(
self,
) -> Literal["anthropic", "grok", "ollama", "mix", "self_hosted"]:
) -> Literal["anthropic", "grok", "codex", "ollama", "mix", "self_hosted"]:
"""Return the current "mode" label for the Settings UI.
Decision tree matches what `apply_mode` writes:
@@ -387,6 +387,12 @@ class ModelRoutingService(BaseService):
- only a global row, Ollama Cloud → "ollama"
- only a global row, LOCAL → "self_hosted"
- anything else → "mix"
"codex" (OPENAI) is READ-only here — there is no `apply_mode="codex"`
write path (mix mode's per-agent picker is the only way to route to
it), so this branch exists purely so a pure-OPENAI global assignment
(however it got there) reports its real provider instead of the
catch-all "mix".
"""
assignments = await self.list_assignments()
if not assignments:
@@ -397,6 +403,8 @@ class ModelRoutingService(BaseService):
if only_global:
if assignments[0].provider.type == ModelProvider.GROK:
return "grok"
if assignments[0].provider.type == ModelProvider.OPENAI:
return "codex"
if assignments[0].provider.type == ModelProvider.OLLAMA_CLOUD:
return "ollama"
if assignments[0].provider.type == ModelProvider.LOCAL:
+65 -1
View File
@@ -52,7 +52,16 @@ async def llm_setup(
enabled=True,
base_url="https://ollama.example.com",
)
db_session.add_all([anthropic, grok, ollama])
# Mirrors migration 083_seed_openai_provider's contract: enabled=True at
# seed time (no apply_mode="codex" write path exists to flip it later —
# see that migration's docstring).
openai = ProviderConfigTable(
name="openai-test",
type=ModelProvider.OPENAI,
enabled=True,
base_url="https://api.openai.com/v1",
)
db_session.add_all([anthropic, grok, ollama, openai])
await db_session.flush()
yield {"svc": ModelRoutingService(db_session)}
@@ -196,6 +205,18 @@ async def test_derive_mode_grok_when_only_grok_global(llm_setup: dict) -> None:
assert await svc.derive_mode() == "grok"
@pytest.mark.asyncio
async def test_derive_mode_codex_when_only_openai_global(llm_setup: dict) -> None:
"""A pure-OPENAI global assignment reports "codex", not the catch-all
"mix" — the read-only branch derive_mode gained alongside the seed fix."""
svc = llm_setup["svc"]
codex_model = _first_model_for_type(ModelProvider.OPENAI)
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=codex_model
)
assert await svc.derive_mode() == "codex"
@pytest.mark.asyncio
async def test_derive_mode_mix_with_per_agent(llm_setup: dict) -> None:
svc = llm_setup["svc"]
@@ -393,6 +414,32 @@ async def test_resolve_for_agent_uses_global_assignment(
assert route.model_name == model
@pytest.mark.asyncio
async def test_upsert_and_resolve_openai_assignment_roundtrip(
llm_setup: dict,
) -> None:
"""gpt-5.3-codex through upsert_assignment -> resolve_for_agent, against
the seeded OPENAI row (migration 083). Before that seed existed,
upsert_assignment's `_get_seeded_provider(ModelProvider.OPENAI)` lookup
raised NotFoundError the moment anyone tried this — this is the round
trip that would have caught it."""
svc = llm_setup["svc"]
codex_model = _first_model_for_type(ModelProvider.OPENAI)
row = await svc.upsert_assignment(
scope=AssignmentScope.AGENT_SLUG,
scope_value="be-dev-1",
model_name=codex_model,
)
assert row.model_name == codex_model
route = await svc.resolve_for_agent("be-dev-1")
assert route.provider_type == ModelProvider.OPENAI
assert route.model_name == codex_model
# The seeded row carries no stored token — Codex authenticates via the
# mounted ~/.codex subscription dir, not a decrypted provider token.
assert route.auth_token is None
@pytest.mark.asyncio
async def test_resolve_for_agent_uses_provider_token(llm_setup: dict) -> None:
"""When provider has auth_token_encrypted, it's decrypted (lines 345-346)."""
@@ -672,6 +719,23 @@ async def test_get_seeded_provider_unknown_raises(
await svc._get_seeded_provider(ModelProvider.ANTHROPIC)
@pytest.mark.asyncio
async def test_upsert_openai_assignment_without_seed_raises_not_found(
db_session: AsyncSession,
) -> None:
"""The exact pre-fix failure: assigning a catalog model whose provider
type has no seeded `provider_configs` row raises NotFoundError out of
`upsert_assignment`. This is what migration `083_seed_openai_provider`
fixes — a bare session (no `llm_setup` fixture, so no OPENAI row) proves
the seed is load-bearing, not incidental."""
svc = ModelRoutingService(db_session)
codex_model = _first_model_for_type(ModelProvider.OPENAI)
with pytest.raises(NotFoundError):
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=codex_model
)
def test_get_model_routing_service_factory(db_session: AsyncSession) -> None:
"""Factory wraps ModelRoutingService with the given session (line 369)."""
@@ -0,0 +1,162 @@
"""Migration 083 tests — seed_openai_provider.
Verifies the post-upgrade state and exercises the downgrade SQL ordering,
mirroring ``test_migration_028_seed_self_hosted.py`` and
``039_seed_grok_provider``'s own shape.
NOT a real alembic round-trip — the suite builds the test DB via
Base.metadata.create_all (see conftest). Migration 083's upgrade()/downgrade()
bodies are reviewed here; the tests guard the resulting DB-level contract —
in particular ``enabled=True`` at seed time, the one detail that diverges
from GROK's own seed (see the migration's docstring for why: there is no
``apply_mode="codex"`` write path to flip it later).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import ModelAssignmentTable, ProviderConfigTable
from roboco.models.base import AssignmentScope, ModelProvider
from sqlalchemy import text
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
_INSERT_SQL = text(
"""
INSERT INTO provider_configs
(id, name, type, base_url, auth_token_encrypted, enabled, created_at)
VALUES
(
gen_random_uuid(),
'Codex (OpenAI)',
'openai',
'https://api.openai.com/v1',
NULL,
true,
now()
)
ON CONFLICT (name) DO NOTHING
"""
)
@pytest.mark.asyncio
async def test_migration_083_upgrade_insert_contract(
db_session: AsyncSession,
) -> None:
"""The upgrade INSERT SQL seeds the Codex row ENABLED (unlike GROK's
seed, which starts disabled) and is idempotent."""
# --- First run: the row should be inserted.
await db_session.execute(_INSERT_SQL)
await db_session.flush()
result = await db_session.execute(
text(
"SELECT name, type, enabled, base_url "
"FROM provider_configs "
"WHERE name = 'Codex (OpenAI)'"
)
)
rows = list(result)
assert len(rows) == 1
name, ptype, enabled, base_url = rows[0]
assert name == "Codex (OpenAI)"
assert ptype == "openai"
# The load-bearing assertion: enabled=True at seed time. Seeding False
# (GROK's convention) would leave resolve_for_agent silently falling back
# to Anthropic forever, since no apply_mode="codex" write path exists to
# flip it — the exact "unreachable" failure this migration fixes.
assert enabled is True
assert base_url == "https://api.openai.com/v1"
# --- Second run: ON CONFLICT DO NOTHING must not create a duplicate.
await db_session.execute(_INSERT_SQL)
await db_session.flush()
result = await db_session.execute(
text("SELECT id FROM provider_configs WHERE name = 'Codex (OpenAI)'")
)
assert len(list(result)) == 1, (
"Expected exactly one 'Codex (OpenAI)' row after two INSERT "
"executions; ON CONFLICT DO NOTHING must prevent duplicates."
)
@pytest.mark.asyncio
async def test_migration_083_downgrade_deletes_assignments_before_config(
db_session: AsyncSession,
) -> None:
"""Downgrade SQL deletes model_assignments before provider_configs.
A FK RESTRICT constraint on model_assignments.provider_config_id means
deleting provider_configs first would raise an IntegrityError.
"""
suffix = uuid4().hex[:8]
openai = ProviderConfigTable(
name=f"Codex (OpenAI)-test-{suffix}",
type=ModelProvider.OPENAI,
enabled=True,
)
db_session.add(openai)
await db_session.flush()
assignment = ModelAssignmentTable(
scope=AssignmentScope.AGENT_SLUG,
scope_value=f"test-agent-{suffix}",
provider_config_id=openai.id,
model_name="gpt-5.3-codex",
)
db_session.add(assignment)
await db_session.flush()
result = await db_session.execute(
text("SELECT id FROM provider_configs WHERE name = :name").bindparams(
name=openai.name
)
)
assert result.scalar_one_or_none() is not None
result = await db_session.execute(
text("SELECT id FROM model_assignments WHERE scope_value = :sv").bindparams(
sv=assignment.scope_value
)
)
assert result.scalar_one_or_none() is not None
# Step 1: delete referencing model_assignments first.
await db_session.execute(
text(
"DELETE FROM model_assignments "
"WHERE provider_config_id IN ("
" SELECT id FROM provider_configs WHERE name = :name"
")"
).bindparams(name=openai.name)
)
# Step 2: now safe to delete the provider row.
await db_session.execute(
text("DELETE FROM provider_configs WHERE name = :name").bindparams(
name=openai.name
)
)
result = await db_session.execute(
text("SELECT id FROM provider_configs WHERE name = :name").bindparams(
name=openai.name
)
)
assert result.scalar_one_or_none() is None, (
"provider_configs row should be deleted by downgrade"
)
result = await db_session.execute(
text("SELECT id FROM model_assignments WHERE scope_value = :sv").bindparams(
sv=assignment.scope_value
)
)
assert result.scalar_one_or_none() is None, (
"model_assignments row should be deleted before provider_configs"
)
+64
View File
@@ -61,6 +61,13 @@ _GROK_OUTPUT = 2.00
_GROK_CACHE_READ = 0.20
_GROK_CACHE_WRITE = 1.00
# OpenAI Codex — priced non-Anthropic (ChatGPT-subscription CLI, priced here
# for cost attribution)
_CODEX_INPUT = 1.75
_CODEX_OUTPUT = 14.00
_CODEX_CACHE_READ = 0.175
_CODEX_CACHE_WRITE = 1.75
# Tolerance for floating-point comparisons
_TOL = 1e-4
@@ -330,6 +337,55 @@ class TestGrokTier:
assert calculate_cost("grok-build-0.1", tokens_input=_M, tokens_output=0) > 0.0
# ---------------------------------------------------------------------------
# Codex tier (OpenAI — priced non-Anthropic)
# ---------------------------------------------------------------------------
class TestCodexTier:
"""gpt-5.3-codex pricing — a real input/output split, unlike grok's fold."""
def test_input_only(self) -> None:
cost = calculate_cost("gpt-5.3-codex", tokens_input=_M, tokens_output=0)
assert abs(cost - _CODEX_INPUT) < _TOL
def test_output_only(self) -> None:
cost = calculate_cost("gpt-5.3-codex", tokens_input=0, tokens_output=_M)
assert abs(cost - _CODEX_OUTPUT) < _TOL
def test_cached_input(self) -> None:
cost = calculate_cost(
"gpt-5.3-codex", tokens_input=0, tokens_output=0, tokens_cache_read=_M
)
assert abs(cost - _CODEX_CACHE_READ) < _TOL
def test_cache_write(self) -> None:
cost = calculate_cost(
"gpt-5.3-codex", tokens_input=0, tokens_output=0, tokens_cache_write=_M
)
assert abs(cost - _CODEX_CACHE_WRITE) < _TOL
def test_all_token_types(self) -> None:
cost = calculate_cost(
"gpt-5.3-codex",
tokens_input=_M,
tokens_output=_M,
tokens_cache_read=_M,
tokens_cache_write=_M,
)
expected = _CODEX_INPUT + _CODEX_OUTPUT + _CODEX_CACHE_READ + _CODEX_CACHE_WRITE
assert abs(cost - expected) < _TOL
def test_codex_is_not_treated_as_anthropic(self) -> None:
assert _is_anthropic_model("gpt-5.3-codex") is False
assert calculate_cost("gpt-5.3-codex", tokens_input=_M, tokens_output=0) > 0.0
def test_output_is_pricier_than_input(self) -> None:
# Codex's real split makes output 8x input — the property grok's
# single-total fold structurally cannot express.
assert _CODEX_OUTPUT > _CODEX_INPUT
# ---------------------------------------------------------------------------
# Unknown / edge cases — must return 0.0 without raising
# ---------------------------------------------------------------------------
@@ -489,6 +545,14 @@ class TestCostResult:
assert result.unpriced is False
assert result.is_anthropic is False
def test_priced_non_anthropic_codex_is_not_unpriced(self) -> None:
result = calculate_cost_result(
"gpt-5.3-codex", tokens_input=_M, tokens_output=0
)
assert result.cost_usd > 0.0
assert result.unpriced is False
assert result.is_anthropic is False
def test_calculate_cost_matches_structured_cost_usd(self) -> None:
model = "claude-opus-4-6"
assert (
+230
View File
@@ -0,0 +1,230 @@
"""codex_auth — keep the Codex CLI credential live via the OAuth refresh grant.
Unlike grok's bundle (keyed by ``<issuer>::<client_id>``, carrying its own
``expires_at``), the Codex auth.json is flat — ``{tokens: {access_token,
refresh_token, ...}}`` — and staleness is decided purely by decoding the
access token's JWT ``exp`` claim.
"""
from __future__ import annotations
import base64
import json
import pathlib
import threading
import time
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from roboco.llm.providers import codex_auth as ca
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _jwt(exp_unix: int) -> str:
"""Build a minimal JWT (header.payload.signature) carrying an ``exp`` claim."""
payload = (
base64.urlsafe_b64encode(json.dumps({"exp": exp_unix}).encode())
.rstrip(b"=")
.decode()
)
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
return f"{header}.{payload}.sig"
def _bundle(access_token: str, *, refresh_token: str = "rt") -> dict[str, Any]:
return {
"auth_mode": "chatgpt",
"tokens": {
"id_token": "id-tok",
"access_token": access_token,
"refresh_token": refresh_token,
"account_id": "acct-1",
},
"last_refresh": "2026-01-01T00:00:00Z",
}
def _write(path: Path, bundle: dict[str, Any]) -> None:
path.write_text(json.dumps(bundle), encoding="utf-8")
def _exp(delta: timedelta) -> int:
return int((datetime.now(UTC) + delta).timestamp())
def test_seconds_until_expiry_and_is_valid(tmp_path: Path) -> None:
path = tmp_path / "auth.json"
token = _jwt(_exp(timedelta(minutes=54)))
_write(path, _bundle(token))
remaining = ca.seconds_until_expiry(path)
assert remaining is not None
assert 3100 < remaining < 3300 # noqa: PLR2004 — ~54 minutes
assert ca.is_valid(path)
assert not ca.is_valid(path, skew_seconds=3600) # <1h left, 1h skew fails
def test_seconds_until_expiry_none_for_missing_or_entryless(tmp_path: Path) -> None:
assert ca.seconds_until_expiry(tmp_path / "nope.json") is None
path = tmp_path / "auth.json"
_write(path, {"tokens": {"account_id": "x"}}) # no access_token
assert ca.seconds_until_expiry(path) is None
def test_seconds_until_expiry_none_for_non_jwt_access_token(tmp_path: Path) -> None:
path = tmp_path / "auth.json"
_write(path, _bundle("api-key-not-a-jwt"))
assert ca.seconds_until_expiry(path) is None
def test_refresh_skips_when_fresh(tmp_path: Path) -> None:
path = tmp_path / "auth.json"
_write(path, _bundle(_jwt(_exp(timedelta(hours=6)))))
calls: list[str] = []
def _post(url: str, _form: dict[str, str]) -> dict[str, Any]:
calls.append(url)
return {}
assert ca.refresh_if_stale(path, post=_post) == "fresh"
assert not calls # no network call when the token is still valid
def test_refresh_mints_new_token_when_stale(tmp_path: Path) -> None:
path = tmp_path / "auth.json"
_write(path, _bundle(_jwt(_exp(timedelta(minutes=-5)))))
new_access = _jwt(_exp(timedelta(hours=6)))
def _post(url: str, form: dict[str, str]) -> dict[str, Any]:
assert url == "https://auth.openai.com/oauth/token"
assert form["grant_type"] == "refresh_token"
assert form["refresh_token"] == "rt"
assert form["client_id"]
return {
"access_token": new_access,
"refresh_token": "new-rt",
"id_token": "new-id",
}
assert ca.refresh_if_stale(path, post=_post) == "refreshed"
bundle = json.loads(path.read_text())
assert bundle["tokens"]["access_token"] == new_access
assert bundle["tokens"]["refresh_token"] == "new-rt" # rotated
assert bundle["tokens"]["id_token"] == "new-id"
assert bundle["last_refresh"] != "2026-01-01T00:00:00Z"
assert ca.is_valid(path)
def test_refresh_keeps_old_refresh_token_when_response_omits_it(
tmp_path: Path,
) -> None:
path = tmp_path / "auth.json"
_write(path, _bundle(_jwt(_exp(timedelta(minutes=-1)))))
def _post(_url: str, _form: dict[str, str]) -> dict[str, Any]:
return {"access_token": _jwt(_exp(timedelta(hours=6)))} # no refresh_token
assert ca.refresh_if_stale(path, post=_post) == "refreshed"
assert json.loads(path.read_text())["tokens"]["refresh_token"] == "rt"
def test_refresh_missing_file(tmp_path: Path) -> None:
assert ca.refresh_if_stale(tmp_path / "nope.json") == "missing"
def test_refresh_no_refresh_token_api_key_mode(tmp_path: Path) -> None:
# auth_mode=apikey has no tokens/refresh_token — refresh is a graceful no-op.
path = tmp_path / "auth.json"
_write(path, {"auth_mode": "apikey", "OPENAI_API_KEY": "sk-x"})
assert ca.refresh_if_stale(path) == "no_refresh_token"
def test_refresh_failed_on_post_error_leaves_file_untouched(tmp_path: Path) -> None:
path = tmp_path / "auth.json"
stale = _jwt(_exp(timedelta(minutes=-1)))
_write(path, _bundle(stale))
def _boom(_url: str, _form: dict[str, str]) -> dict[str, Any]:
raise RuntimeError("network down")
assert ca.refresh_if_stale(path, post=_boom) == "failed"
assert json.loads(path.read_text())["tokens"]["access_token"] == stale
def test_refresh_failed_when_no_access_token(tmp_path: Path) -> None:
path = tmp_path / "auth.json"
_write(path, _bundle(_jwt(_exp(timedelta(minutes=-1)))))
assert ca.refresh_if_stale(path, post=lambda _u, _f: {}) == "failed"
def test_refresh_persists_rotated_token_when_atomic_write_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A rotated refresh_token is single-use; if the atomic write fails after
rotation, the direct-write fallback must still land it on disk."""
path = tmp_path / "auth.json"
_write(path, _bundle(_jwt(_exp(timedelta(minutes=-1)))))
new_access = _jwt(_exp(timedelta(hours=6)))
def _post(_url: str, _form: dict[str, str]) -> dict[str, Any]:
return {"access_token": new_access, "refresh_token": "rotated-rt"}
def _boom_replace(_self: pathlib.Path, _target: pathlib.Path) -> pathlib.Path:
raise OSError("replace failed (simulated)")
monkeypatch.setattr(pathlib.Path, "replace", _boom_replace)
assert ca.refresh_if_stale(path, post=_post) == "refreshed"
tokens = json.loads(path.read_text())["tokens"]
assert tokens["refresh_token"] == "rotated-rt"
assert tokens["access_token"] == new_access
def test_main_check_exit_codes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
home = tmp_path / ".codex"
home.mkdir()
monkeypatch.setenv("HOME", str(tmp_path))
_write(home / "auth.json", _bundle(_jwt(_exp(timedelta(hours=6)))))
assert ca.main(["--check"]) == 0
_write(home / "auth.json", _bundle(_jwt(_exp(timedelta(minutes=-1)))))
assert ca.main(["--check"]) == 1
def test_concurrent_refresh_does_not_double_rotate_single_use_token(
tmp_path: Path,
) -> None:
"""Two near-simultaneous ``refresh_if_stale`` calls must POST the
refresh-token grant ONCE — a process-wide lock + re-load inside it makes
the loser see the winner's refreshed token and return "fresh" instead of
re-rotating (mirrors grok_auth's #94 fix)."""
path = tmp_path / "auth.json"
_write(path, _bundle(_jwt(_exp(timedelta(minutes=-1)))))
posted: list[str] = []
post_lock = threading.Lock()
def _post(_url: str, form: dict[str, str]) -> dict[str, Any]:
with post_lock:
posted.append(form["refresh_token"])
time.sleep(0.1)
return {
"access_token": _jwt(_exp(timedelta(hours=6))),
"refresh_token": "new-rt",
}
results: list[str] = []
def _run() -> None:
results.append(ca.refresh_if_stale(path, post=_post))
threads = [threading.Thread(target=_run) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(posted) == 1 # exactly one grant POST — no double rotation
assert all(r in ("refreshed", "fresh") for r in results)
assert "refreshed" in results
@@ -0,0 +1,120 @@
"""codex_cli_config — mcp-config → config.toml + execpolicy rules + combined
prompt + per-role sandbox flag."""
from __future__ import annotations
import tomllib
from typing import TYPE_CHECKING
from roboco.llm.providers import codex_cli_config as cc
if TYPE_CHECKING:
from pathlib import Path
_SAMPLE_MCP = {
"mcpServers": {
"roboco-flow": {
"command": "uv",
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"],
"env": {"ROBOCO_AGENT_ID": "be-dev-1", "ROBOCO_AGENT_TOKEN": "tok-123"},
},
"roboco-do": {"command": "uv", "args": ["run", "x"]},
"roboco-optimal": {"command": "uv", "args": ["run", "y"]},
}
}
def test_render_config_toml_is_valid_toml_and_injects_env() -> None:
parsed = tomllib.loads(cc.render_config_toml(_SAMPLE_MCP))
flow = parsed["mcp_servers"]["roboco-flow"]
assert flow["command"] == "uv"
assert flow["args"][:2] == ["run", "--no-sync"]
assert flow["env"]["ROBOCO_AGENT_TOKEN"] == "tok-123"
assert "env" not in parsed["mcp_servers"]["roboco-do"]
def test_render_config_toml_marks_gateway_pair_required() -> None:
parsed = tomllib.loads(cc.render_config_toml(_SAMPLE_MCP))
assert parsed["mcp_servers"]["roboco-flow"]["required"] is True
assert parsed["mcp_servers"]["roboco-do"]["required"] is True
# Every other server is best-effort — no `required` key at all.
assert "required" not in parsed["mcp_servers"]["roboco-optimal"]
def test_render_config_toml_empty_when_no_servers() -> None:
assert cc.render_config_toml({}) == ""
assert cc.render_config_toml({"mcpServers": {}}) == ""
def test_sandbox_level_developer_is_workspace_write() -> None:
assert cc.sandbox_level_for_role("developer") == "workspace-write"
def test_sandbox_level_other_delivery_roles_are_read_only() -> None:
# Narrower than grok's per-role allows_write (documenter also writes there)
# — Codex V1 restricts local sandbox writes to developer only; documenter's
# real writes ride the roboco-docs MCP server, not a local file edit.
for role in ("qa", "documenter", "pr_reviewer", "cell_pm", "main_pm", ""):
assert cc.sandbox_level_for_role(role) == "read-only"
def test_codex_cli_args_for_role_carries_sandbox_and_skip_git_check() -> None:
dev_args = cc.codex_cli_args_for_role("developer")
assert dev_args == ["--sandbox", "workspace-write", "--skip-git-repo-check"]
qa_args = cc.codex_cli_args_for_role("qa")
assert qa_args == ["--sandbox", "read-only", "--skip-git-repo-check"]
def test_render_execpolicy_rules_covers_git_mutation_destructive_and_raw_pm() -> None:
rules = cc.render_execpolicy_rules()
assert 'prefix_rule(pattern = ["git", "push"], decision = "forbidden")' in rules
assert 'prefix_rule(pattern = ["git", "tag", "-d"], decision = "forbidden")' in (
rules
)
assert 'prefix_rule(pattern = ["rm", "-rf"], decision = "forbidden")' in rules
assert 'prefix_rule(pattern = ["uv", "run"], decision = "forbidden")' in rules
assert 'prefix_rule(pattern = ["pip", "install"], decision = "forbidden")' in rules
# Only allow/forbidden decisions — never `prompt` (blocks headless turns).
assert "prompt" not in rules
def test_write_execpolicy_rules_writes_to_dest(tmp_path: Path) -> None:
dest = tmp_path / "rules" / "default.rules"
cc.write_execpolicy_rules(dest=dest)
assert dest.exists()
assert "git" in dest.read_text(encoding="utf-8")
def test_render_combined_prompt_joins_system_and_task() -> None:
combined = cc.render_combined_prompt("You are the developer.", "Fix the bug.")
assert combined.startswith("You are the developer.")
assert combined.endswith("Fix the bug.")
assert "---" in combined
def test_render_combined_prompt_degrades_gracefully() -> None:
assert cc.render_combined_prompt("", "task only") == "task only"
assert cc.render_combined_prompt("system only", "") == "system only"
assert cc.render_combined_prompt("", "") == ""
def test_write_combined_prompt_reads_source_and_writes_dest(tmp_path: Path) -> None:
src = tmp_path / "system-prompt.md"
src.write_text("You are the RoboCo developer.", encoding="utf-8")
dest = tmp_path / "prompt.txt"
found = cc.write_combined_prompt(
task_prompt="Implement the feature.", source=src, dest=dest
)
assert found is True
text = dest.read_text(encoding="utf-8")
assert "You are the RoboCo developer." in text
assert "Implement the feature." in text
def test_write_combined_prompt_degrades_when_source_absent(tmp_path: Path) -> None:
dest = tmp_path / "prompt.txt"
found = cc.write_combined_prompt(
task_prompt="Implement the feature.", source=tmp_path / "absent.md", dest=dest
)
assert found is False
assert dest.read_text(encoding="utf-8") == "Implement the feature."
@@ -0,0 +1,188 @@
"""codex_cli_sniff — classify a Codex run from ONLY its machine-relevant text.
The structural guarantee under test: the model's own on-topic prose (which
can legitimately contain the words "quota-limited", "login page", or a "429"
substring inside a commit hash / id) must NEVER reach the classifier, because
extraction only pulls ``error.message`` fields off error-bearing JSONL events
plus raw stderr never ``turn.completed`` / ``item.*`` content.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from roboco.llm.providers import codex_cli_sniff as sniff
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _write_jsonl(path: Path, lines: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _turn_failed(message: str) -> str:
return json.dumps({"type": "turn.failed", "error": {"message": message}})
# ---------------------------------------------------------------------------
# extract_error_text — structural isolation
# ---------------------------------------------------------------------------
def test_extract_error_text_pulls_only_error_message(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[
json.dumps(
{
"type": "turn.completed",
"usage": {"input_tokens": 1},
"text": "the quota-limited rollout ships this sprint",
}
),
_turn_failed("real error text"),
],
)
assert sniff.extract_error_text(log) == "real error text"
def test_extract_error_text_empty_for_missing_or_error_less_log(
tmp_path: Path,
) -> None:
assert sniff.extract_error_text(tmp_path / "nope.jsonl") == ""
log = tmp_path / "run.jsonl"
_write_jsonl(log, [json.dumps({"type": "turn.completed", "usage": {}})])
assert sniff.extract_error_text(log) == ""
# ---------------------------------------------------------------------------
# The false-positive class this fix exists to kill
# ---------------------------------------------------------------------------
def test_benign_transcript_never_false_parks(tmp_path: Path) -> None:
"""A transcript whose ONLY content is benign on-topic prose — mentioning
"quota-limited" work, a "login page" bug, and a commit hash containing
"429" must classify as "" (no park), because none of it lives in an
error field the extractor even looks at."""
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[
json.dumps(
{
"type": "turn.completed",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
),
json.dumps(
{
"type": "item.completed",
"item": {
"type": "agent_message",
"text": (
"Fixed the quota-limited rollout gate and the "
"login page redirect bug. Committed as abc4291f."
),
},
}
),
],
)
err_log = tmp_path / "run.err"
err_log.write_text("", encoding="utf-8")
assert sniff.classify(log, err_log) == ""
def test_word_boundary_prevents_429_substring_false_positive() -> None:
# "429" embedded inside a larger digit/word run must not match — grok's
# own \b429\b pattern, restored here after an initial cut dropped it.
assert not sniff.is_rate_limited("commit abc14293 deployed to prod")
assert not sniff.is_rate_limited("fix4297abc landed")
# "quota" alone legitimately matches wherever it appears (grok's own
# pattern, unchanged) — the false-positive class this fix kills is SCOPE
# (which text gets scanned, i.e. never turn.completed/item.* content),
# not the word "quota" itself. See test_benign_transcript_never_false_parks.
def test_bare_login_word_does_not_classify_as_auth() -> None:
# "login" was dropped from the auth pattern — a mention of a login PAGE
# (this repo's own panel) must not false-park the provider.
assert not sniff.is_auth_failure("please visit the login page to continue")
assert not sniff.is_auth_failure("login required")
# ---------------------------------------------------------------------------
# True positives — real machine-extracted error text
# ---------------------------------------------------------------------------
def test_real_429_error_message_classifies_rate_limit(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_turn_failed("Rate limit exceeded: 429 Too Many Requests")])
assert sniff.classify(log) == "rate_limit"
def test_insufficient_quota_error_message_classifies_rate_limit(
tmp_path: Path,
) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_turn_failed("insufficient_quota: billing hard limit hit")])
assert sniff.classify(log) == "rate_limit"
def test_exact_auth_phrase_refresh_token_expired_classifies_auth(
tmp_path: Path,
) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log, [_turn_failed("Your refresh token has expired, please re-authenticate")]
)
assert sniff.classify(log) == "auth"
def test_exact_auth_phrase_not_signed_in_classifies_auth(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_turn_failed("Error: not signed in")])
assert sniff.classify(log) == "auth"
def test_classify_reads_stderr_too(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [json.dumps({"type": "turn.completed", "usage": {}})])
err_log = tmp_path / "run.err"
err_log.write_text("fatal: 429 too many requests\n", encoding="utf-8")
assert sniff.classify(log, err_log) == "rate_limit"
def test_classify_missing_files_returns_empty(tmp_path: Path) -> None:
assert sniff.classify(tmp_path / "nope.jsonl", tmp_path / "nope.err") == ""
def test_rate_limit_checked_before_auth_when_both_present(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[_turn_failed("429 too many requests, and also not signed in downstream")],
)
assert sniff.classify(log) == "rate_limit"
def test_main_cli_prints_classification(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_turn_failed("429 too many requests")])
assert sniff.main([str(log)]) == 0
assert capsys.readouterr().out.strip() == "rate_limit"
def test_main_cli_no_args_prints_empty(capsys: pytest.CaptureFixture[str]) -> None:
assert sniff.main([]) == 0
assert capsys.readouterr().out.strip() == ""
@@ -0,0 +1,159 @@
"""codex_cli_usage — sum real input/output/cache usage across ``turn.completed``
events in a captured ``codex exec --json`` JSONL log."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from roboco.llm.providers import codex_cli_usage as cu
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _turn_completed(
*,
input_tokens: int,
cached_input_tokens: int = 0,
cache_write_input_tokens: int = 0,
output_tokens: int,
reasoning_output_tokens: int = 0,
) -> str:
return json.dumps(
{
"type": "turn.completed",
"usage": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"cache_write_input_tokens": cache_write_input_tokens,
"output_tokens": output_tokens,
"reasoning_output_tokens": reasoning_output_tokens,
},
}
)
def _write_jsonl(path: Path, lines: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_aggregate_sums_across_multiple_turn_completed_events(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[
json.dumps({"type": "thread.started"}),
json.dumps({"type": "turn.started"}),
_turn_completed(
input_tokens=1000, cached_input_tokens=200, output_tokens=100
),
json.dumps({"type": "item.completed", "item": {"type": "command"}}),
_turn_completed(
input_tokens=500,
cached_input_tokens=100,
cache_write_input_tokens=50,
output_tokens=80,
reasoning_output_tokens=20,
),
],
)
agg = cu.aggregate_usage_from_jsonl(log)
assert agg["input_tokens"] == 1500 # noqa: PLR2004
assert agg["cached_input_tokens"] == 300 # noqa: PLR2004
assert agg["cache_write_input_tokens"] == 50 # noqa: PLR2004
assert agg["output_tokens"] == 180 # noqa: PLR2004
assert agg["reasoning_output_tokens"] == 20 # noqa: PLR2004
assert agg["turns"] == 2 # noqa: PLR2004
def test_aggregate_ignores_turn_failed_and_bad_lines(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[
"not json",
json.dumps({"type": "turn.failed", "error": {"message": "boom"}}),
_turn_completed(input_tokens=10, output_tokens=5),
],
)
agg = cu.aggregate_usage_from_jsonl(log)
assert agg["input_tokens"] == 10 # noqa: PLR2004
assert agg["turns"] == 1
def test_aggregate_zero_for_missing_or_empty_log(tmp_path: Path) -> None:
agg = cu.aggregate_usage_from_jsonl(tmp_path / "nope.jsonl")
assert agg["turns"] == 0
assert all(v == 0 for k, v in agg.items() if k != "turns")
def test_usage_and_cost_treats_cached_as_subset_of_input() -> None:
# cached_input_tokens is a SUBSET of input_tokens (not additional) — the
# "fresh" input priced at the full rate is the difference.
agg = {
"input_tokens": 1000,
"cached_input_tokens": 300,
"cache_write_input_tokens": 0,
"output_tokens": 200,
"reasoning_output_tokens": 50,
}
tin, tout, cr, cw, cost = cu.usage_and_cost("gpt-5.3-codex", agg)
assert tin == 700 # 1000 - 300 # noqa: PLR2004
assert tout == 250 # output + reasoning folded in # noqa: PLR2004
assert cr == 300 # noqa: PLR2004
assert cw == 0
assert cost > 0.0
def test_usage_and_cost_never_goes_negative_when_cached_exceeds_input() -> None:
agg = {
"input_tokens": 10,
"cached_input_tokens": 50, # malformed/inconsistent upstream data
"cache_write_input_tokens": 0,
"output_tokens": 0,
"reasoning_output_tokens": 0,
}
tin, *_rest = cu.usage_and_cost("gpt-5.3-codex", agg)
assert tin == 0
def test_capture_run_usage_writes_usage_json(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_turn_completed(input_tokens=100, output_tokens=50)])
out = tmp_path / "usage.json"
tokens = cu.capture_run_usage(run_log=log, model="gpt-5.3-codex", out_path=out)
assert tokens == (100, 50, 0, 0)
data = json.loads(out.read_text())
assert data["model"] == "gpt-5.3-codex"
assert data["tokens_input"] == 100 # noqa: PLR2004
assert data["tokens_output"] == 50 # noqa: PLR2004
assert data["turns"] == 1
assert data["cost_usd"] > 0.0
def test_main_writes_usage_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_turn_completed(input_tokens=200, output_tokens=100)])
out = tmp_path / "usage.json"
monkeypatch.setattr(cu, "USAGE_OUT_PATH", out)
monkeypatch.setenv("ROBOCO_CODEX_RUN_LOG", str(log))
monkeypatch.setenv("ROBOCO_AGENT_MODEL", "gpt-5.3-codex")
assert cu.main() == 0
data = json.loads(out.read_text())
assert data["tokens_input"] == 200 # noqa: PLR2004
assert data["tokens_output"] == 100 # noqa: PLR2004
def test_main_warns_when_run_log_env_missing(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.delenv("ROBOCO_CODEX_RUN_LOG", raising=False)
with caplog.at_level("WARNING", logger="roboco.llm.providers.codex_cli_usage"):
assert cu.main() == 0
assert any("ROBOCO_CODEX_RUN_LOG" in r.message for r in caplog.records)
+165
View File
@@ -19,6 +19,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.llm.providers import (
ClaudeCodeProvider,
CodexCliProvider,
GrokCliProvider,
ProviderError,
ProviderNotRegisteredError,
@@ -37,6 +38,16 @@ def _isolate_grok_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
return tmp_path
@pytest.fixture(autouse=True)
def _isolate_codex_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point CODEX_AUTH_HOST_PATH at a fresh tmp dir (parity with grok above)."""
codex_dir = tmp_path / "codex-auth"
monkeypatch.setattr(
"roboco.llm.providers.codex.CODEX_AUTH_HOST_PATH", str(codex_dir)
)
return codex_dir
def _config(
*,
agent_id: str = "be-dev-1",
@@ -85,6 +96,9 @@ class _FakeHost:
def _ensure_grok_usage_dir(self, agent_id: str) -> None:
self.data_dirs_ensured.append(agent_id)
def _ensure_codex_usage_dir(self, agent_id: str) -> None:
self.data_dirs_ensured.append(agent_id)
def _resolve_host_paths(
self, config: OrchestratorAgentConfig, agent_settings_path: Path | None
) -> dict[str, str | None]:
@@ -94,6 +108,7 @@ class _FakeHost:
else None,
"settings": str(agent_settings_path) if agent_settings_path else None,
"grok_usage": f"/host/data/grok-usage/{config.agent_id}",
"codex_usage": f"/host/data/codex-usage/{config.agent_id}",
}
def _build_mount_args(
@@ -312,6 +327,156 @@ async def test_grok_spawn_raises_on_docker_failure() -> None:
await provider.spawn(_config())
# ---------------------------------------------------------------------------
# CodexCliProvider
# ---------------------------------------------------------------------------
def _codex_config(
*,
agent_id: str = "be-dev-1",
provider_base_url: str | None = "https://api.x.ai/v1",
provider_auth_token: str | None = "should-not-leak",
mcp_config_path: Path | None = Path("/host/mcp-configs/be-dev-1.json"),
) -> OrchestratorAgentConfig:
return OrchestratorAgentConfig(
agent_id=agent_id,
blueprint_path=Path("/app/system-prompt.md"),
model="gpt-5.3-codex",
mcp_config_path=mcp_config_path,
claude_session_id="sess-1",
provider_type="openai",
provider_base_url=provider_base_url,
provider_auth_token=provider_auth_token,
)
async def test_codex_spawn_requires_mcp_config() -> None:
provider = CodexCliProvider(_FakeHost())
with pytest.raises(ProviderError, match="MCP config"):
await provider.spawn(_codex_config(mcp_config_path=None))
async def test_codex_spawn_does_not_require_api_key() -> None:
# Subscription auth (mounted ~/.codex) — a missing provider key is fine.
host = _FakeHost()
provider = CodexCliProvider(host)
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())):
result = await provider.spawn(_codex_config(provider_auth_token=None))
assert result.instance_id == "roboco-agent-be-dev-1"
async def test_codex_spawn_no_leaked_key_and_no_anthropic_leak() -> None:
host = _FakeHost()
provider = CodexCliProvider(host, image="roboco-agent-codex:test")
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_codex_config(), initial_prompt="do the work")
cmd = list(exec_mock.call_args.args)
assert not any(c.startswith("OPENAI_API_KEY=") for c in cmd)
# The provider endpoint must NOT be injected as an Anthropic var.
assert not any(c.startswith("ANTHROPIC_BASE_URL=") for c in cmd)
assert not any(c.startswith("ANTHROPIC_AUTH_TOKEN=") for c in cmd)
assert host.mount_config is not None
assert host.mount_config.provider_base_url is None
assert host.mount_config.provider_auth_token is None
async def test_codex_spawn_wires_gateway_env_and_image_last() -> None:
host = _FakeHost()
provider = CodexCliProvider(host, image="roboco-agent-codex:test")
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
result = await provider.spawn(_codex_config())
cmd = list(exec_mock.call_args.args)
assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd
assert "ROBOCO_AGENT_ID=be-dev-1" in cmd
assert "ROBOCO_AGENT_MODEL=gpt-5.3-codex" in cmd
# Usage capture: per-agent data dir mounted + the entrypoint's usage file.
assert host.data_dirs_ensured == ["be-dev-1"]
assert "/host/data/codex-usage/be-dev-1:/home/agent/.codex-usage" in cmd
assert "ROBOCO_CODEX_USAGE_FILE=/home/agent/.codex-usage/usage.json" in cmd
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
assert cmd[-1] == "roboco-agent-codex:test"
assert host.removed == ["roboco-agent-be-dev-1"]
assert host.remove_stop_reasons == ["pre_spawn_stale_clear"]
assert result == SpawnResult(
instance_id="roboco-agent-be-dev-1",
extra={"container_id": "cid", "model": "gpt-5.3-codex"},
)
async def test_codex_spawn_mounts_auth_when_present(
_isolate_codex_auth: Path,
) -> None:
_isolate_codex_auth.mkdir(parents=True, exist_ok=True)
(_isolate_codex_auth / "auth.json").write_text("{}", encoding="utf-8")
host = _FakeHost()
provider = CodexCliProvider(host)
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_codex_config())
cmd = list(exec_mock.call_args.args)
# Mount the host ~/.codex DIRECTORY (ro), not the single auth.json file —
# a single-file bind mount pins the inode (same concern grok documents).
expected = f"{_isolate_codex_auth}:/home/agent/.codex-auth-ro:ro"
assert expected in cmd
async def test_codex_spawn_omits_auth_mount_when_absent() -> None:
host = _FakeHost()
provider = CodexCliProvider(host)
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_codex_config())
cmd = list(exec_mock.call_args.args)
assert not any("/home/agent/.codex-auth-ro" in c for c in cmd)
async def test_codex_spawn_warns_when_auth_absent(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("WARNING", logger="roboco.llm.providers.codex")
host = _FakeHost()
provider = CodexCliProvider(host)
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())):
await provider.spawn(_codex_config())
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert warnings, "expected a spawn-time WARNING for the missing host auth.json"
msg = warnings[0].getMessage()
assert "auth.json" in msg
assert "codex login" in msg
async def test_codex_spawn_prompt_is_injection_safe() -> None:
host = _FakeHost()
provider = CodexCliProvider(host)
nasty = "--model evil --session-id pwned"
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_codex_config(), initial_prompt=nasty)
cmd = list(exec_mock.call_args.args)
assert f"ROBOCO_INITIAL_PROMPT={nasty}" in cmd
assert nasty not in cmd
async def test_codex_spawn_raises_on_docker_failure() -> None:
provider = CodexCliProvider(_FakeHost())
with (
patch(
"asyncio.create_subprocess_exec",
AsyncMock(return_value=_proc(returncode=1, stderr=b"boom")),
),
pytest.raises(ProviderError, match="boom"),
):
await provider.spawn(_codex_config())
# ---------------------------------------------------------------------------
# ClaudeCodeProvider
# ---------------------------------------------------------------------------
+158
View File
@@ -0,0 +1,158 @@
"""CODEX 429/auth parking: same exit-code convention as grok, scoped to
ModelProvider.OPENAI so a numeric-code collision with another provider's crash
can never mis-park (see ``_CODEX_RATE_LIMIT_EXIT_CODE`` / ``_CODEX_AUTH_EXIT_CODE``
in ``roboco.runtime.orchestrator``).
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from roboco.models.runtime import AgentInstance
from roboco.runtime.orchestrator import (
_CODEX_AUTH_EXIT_CODE,
_CODEX_RATE_LIMIT_EXIT_CODE,
AgentOrchestrator,
AgentState,
)
def _codex_instance(provider_type: str = "openai") -> AgentInstance:
cfg = type("C", (), {"provider_type": provider_type, "model": "gpt-5.3-codex"})()
inst = AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE, config=cfg)
inst.current_task_id = "task-1"
inst.container_id = "cid"
return inst
class _FakeTracker:
def __init__(self) -> None:
self.activated_with: dict[str, object] | None = None
async def activate(
self,
*,
retry_after: float,
affected_agents: list[str],
kind: str = "rate_limited",
) -> None:
self.activated_with = {
"retry_after": retry_after,
"affected_agents": affected_agents,
"kind": kind,
}
def test_is_codex_rate_limit_exit() -> None:
inst = _codex_instance()
assert AgentOrchestrator._is_codex_rate_limit_exit(
inst, _CODEX_RATE_LIMIT_EXIT_CODE
)
assert not AgentOrchestrator._is_codex_rate_limit_exit(inst, 0)
assert not AgentOrchestrator._is_codex_rate_limit_exit(inst, 1)
# A grok exit at the SAME numeric code must NOT be classified as codex.
assert not AgentOrchestrator._is_codex_rate_limit_exit(
_codex_instance(provider_type="grok"), _CODEX_RATE_LIMIT_EXIT_CODE
)
assert not AgentOrchestrator._is_codex_rate_limit_exit(
_codex_instance(provider_type="anthropic"), _CODEX_RATE_LIMIT_EXIT_CODE
)
def test_is_codex_auth_exit() -> None:
inst = _codex_instance()
assert AgentOrchestrator._is_codex_auth_exit(inst, _CODEX_AUTH_EXIT_CODE)
assert not AgentOrchestrator._is_codex_auth_exit(inst, 0)
assert not AgentOrchestrator._is_codex_auth_exit(inst, 1)
assert not AgentOrchestrator._is_codex_auth_exit(
_codex_instance(provider_type="grok"), _CODEX_AUTH_EXIT_CODE
)
@pytest.mark.asyncio
async def test_park_codex_rate_limited_activates_and_offlines(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._waiting_records = {}
orch._rate_limit_ceo_notified = set()
inst = _codex_instance()
inst.error_count = 2 # pretend prior crashes — parking must NOT count one
tracker = _FakeTracker()
monkeypatch.setattr(orch, "_make_tracker", lambda _p: tracker)
finalize = AsyncMock()
monkeypatch.setattr(orch, "_finalize_spawn_session", finalize)
monkeypatch.setattr(orch, "_persist_waiting_record", AsyncMock())
await orch._park_codex_rate_limited("be-dev-1", inst)
finalize.assert_awaited_once()
assert inst.state == AgentState.OFFLINE
assert inst.container_id is None
assert inst.error_count == 0 # a 429 is not a crash
assert tracker.activated_with == {
"retry_after": pytest.approx(60.0),
"affected_agents": ["be-dev-1"],
"kind": "rate_limited",
}
@pytest.mark.asyncio
async def test_park_codex_auth_unavailable_activates_with_auth_missing_kind(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._waiting_records = {}
orch._rate_limit_ceo_notified = set()
inst = _codex_instance()
inst.error_count = 2
tracker = _FakeTracker()
monkeypatch.setattr(orch, "_make_tracker", lambda _p: tracker)
monkeypatch.setattr(orch, "_finalize_spawn_session", AsyncMock())
monkeypatch.setattr(orch, "_persist_waiting_record", AsyncMock())
await orch._park_codex_auth_unavailable("be-dev-1", inst)
assert inst.state == AgentState.OFFLINE
assert inst.container_id is None
assert inst.error_count == 0
assert tracker.activated_with == {
"retry_after": pytest.approx(60.0),
"affected_agents": ["be-dev-1"],
"kind": "auth_missing",
}
@pytest.mark.asyncio
async def test_handle_stopped_container_parks_on_codex_429(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
inst = _codex_instance()
park = AsyncMock()
finalize = AsyncMock()
monkeypatch.setattr(orch, "_park_codex_rate_limited", park)
monkeypatch.setattr(orch, "_finalize_spawn_session", finalize)
await orch._handle_stopped_container("be-dev-1", inst, _CODEX_RATE_LIMIT_EXIT_CODE)
park.assert_awaited_once_with("be-dev-1", inst)
finalize.assert_not_awaited()
@pytest.mark.asyncio
async def test_handle_stopped_container_parks_on_codex_auth_exit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
inst = _codex_instance()
park = AsyncMock()
finalize = AsyncMock()
monkeypatch.setattr(orch, "_park_codex_auth_unavailable", park)
monkeypatch.setattr(orch, "_finalize_spawn_session", finalize)
await orch._handle_stopped_container("be-dev-1", inst, _CODEX_AUTH_EXIT_CODE)
park.assert_awaited_once_with("be-dev-1", inst)
finalize.assert_not_awaited()
@@ -0,0 +1,166 @@
"""OPENAI (codex) agents capture real input/output/cache-split token usage
from their captured ``usage.json`` unlike grok's single cumulative total,
codex's JSONL carries a genuine split (see ``codex_cli_usage``), so finalize
must return the real 4-tuple instead of folding everything into output.
"""
from __future__ import annotations
import json
import tempfile
from typing import TYPE_CHECKING
import httpx
import pytest
from roboco.models.runtime import AgentInstance
from roboco.runtime import orchestrator as orch_mod
from roboco.runtime.orchestrator import AgentOrchestrator
if TYPE_CHECKING:
from pathlib import Path
def _write_usage(path: Path, **fields: object) -> None:
payload = {
"model": "gpt-5.3-codex",
"tokens_input": 0,
"tokens_output": 0,
"tokens_cache_read": 0,
"tokens_cache_write": 0,
"cost_usd": 0.0,
"turns": 1,
**fields,
}
path.write_text(
json.dumps(payload),
encoding="utf-8",
)
def test_codex_usage_returns_real_split(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
usage = tmp_path / "usage.json"
_write_usage(
usage, tokens_input=700, tokens_output=250, tokens_cache_read=300, turns=2
)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(
orch, "_codex_usage_json", lambda _aid: json.loads(usage.read_text())
)
expected_turns = 2
assert orch._codex_usage_tokens("be-dev-1") == (700, 250, 300, 0)
assert orch._codex_usage_turns("be-dev-1") == expected_turns
def test_read_usage_json_contained_refuses_escape(tmp_path: Path) -> None:
"""A '..' id resolves outside the usage root and must be refused —
basename alone does not neutralize '..', the containment check does."""
(tmp_path / "usage.json").write_text('{"leak": 1}', encoding="utf-8")
base = tmp_path / "root"
base.mkdir()
assert AgentOrchestrator._read_usage_json_contained(base, "..") is None
def test_read_usage_json_contained_reads_inside_root(tmp_path: Path) -> None:
agent_dir = tmp_path / "be-dev-1"
agent_dir.mkdir()
(agent_dir / "usage.json").write_text('{"total_tokens": 5}', encoding="utf-8")
data = AgentOrchestrator._read_usage_json_contained(tmp_path, "be-dev-1")
assert data == {"total_tokens": 5}
def test_codex_usage_zero_when_store_missing(monkeypatch: pytest.MonkeyPatch) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(orch, "_codex_usage_json", lambda _aid: None)
assert orch._codex_usage_tokens("be-dev-1") == (0, 0, 0, 0)
assert orch._codex_usage_turns("be-dev-1") == 0
@pytest.mark.asyncio
async def test_resolve_final_usage_routes_openai_to_usage_json(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(
orch,
"_codex_usage_json",
lambda _aid: {
"tokens_input": 12,
"tokens_output": 34,
"tokens_cache_read": 5,
"tokens_cache_write": 1,
},
)
cfg = type("C", (), {"provider_type": "openai"})()
orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)}
assert await orch._resolve_final_token_usage("be-dev-1") == (12, 34, 5, 1)
@pytest.mark.asyncio
async def test_resolve_final_turns_tools_routes_openai_to_usage_json(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(orch, "_codex_usage_turns", lambda _aid: 3)
cfg = type("C", (), {"provider_type": "openai"})()
orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)}
# Codex has no tool-call signal — tool_calls stays 0.
assert await orch._resolve_final_turns_tools("be-dev-1") == (3, 0)
@pytest.mark.asyncio
async def test_resolve_active_tokens_routes_openai_to_usage_json(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(
orch,
"_codex_usage_json",
lambda _aid: {"tokens_input": 12, "tokens_output": 34},
)
cfg = type("C", (), {"provider_type": "openai"})()
orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)}
async with httpx.AsyncClient() as client:
assert await orch._resolve_active_tokens(client, "be-dev-1") == (12, 34, 0, 0)
def test_codex_usage_dir_branches_compose_vs_local(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "")
local = AgentOrchestrator._codex_usage_dir("be-dev-1")
assert "roboco-codex-usage" in str(local)
assert local.name == "be-dev-1"
monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "/volume1/roboco")
monkeypatch.setattr(orch_mod, "CODEX_USAGE_DATA_DIR", "/data/codex-usage")
assert str(AgentOrchestrator._codex_usage_dir("be-dev-1")) == (
"/data/codex-usage/be-dev-1"
)
@pytest.mark.parametrize(
"bad",
["..", ".", "../etc", "a/b", "a\\b", "", "be-dev-1/../x", "x\x00y"],
)
def test_codex_usage_dir_rejects_path_traversal(bad: str) -> None:
with pytest.raises(ValueError, match="unsafe agent id"):
AgentOrchestrator._codex_usage_dir(bad)
def test_codex_usage_json_reads_the_real_local_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "")
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
udir = tmp_path / "roboco-codex-usage" / "be-dev-1"
udir.mkdir(parents=True)
_write_usage(udir / "usage.json", tokens_input=55, tokens_output=10)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
assert orch._codex_usage_tokens("be-dev-1") == (55, 10, 0, 0)