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-pr-reviewer]=docker/agent-pr-reviewer.Dockerfile
|
||||||
[roboco-agent-grok-prompter]=docker/agent-grok-prompter.Dockerfile
|
[roboco-agent-grok-prompter]=docker/agent-grok-prompter.Dockerfile
|
||||||
[roboco-agent-grok-secretary]=docker/agent-grok-secretary.Dockerfile
|
[roboco-agent-grok-secretary]=docker/agent-grok-secretary.Dockerfile
|
||||||
|
[roboco-agent-codex]=docker/agent-codex.Dockerfile
|
||||||
)
|
)
|
||||||
for name in "${!IMAGES[@]}"; do
|
for name in "${!IMAGES[@]}"; do
|
||||||
echo "::group::build ${name}"
|
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'"]
|
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok-secretary image present'"]
|
||||||
restart: "no"
|
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
|
# Sandbox PG (kitchen-sink) — pulled by the provisioner when a venture opts
|
||||||
# into pg extensions. Bare sandboxes use the upstream postgres image, so this
|
# into pg extensions. Bare sandboxes use the upstream postgres image, so this
|
||||||
# is only needed by extension-using projects.
|
# is only needed by extension-using projects.
|
||||||
@@ -349,6 +356,9 @@ services:
|
|||||||
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
|
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
|
||||||
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
|
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
|
||||||
ROBOCO_HOST_GROK_DIR: ${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}
|
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}
|
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/opt/roboco/data}
|
||||||
# Reachable base URL for commit-trailer links — set to your host's LAN
|
# Reachable base URL for commit-trailer links — set to your host's LAN
|
||||||
# address or domain so the links in commit bodies resolve.
|
# 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
|
# 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.
|
# 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}
|
- ${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}/mcp-configs:/app/mcp-configs
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
|
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
|
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
|
||||||
@@ -457,6 +469,8 @@ services:
|
|||||||
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
|
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
|
||||||
# Per-agent GROK usage capture (usage.json -> finalizer).
|
# Per-agent GROK usage capture (usage.json -> finalizer).
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
|
- ${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
|
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
|
||||||
# video engine: NAS-only bind mount, off by default in public registry.
|
# video engine: NAS-only bind mount, off by default in public registry.
|
||||||
# Uncomment + set ROBOCO_VIDEO_ENGINE_ENABLED=true in .env to arm.
|
# Uncomment + set ROBOCO_VIDEO_ENGINE_ENABLED=true in .env to arm.
|
||||||
|
|||||||
@@ -391,6 +391,20 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- agent-grok-image
|
- 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)
|
# Sandbox PG Image Builder (kitchen-sink postgres for parameterized dev DBs)
|
||||||
# Only pulled by the provisioner when a venture requests pg extensions; bare
|
# 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
|
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
|
||||||
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
|
# 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}
|
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}
|
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
|
||||||
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
|
# 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
|
# 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;
|
# (grok_auth.refresh_if_stale) so agents never mount a dead credential;
|
||||||
# each agent's own auth.json mount stays read-only.
|
# each agent's own auth.json mount stays read-only.
|
||||||
- ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
|
- ${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)
|
# Shared config directory for MCP configs (writable)
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
|
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
|
||||||
@@ -681,6 +702,8 @@ services:
|
|||||||
# Per-agent GROK usage capture: each Grok agent writes usage.json under
|
# Per-agent GROK usage capture: each Grok agent writes usage.json under
|
||||||
# <agent_id>/; the finalizer reads the captured tokens/cost back here.
|
# <agent_id>/; the finalizer reads the captured tokens/cost back here.
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
|
- ${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
|
# Persistent logs — survive `docker compose down/up`. Orchestrator and
|
||||||
# each spawned agent write structured logs here so we can audit past
|
# each spawned agent write structured logs here so we can audit past
|
||||||
# runs instead of relying on ephemeral `docker logs`.
|
# runs instead of relying on ephemeral `docker logs`.
|
||||||
|
|||||||
@@ -391,6 +391,20 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- agent-grok-image
|
- 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)
|
# Sandbox PG Image Builder (kitchen-sink postgres for parameterized dev DBs)
|
||||||
# Only pulled by the provisioner when a venture requests pg extensions; bare
|
# 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
|
# SuperGrok auth (host ~/.grok) for Grok-CLI agents — the orchestrator
|
||||||
# mounts <dir>/auth.json into each Grok agent. Run `grok login` on the host.
|
# 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}
|
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}
|
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
|
||||||
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
|
# 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
|
# 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;
|
# (grok_auth.refresh_if_stale) so agents never mount a dead credential;
|
||||||
# each agent's own auth.json mount stays read-only.
|
# each agent's own auth.json mount stays read-only.
|
||||||
- ${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}:${ROBOCO_HOST_GROK_DIR:-/home/renzof/.grok}
|
- ${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)
|
# Shared config directory for MCP configs (writable)
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
|
- ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault
|
||||||
@@ -681,6 +702,8 @@ services:
|
|||||||
# Per-agent GROK usage capture: each Grok agent writes usage.json under
|
# Per-agent GROK usage capture: each Grok agent writes usage.json under
|
||||||
# <agent_id>/; the finalizer reads the captured tokens/cost back here.
|
# <agent_id>/; the finalizer reads the captured tokens/cost back here.
|
||||||
- ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage
|
- ${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
|
# Persistent logs — survive `docker compose down/up`. Orchestrator and
|
||||||
# each spawned agent write structured logs here so we can audit past
|
# each spawned agent write structured logs here so we can audit past
|
||||||
# runs instead of relying on ephemeral `docker logs`.
|
# 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",
|
provider_type: "grok",
|
||||||
display_name: "Grok Build 0.1",
|
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 })),
|
getOllamaKey: vi.fn(async () => ({ has_key: false, enabled: true })),
|
||||||
setOllamaKey: vi.fn(async () => ({ has_key: true, 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 }) =>
|
||||||
c.provider_type === ModelProvider.GROK,
|
c.provider_type === ModelProvider.GROK,
|
||||||
);
|
);
|
||||||
|
const catalogOpenaiOnly = catalog.filter(
|
||||||
|
(c: { provider_type: ModelProvider }) =>
|
||||||
|
c.provider_type === ModelProvider.OPENAI,
|
||||||
|
);
|
||||||
const catalogAnthropicOnly = catalog.filter(
|
const catalogAnthropicOnly = catalog.filter(
|
||||||
(c: { provider_type: ModelProvider }) =>
|
(c: { provider_type: ModelProvider }) =>
|
||||||
c.provider_type === ModelProvider.ANTHROPIC,
|
c.provider_type === ModelProvider.ANTHROPIC,
|
||||||
@@ -1033,6 +1037,29 @@ export function AIRoutingCard() {
|
|||||||
</SelectGroup>
|
</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 */}
|
{/* Ollama Cloud models */}
|
||||||
{catalogOllamaOnly.length > 0 && (
|
{catalogOllamaOnly.length > 0 && (
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
@@ -1247,19 +1274,21 @@ function errMsg(e: unknown): string {
|
|||||||
function ProviderBadge({
|
function ProviderBadge({
|
||||||
variant,
|
variant,
|
||||||
}: {
|
}: {
|
||||||
variant: "anthropic" | "grok" | "ollama" | "self-hosted";
|
variant: "anthropic" | "grok" | "openai" | "ollama" | "self-hosted";
|
||||||
}) {
|
}) {
|
||||||
const styles: Record<string, string> = {
|
const styles: Record<string, string> = {
|
||||||
anthropic: "bg-blue-500/20 text-blue-700 dark:text-blue-400",
|
anthropic: "bg-blue-500/20 text-blue-700 dark:text-blue-400",
|
||||||
ollama: "bg-violet-500/20 text-violet-700 dark:text-violet-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",
|
"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",
|
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> = {
|
const labels: Record<string, string> = {
|
||||||
anthropic: "A",
|
anthropic: "A",
|
||||||
ollama: "O",
|
ollama: "O",
|
||||||
"self-hosted": "S",
|
"self-hosted": "S",
|
||||||
grok: "G",
|
grok: "G",
|
||||||
|
openai: "C",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ export interface ModelAssignment {
|
|||||||
model_name: string;
|
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 =
|
export type RoutingMode =
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "grok"
|
| "grok"
|
||||||
|
| "codex"
|
||||||
| "ollama"
|
| "ollama"
|
||||||
| "self_hosted"
|
| "self_hosted"
|
||||||
| "mix"
|
| "mix"
|
||||||
|
|||||||
@@ -201,9 +201,17 @@ class ApplyModeRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ModeResponse(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]
|
assignments: list[AssignmentResponse]
|
||||||
|
|
||||||
|
|
||||||
@@ -267,7 +275,9 @@ class RoutingPresetApplyResponse(BaseModel):
|
|||||||
`ModeResponse`) plus any per-entry skip notes (e.g. a since-removed
|
`ModeResponse`) plus any per-entry skip notes (e.g. a since-removed
|
||||||
catalog model) — never a partial/silent apply."""
|
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]
|
assignments: list[AssignmentResponse]
|
||||||
skipped: list[str]
|
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.
|
* **Anthropic** — priced from the table below by substring match.
|
||||||
* **Priced non-Anthropic** — xAI Grok (``grok-build-*``, billed per token via
|
* **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
|
* **Free non-Anthropic** — local self-hosted Ollama models (``ollama/`` prefix
|
||||||
or bare model tags) and Ollama Cloud models (``:cloud`` tag). These have **no
|
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
|
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
|
# 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
|
# the normal input rate. https://docs.x.ai/developers/models
|
||||||
("grok-build", 1.00, 2.00, 0.20, 1.00),
|
("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
|
# Short aliases used in ROLE_MODEL_MAP / MODEL_MAP
|
||||||
("opus", 5.00, 25.00, 0.50, 6.25),
|
("opus", 5.00, 25.00, 0.50, 6.25),
|
||||||
("sonnet", 3.00, 15.00, 0.30, 0.75),
|
("sonnet", 3.00, 15.00, 0.30, 0.75),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import os
|
|||||||
import posixpath
|
import posixpath
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -1838,6 +1839,28 @@ class Settings(BaseSettings):
|
|||||||
"0 disables. Override via ROBOCO_GROK_MAX_COST_USD"
|
"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
|
# An interactive intake/secretary chat the human abandoned (closed the tab
|
||||||
# without confirming/stopping) otherwise leaks its container until the
|
# without confirming/stopping) otherwise leaks its container until the
|
||||||
# orchestrator restarts. The sweeper reaps a live session whose
|
# 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).
|
also serves Ollama Cloud / self-hosted via ``ANTHROPIC_BASE_URL`` injection).
|
||||||
- :class:`GrokCliProvider` — xAI Grok Build via the official ``grok`` CLI on the
|
- :class:`GrokCliProvider` — xAI Grok Build via the official ``grok`` CLI on the
|
||||||
SuperGrok subscription (mounted ``~/.grok`` auth, parity with the Claude path).
|
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.base import AgentProvider, ProviderError, SpawnResult
|
||||||
from roboco.llm.providers.claude_code import ClaudeCodeProvider
|
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.grok import GrokCliProvider
|
||||||
from roboco.llm.providers.registry import ProviderNotRegisteredError, ProviderRegistry
|
from roboco.llm.providers.registry import ProviderNotRegisteredError, ProviderRegistry
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentProvider",
|
"AgentProvider",
|
||||||
"ClaudeCodeProvider",
|
"ClaudeCodeProvider",
|
||||||
|
"CodexCliProvider",
|
||||||
"GrokCliProvider",
|
"GrokCliProvider",
|
||||||
"ProviderError",
|
"ProviderError",
|
||||||
"ProviderNotRegisteredError",
|
"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
|
Messages API, so GROK agents run through a dedicated OpenAI-protocol
|
||||||
provider (roboco.llm.providers.grok), not ANTHROPIC_BASE_URL injection.
|
provider (roboco.llm.providers.grok), not ANTHROPIC_BASE_URL injection.
|
||||||
The xAI key is set via PUT /api/providers/grok/key.
|
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"
|
ANTHROPIC = "anthropic"
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ MODEL_CATALOG: tuple[CatalogEntry, ...] = (
|
|||||||
# Routes to the GROK provider → GrokCliProvider spawn (api.x.ai/v1). The xAI
|
# Routes to the GROK provider → GrokCliProvider spawn (api.x.ai/v1). The xAI
|
||||||
# key is set via PUT /api/providers/grok/key.
|
# key is set via PUT /api/providers/grok/key.
|
||||||
CatalogEntry("grok-build-0.1", ModelProvider.GROK, "Grok Build 0.1"),
|
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 hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
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).
|
# (the grok analogue of reading the Claude transcript from the mounted ~/.claude).
|
||||||
# Override for local runs.
|
# Override for local runs.
|
||||||
GROK_USAGE_DATA_DIR = os.environ.get("ROBOCO_GROK_USAGE_DIR", "/data/grok-usage")
|
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
|
# Interactive Grok images (grok-CLI conversation drivers) — selected for the
|
||||||
# intake / secretary roles when their route resolves to GROK, instead of 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_EXIT_CODE = 78
|
||||||
_GROK_AUTH_RETRY_AFTER_S = 60.0
|
_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
|
# ORCHESTRATOR
|
||||||
@@ -1524,6 +1543,47 @@ class AgentOrchestrator:
|
|||||||
error=str(exc),
|
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(
|
async def _ensure_image_present(
|
||||||
self, bare_image: str, dockerfile_path: str, build_context: str
|
self, bare_image: str, dockerfile_path: str, build_context: str
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2834,6 +2894,8 @@ class AgentOrchestrator:
|
|||||||
# captured tokens back at finalize via the shared data volume
|
# captured tokens back at finalize via the shared data volume
|
||||||
# (see GROK_USAGE_DATA_DIR).
|
# (see GROK_USAGE_DATA_DIR).
|
||||||
"grok_usage": f"{DATA_HOST_PATH}/grok-usage/{config.agent_id}",
|
"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": (
|
"prompt": (
|
||||||
f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md"
|
f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md"
|
||||||
),
|
),
|
||||||
@@ -2856,6 +2918,9 @@ class AgentOrchestrator:
|
|||||||
"grok_usage": str(
|
"grok_usage": str(
|
||||||
Path(tempfile.gettempdir()) / "roboco-grok-usage" / config.agent_id
|
Path(tempfile.gettempdir()) / "roboco-grok-usage" / config.agent_id
|
||||||
),
|
),
|
||||||
|
"codex_usage": str(
|
||||||
|
Path(tempfile.gettempdir()) / "roboco-codex-usage" / config.agent_id
|
||||||
|
),
|
||||||
"prompt": str(
|
"prompt": str(
|
||||||
Path(tempfile.gettempdir())
|
Path(tempfile.gettempdir())
|
||||||
/ "roboco-prompts"
|
/ "roboco-prompts"
|
||||||
@@ -3225,20 +3290,31 @@ class AgentOrchestrator:
|
|||||||
"""Build (once) the registry of dedicated provider backends.
|
"""Build (once) the registry of dedicated provider backends.
|
||||||
|
|
||||||
Only providers that need a runtime other than the built-in Claude Code
|
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:
|
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
|
from roboco.models.base import ModelProvider
|
||||||
|
|
||||||
registry = ProviderRegistry()
|
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
|
# resolves in both local-build and registry deploys (parity with
|
||||||
# get_agent_image for the Claude path).
|
# get_agent_image for the Claude path).
|
||||||
registry.register(
|
registry.register(
|
||||||
ModelProvider.GROK,
|
ModelProvider.GROK,
|
||||||
GrokCliProvider(self, image=_qualify_agent_image("roboco-agent-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
|
self._provider_registry = registry
|
||||||
return self._provider_registry
|
return self._provider_registry
|
||||||
|
|
||||||
@@ -6114,13 +6190,36 @@ class AgentOrchestrator:
|
|||||||
branched dir the writers mount (``_grok_usage_dir``). Returns ``None`` when
|
branched dir the writers mount (``_grok_usage_dir``). Returns ``None`` when
|
||||||
absent / unreadable.
|
absent / unreadable.
|
||||||
"""
|
"""
|
||||||
# os.path.basename keeps only the final path component of the agent id
|
return self._read_usage_json_contained(self._grok_usage_root(), 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
|
@staticmethod
|
||||||
# '.' / '..' / separators / NUL upstream (a bad id raises -> None here).
|
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:
|
try:
|
||||||
usage_json = self._grok_usage_dir(os.path.basename(agent_id)) / "usage.json"
|
root = os.path.realpath(base)
|
||||||
data = json.loads(usage_json.read_text(encoding="utf-8"))
|
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):
|
except (OSError, ValueError, json.JSONDecodeError):
|
||||||
return None
|
return None
|
||||||
return data if isinstance(data, dict) else None
|
return data if isinstance(data, dict) else None
|
||||||
@@ -6159,6 +6258,58 @@ class AgentOrchestrator:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 0.0
|
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:
|
async def _enforce_grok_cost_budget(self) -> None:
|
||||||
"""Kill a live GROK container whose captured cost exceeds the cap.
|
"""Kill a live GROK container whose captured cost exceeds the cap.
|
||||||
|
|
||||||
@@ -6226,17 +6377,21 @@ class AgentOrchestrator:
|
|||||||
) -> tuple[int, int, int, int]:
|
) -> tuple[int, int, int, int]:
|
||||||
"""Resolve final token counts for a stopping agent.
|
"""Resolve final token counts for a stopping agent.
|
||||||
|
|
||||||
For a GROK agent, reads the captured ``usage.json`` (no SDK server /
|
For a GROK or OPENAI (codex) agent, reads the captured ``usage.json``
|
||||||
Claude transcript exists). Otherwise tries the live SDK ``/usage/status``
|
(no SDK server / Claude transcript exists for either). Otherwise tries
|
||||||
first; if that misses — the SDK's in-memory counts race container teardown
|
the live SDK ``/usage/status`` first; if that misses — the SDK's
|
||||||
for short-lived agents — it falls back to the agent's Claude Code
|
in-memory counts race container teardown for short-lived agents — it
|
||||||
transcript, which is durable and mounted into this container. Returns
|
falls back to the agent's Claude Code transcript, which is durable and
|
||||||
|
mounted into this container. Returns
|
||||||
``(input, output, cache_read, cache_write)``.
|
``(input, output, cache_read, cache_write)``.
|
||||||
"""
|
"""
|
||||||
from roboco.models.base import ModelProvider
|
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)
|
return self._grok_usage_tokens(agent_id)
|
||||||
|
if provider == ModelProvider.OPENAI.value:
|
||||||
|
return self._codex_usage_tokens(agent_id)
|
||||||
|
|
||||||
tokens = (0, 0, 0, 0)
|
tokens = (0, 0, 0, 0)
|
||||||
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
|
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
|
assistant-message count) for short-lived agents whose SDK counts race
|
||||||
teardown; ``tool_calls`` has no transcript equivalent and stays 0 ("n/a")
|
teardown; ``tool_calls`` has no transcript equivalent and stays 0 ("n/a")
|
||||||
when the SDK misses. Grok agents have neither — returns ``(0, 0)``.
|
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
|
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)
|
return (0, 0)
|
||||||
|
if provider == ModelProvider.OPENAI.value:
|
||||||
|
return (self._codex_usage_turns(agent_id), 0)
|
||||||
|
|
||||||
turns = tool_calls = 0
|
turns = tool_calls = 0
|
||||||
sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
|
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
|
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
|
back to the durable transcript (the SDK can report zero mid-run, the
|
||||||
same race the finalize path handles). Returns ``None`` when neither
|
same race the finalize path handles). Returns ``None`` when neither
|
||||||
source has any usage yet. GROK has no SDK server or Claude transcript,
|
source has any usage yet. GROK / OPENAI (codex) have no SDK server or
|
||||||
so it routes to its ``usage.json`` — the same early return the finalize
|
Claude transcript, so each routes to its own ``usage.json`` — the same
|
||||||
path uses, so live USAGE_SNAPSHOT reflects grok agents mid-run too.
|
early return the finalize path uses, so live USAGE_SNAPSHOT reflects
|
||||||
|
grok/codex agents mid-run too.
|
||||||
"""
|
"""
|
||||||
instance = self._instances.get(agent_id)
|
instance = self._instances.get(agent_id)
|
||||||
is_grok = (
|
provider = (
|
||||||
instance is not None
|
instance.config.provider_type
|
||||||
and instance.config is not None
|
if instance is not None and instance.config is not None
|
||||||
and instance.config.provider_type == ModelProvider.GROK.value
|
else None
|
||||||
)
|
)
|
||||||
if is_grok:
|
if provider == ModelProvider.GROK.value:
|
||||||
grok_tokens = self._grok_usage_tokens(agent_id)
|
grok_tokens = self._grok_usage_tokens(agent_id)
|
||||||
return grok_tokens if any(grok_tokens) else None
|
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)
|
tokens = await self._fetch_agent_tokens(client, agent_id)
|
||||||
if tokens is not None:
|
if tokens is not None:
|
||||||
return tokens
|
return tokens
|
||||||
@@ -8091,6 +8255,16 @@ Start by:
|
|||||||
if self._is_grok_auth_exit(instance, exit_code):
|
if self._is_grok_auth_exit(instance, exit_code):
|
||||||
await self._park_grok_auth_unavailable(agent_id, instance)
|
await self._park_grok_auth_unavailable(agent_id, instance)
|
||||||
return
|
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
|
graceful = exit_code == 0
|
||||||
# Park the provider on a session/usage limit or a server overload detected
|
# 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
|
# 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
|
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
|
@staticmethod
|
||||||
async def _tail_container_logs(container_name: str, lines: int = 80) -> str:
|
async def _tail_container_logs(container_name: str, lines: int = 80) -> str:
|
||||||
"""Return the last ``lines`` of a container's combined output, '' on error.
|
"""Return the last ``lines`` of a container's combined output, '' on error.
|
||||||
@@ -9718,6 +9919,41 @@ Start by:
|
|||||||
kind="auth_missing",
|
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
|
@staticmethod
|
||||||
def _too_early_to_probe(state: dict[str, Any]) -> bool:
|
def _too_early_to_probe(state: dict[str, Any]) -> bool:
|
||||||
"""True while the estimated lift time (activated_at + retry_after) is future.
|
"""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()
|
self._dispatch_wake.clear()
|
||||||
await self._refresh_grok_auth()
|
await self._refresh_grok_auth()
|
||||||
|
await self._refresh_codex_auth()
|
||||||
await self._dispatch_all_work()
|
await self._dispatch_all_work()
|
||||||
await self._emit_dispatcher_heartbeat()
|
await self._emit_dispatcher_heartbeat()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -10932,6 +11169,34 @@ Start now: evidence(task_id="{task_id}")
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("grok auth refresh hook error", error=str(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:
|
async def _reconcile_orphan_claims_on_startup(self) -> None:
|
||||||
"""Roll back tasks left in CLAIMED/IN_PROGRESS without a branch.
|
"""Roll back tasks left in CLAIMED/IN_PROGRESS without a branch.
|
||||||
|
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ class ModelRoutingService(BaseService):
|
|||||||
|
|
||||||
async def derive_mode(
|
async def derive_mode(
|
||||||
self,
|
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.
|
"""Return the current "mode" label for the Settings UI.
|
||||||
|
|
||||||
Decision tree matches what `apply_mode` writes:
|
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, Ollama Cloud → "ollama"
|
||||||
- only a global row, LOCAL → "self_hosted"
|
- only a global row, LOCAL → "self_hosted"
|
||||||
- anything else → "mix"
|
- 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()
|
assignments = await self.list_assignments()
|
||||||
if not assignments:
|
if not assignments:
|
||||||
@@ -397,6 +403,8 @@ class ModelRoutingService(BaseService):
|
|||||||
if only_global:
|
if only_global:
|
||||||
if assignments[0].provider.type == ModelProvider.GROK:
|
if assignments[0].provider.type == ModelProvider.GROK:
|
||||||
return "grok"
|
return "grok"
|
||||||
|
if assignments[0].provider.type == ModelProvider.OPENAI:
|
||||||
|
return "codex"
|
||||||
if assignments[0].provider.type == ModelProvider.OLLAMA_CLOUD:
|
if assignments[0].provider.type == ModelProvider.OLLAMA_CLOUD:
|
||||||
return "ollama"
|
return "ollama"
|
||||||
if assignments[0].provider.type == ModelProvider.LOCAL:
|
if assignments[0].provider.type == ModelProvider.LOCAL:
|
||||||
|
|||||||
@@ -52,7 +52,16 @@ async def llm_setup(
|
|||||||
enabled=True,
|
enabled=True,
|
||||||
base_url="https://ollama.example.com",
|
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()
|
await db_session.flush()
|
||||||
yield {"svc": ModelRoutingService(db_session)}
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_derive_mode_mix_with_per_agent(llm_setup: dict) -> None:
|
async def test_derive_mode_mix_with_per_agent(llm_setup: dict) -> None:
|
||||||
svc = llm_setup["svc"]
|
svc = llm_setup["svc"]
|
||||||
@@ -393,6 +414,32 @@ async def test_resolve_for_agent_uses_global_assignment(
|
|||||||
assert route.model_name == model
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_for_agent_uses_provider_token(llm_setup: dict) -> None:
|
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)."""
|
"""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)
|
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:
|
def test_get_model_routing_service_factory(db_session: AsyncSession) -> None:
|
||||||
"""Factory wraps ModelRoutingService with the given session (line 369)."""
|
"""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_READ = 0.20
|
||||||
_GROK_CACHE_WRITE = 1.00
|
_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
|
# Tolerance for floating-point comparisons
|
||||||
_TOL = 1e-4
|
_TOL = 1e-4
|
||||||
|
|
||||||
@@ -330,6 +337,55 @@ class TestGrokTier:
|
|||||||
assert calculate_cost("grok-build-0.1", tokens_input=_M, tokens_output=0) > 0.0
|
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
|
# Unknown / edge cases — must return 0.0 without raising
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -489,6 +545,14 @@ class TestCostResult:
|
|||||||
assert result.unpriced is False
|
assert result.unpriced is False
|
||||||
assert result.is_anthropic 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:
|
def test_calculate_cost_matches_structured_cost_usd(self) -> None:
|
||||||
model = "claude-opus-4-6"
|
model = "claude-opus-4-6"
|
||||||
assert (
|
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
|
import pytest
|
||||||
from roboco.llm.providers import (
|
from roboco.llm.providers import (
|
||||||
ClaudeCodeProvider,
|
ClaudeCodeProvider,
|
||||||
|
CodexCliProvider,
|
||||||
GrokCliProvider,
|
GrokCliProvider,
|
||||||
ProviderError,
|
ProviderError,
|
||||||
ProviderNotRegisteredError,
|
ProviderNotRegisteredError,
|
||||||
@@ -37,6 +38,16 @@ def _isolate_grok_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|||||||
return tmp_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(
|
def _config(
|
||||||
*,
|
*,
|
||||||
agent_id: str = "be-dev-1",
|
agent_id: str = "be-dev-1",
|
||||||
@@ -85,6 +96,9 @@ class _FakeHost:
|
|||||||
def _ensure_grok_usage_dir(self, agent_id: str) -> None:
|
def _ensure_grok_usage_dir(self, agent_id: str) -> None:
|
||||||
self.data_dirs_ensured.append(agent_id)
|
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(
|
def _resolve_host_paths(
|
||||||
self, config: OrchestratorAgentConfig, agent_settings_path: Path | None
|
self, config: OrchestratorAgentConfig, agent_settings_path: Path | None
|
||||||
) -> dict[str, str | None]:
|
) -> dict[str, str | None]:
|
||||||
@@ -94,6 +108,7 @@ class _FakeHost:
|
|||||||
else None,
|
else None,
|
||||||
"settings": str(agent_settings_path) if agent_settings_path else None,
|
"settings": str(agent_settings_path) if agent_settings_path else None,
|
||||||
"grok_usage": f"/host/data/grok-usage/{config.agent_id}",
|
"grok_usage": f"/host/data/grok-usage/{config.agent_id}",
|
||||||
|
"codex_usage": f"/host/data/codex-usage/{config.agent_id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_mount_args(
|
def _build_mount_args(
|
||||||
@@ -312,6 +327,156 @@ async def test_grok_spawn_raises_on_docker_failure() -> None:
|
|||||||
await provider.spawn(_config())
|
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
|
# 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