mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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)'"))
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"]
|
||||
Executable
+123
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user