Files
roboco/alembic/versions/083_seed_openai_provider.py
c70ff3cf9a 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>
2026-07-23 03:20:29 +02:00

85 lines
3.4 KiB
Python

"""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)'"))