Files
Corey Zumar 1732faf3f3 feat: one harness-truth source for every model surface (listings, defaults, reports, confirmed switching) (#5022)
* feat(host): answer pre-launch model listings by probing the real harnesses

The pre-launch pickers were fed by catalog reconstruction — for a
Databricks-gateway codex host, serving-endpoint name enumeration: id
spellings the gateway's codex surface does not route, chat-only traps
(gpt-oss), no display names or effort ladders. The harness itself is
the only authority on what its /model picker would offer, so the host
now asks the harnesses:

- codex-native: probe_codex_model_options boots codex app-server with
  the SAME Databricks materialization a session launch gets (shared
  _databricks_launch_materialization, extracted from
  build_codex_native_server so the two cannot drift), a persistent
  probe CODEX_HOME (codex's own models_cache ETag makes refreshes
  cheap), and passes model/list rows through verbatim with a single
  default marker (launch pin first, else codex's own). Scoped to
  Databricks-profile launches; everything else — and every probe
  failure — falls open to the existing catalog path unchanged.
- claude-native: session launches (and the probe) now opt in to Claude
  Code's gateway model discovery
  (CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 in the ucode env; the
  fetch 404s harmlessly until the gateway serves /v1/models).
  probe_claude_gateway_models runs claude -p "/model" with the launch
  env so the harness executes its own discovery, then reads the
  harness-written gateway-models.json artifact — no discovery
  semantics replicated. Rows union with the configured tier rows,
  exact-id deduped. The nonessential-traffic kill-switch is stripped
  from the probe env (Claude treats it as covering discovery).
- claude-sdk: SDK-mode claude is a pass-through client with no catalog
  of its own, so the endpoint listing is the harness truth — served
  via the existing list_models_for_worker in the exact wire spelling
  the SDK sends.

Serving stays off the probe path: a new host-side cache
(omnigent/host/model_options_cache.py) keys results by a resolved-
config fingerprint, serves stale-while-revalidating with single-flight
probes, and is prewarmed per tunnel connection — measured 65ms at the
REST route warm, ~1.3s joining the prewarm probe cold. The
model-options frame is now answered from a tracked task instead of
inline on the tunnel receive loop (a cold probe there stalled every
frame — same class as a83cc707); a filesystem frame answered in 22ms
mid-probe. The REST route also stops dropping the routable_models the
frame already carries (openapi regenerated).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(host): always probe Claude Code itself for the model list

claude -p "/model" makes the harness print its own alias enumeration
headlessly, so the curated static subscription list demotes from
first resort to failure fallback. probe_claude_gateway_models
generalizes to probe_claude_model_options: it runs for every config
shape (bare subscription launches included), parses the printed
"Available:" aliases verbatim (no alias names known to the parser, so
new Claude releases flow through), and still reads the discovery
artifact when the env opts in. The host lane serves configured tier
rows (the rich spelling for pinned aliases) unioned with the
harness's printed aliases and discovered gateway rows, exact-id
deduped; the configured/static rows stand alone only when the probe
itself fails.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(host): probe Codex for every launch shape, not just Databricks routing

Same mandate as the Claude lane: the harness always answers. The probe
drops its Databricks-profile gate — a non-profile launch boots codex
app-server with whatever -c overrides the launch resolved (provider
routing, the dismissal pin, or nothing) and reads model/list verbatim,
so subscription/CLI-login and custom-provider shapes get Codex's real
visible catalog instead of the static curated list (the stale
hyphenated-id class of bug) or the raw enumeration. With no
launch-pinned model, Codex's own default marker stands. The legacy
catalog paths remain solely as the probe-failure fallback, pinned by
the existing handler tests now running with a failing probe stub.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(host): resolve Claude model aliases to concrete versions in the picker

The probed alias list answered WHICH aliases exist but not what they land
on — 'opus' could be Opus 5.0 or 4.8 and the picker couldn't say. Ask the
harness that too: each printed alias gets its own headless
'--model <alias> -p /model' run in stream-json mode, whose init event
carries the exact resolved id and whose printed 'Current model:' line
carries the human label (only the effort suffix stripped). Rows become
{id: alias, model: exact id, displayName: 'alias — label'}; the web
picker already renders displayName, so no frontend change.

Resolution runs share the enumeration run's invocation assembly so the
two cannot drift, fan out under one bounded budget (startup dominates
and stretches with box load — measured 0.7s-17s for the same command —
so one wave covers a whole alias set), and fail per-alias back to the
bare row, never the probe. Live run resolves all 10 aliases in ~6s and
surfaces facts worth not guessing: fable[1m] resolves to plain
claude-fable-5, and 'best' pins to Fable rather than Opus.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(host): show Claude picker rows as resolved versions only

Presentation pass on the resolved alias rows: the display is the
harness's resolved label alone (the alias prefix was noise), 1M-context
resolutions always say '(1M context)' even where the harness's label
omits it (sonnet[1m] prints just 'Sonnet 5'), the 'default' alias never
becomes a row (the picker renders its own Default choice, so it was a
duplicate), and aliases resolving to an earlier row's exact (model,
label) are dropped — which removes 'best' and 'fable[1m]' as the
duplicates of fable's row they currently are, without hardcoding any
alias name. Launch ids are untouched; only displayName and row
membership change.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(host): dedupe Claude picker rows by resolved model alone

opusplan resolves to claude-sonnet-5 — a model the sonnet row already
lists — so the same duplicate-model rule that removes best and
fable[1m] now covers it: one picker row per resolved model, no alias
names hardcoded. A composite-mode alias would reappear only if it ever
resolved to a model no other alias offers.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(models): drop curated picker fallbacks; sessions ride the probe

The release-curated picker stand-ins in model_fallbacks are gone — the
live harness probes are the source of truth everywhere, and a path that
cannot probe now reports nothing rather than a plausible-but-stale list
(the codex entries even carried hyphenated spellings codex itself does
not use). Smart Routing's tables stay: rankings, arm menus, and probed
exclusions are router contract data no discovery API can provide, and
the ownership test now guards those records.

Companions so nothing regresses to empty:
- The subscription sonnet_5 pick degrades to Claude's own 'sonnet'
  alias instead of hunting a static list — the harness resolves it.
- The claude-sdk pre-launch lane rides the claude probe whenever the
  endpoint listing is empty (the SDK drives the claude CLI, so the
  CLI's aliases are its truth on subscription boxes).
- Existing sessions now match the new-session picker: the runner's
  claude-model-options endpoint resolves configured rows ∪ probe once
  per session via the new shared claude_model_options_with_probe (the
  host lane uses the same composition, so the two cannot drift),
  answering 503-pending while the probe is in flight (the server fetch
  already retries those) and falling back to configured rows past a
  grace so the catalog is never empty.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): friendly composer label for Claude [1m] aliases off-catalog

The composer chip prefers the session catalog's display name, but a
Claude bracket alias the catalog doesn't list (a pick made before the
catalog carried the row, e.g. on a session launched by an older runner)
fell through to the raw id — 'sonnet[1m] High'. Render that case as
'Sonnet (1M context)': title-cased family plus the context marker, no
version claimed, since only the harness knows which Sonnet the alias
lands on. Catalog hits keep the probed display name verbatim.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): version-agnostic 'Sonnet' fallback label kills the 4.6 flash

Cold-loading a claude session painted the composer chip 'Sonnet 4.6'
for the window before the session catalog arrived, then corrected to
'Sonnet 5' — the fallback label list pinned a version that only the
harness can know (reproduced via Playwright: 'Sonnet 4.6 High' at
3.96s → 'Sonnet 5 High' at 4.70s). The fallback now says just
'Sonnet'; the catalog's display name supersedes it wherever one has
arrived, so the pre-catalog window shows a coarser label, never a
wrong one. Same honesty for the sandbox new-chat picker and the
scheduled-task model dropdown, which render the same list.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(web): retire two redundant uses of the local Claude alias list

The composer chip's pre-catalog fallback now formats alias-shaped ids
mechanically (title-case family, '_N' → ' N', '[1m]' → ' (1M context)')
instead of looking them up in CLAUDE_NATIVE_MODELS — same rendering,
zero model knowledge. The sticky-model compatibility check collapses to
session-catalog membership alone: its isClaudeNativeModel conjunct was
subsumed by the catalog check it was AND-ed with, and would have
rejected catalog rows whose ids don't look Claude-ish even though the
session's own catalog offered them. The now-orphaned guard is deleted;
the list itself stays for the genuinely hostless surfaces (sandbox
picker, unpinned scheduled tasks, schema enums).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(e2e_ui): composer label stays version-free until the catalog speaks

Covers the label behaviors the model-listing work changed: with the
session catalog held back, the composer chip renders the alias
mechanically ('sonnet[1m]' → 'Sonnet (1M context)'), and only the
arriving catalog upgrades it to its display name ('Sonnet 5 (1M
context)'). Every painted label is recorded via a MutationObserver so a
transient raw id or invented version ('Sonnet 4.6') cannot hide from a
retrying expect().

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(web): offer Smart Routing in the in-session gear for native panes

The in-session composer gear withheld the Smart Routing model option
from native Claude Code / Codex sessions under a stale premise ('their
CLI bakes the model at launch') — the server has routed native panes
per turn via /model injection since the create-time gear gained the
option, and validates routing-on creates with a per-family rule. The
in-session gate now mirrors that exact rule: a router must answer for
the session's family — the external AI-Gateway router only when the
host runs the family through the gateway (read off the session's host
row; absent rows fail open like the landing), the built-in judge
anywhere. SDK/bundle sessions keep their existing flag-only gate.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* style(web): prettier over the routing-gate and label changes

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(runner): sys_list_models subscription row is an honest empty listing

The curated claude stand-ins are gone from the static subscription
path; the dispatch test now pins the empty-models shape with the
probing note, matching the model-catalog contract.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): composer gear rides the host probe until the session catalog lands

A fresh codex session's gear showed a sparse Model row and no Effort row
for ~15s: effort levels come from the session catalog's
supportedReasoningEfforts, and that catalog only resolves once codex
app-server answers model/list. The session's host already probed the
same harness for the new-chat picker, so the gear (and the composer
chip) now falls back to those cached rows — same ids the launch accepts,
~90ms warm — whenever the session's own catalog is empty; the runner's
per-session catalog supersedes them the moment it arrives. Claude
sessions get the same pre-catalog Model list for free (their effort
levels were already static).

Verified live on a fresh codex session: Effort visible 0.5s after
create+load with the session catalog still empty, offering the host
row's levels.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): keep model-options identity stable when the host fallback is idle

The pre-catalog host fallback returned a fresh empty array whenever the
session catalog was empty and no host rows existed — for EVERY session
shape, native or not. That new identity per render re-rendered each
options consumer (composer, gear, agent-info popover) on every
streaming/liveness tick, which under CI load tipped the agent-info
hover-open grace race (shard 1 failed the same popover test twice).
Substitute only when host rows actually exist; otherwise the store's own
stable array reference flows through untouched, restoring the exact
pre-fallback behavior for every session the feature doesn't apply to.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(claude-native): apply picked model aliases verbatim, never the default

Picking Fable in a gateway session's composer switched the pane to
Opus: resolve_claude_native_model_selection swapped an unpinned family
alias for the provider's default model (a degrade from the era when
the picker always showed every family alias), the vocabulary re-spelled
that default as its pinned alias, so the runner injected '/model opus'
for a 'fable' pick — and the statusLine mirror then recorded the wrong
model as the session override. Bracket aliases had sibling failures:
'/model sonnet[1m]' 503'd on a pinned env (no spelling for it) and
silently dropped the [1m] marker on a bare login (family-segment
step-down).

Picker rows are pin-backed or probe-vouched now, so a pick passes
through verbatim and Claude owns resolution:
- the resolver's no-pin gateway degrade is gone (an out-of-band
  unpinned pick now fails visibly at inference instead of silently
  running the default);
- bracket variants of the family aliases are their own /model
  arguments in the vocabulary — the harness enumerates them itself;
- the configured∪probe union drops probe rows whose resolved model is
  a bare canonical Anthropic id on an endpoint that routes its own ids
  only: the pick could never work there, so the row is not offered
  (a pinned family resolves to the endpoint's spelling and stays).

Reproduced at the runner layer (events → resolver → injected command):
picking 'fable' asserted '/model fable' and got '/model opus' before
the fix. An e2e_ui guard pins that the web PATCHes the picked row id
verbatim — the client layer was innocent.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(claude-native): mirror pane model switches in the catalog's vocabulary

Live verification of the verbatim-alias fix exposed the last surface in
the same family: after a web pick of 'sonnet[1m]' correctly switched the
pane, the statusLine mirror collapsed the observed model back to the
LEGACY picker vocabulary — 'databricks-claude-sonnet-5[1m]' became
'sonnet_5' — stomping the just-saved override with an id the session's
catalog doesn't list (and which a relaunch would resolve through the
custom-tier branch, silently dropping the 1M context).

_model_alias_for now speaks the catalog's row ids: 1M resolutions keep
their bracket marker ('sonnet[1m]'), and the legacy 'sonnet_5' opt-in
row is mirrored only on a config whose custom slot actually pins it —
read off the session's launch pins — since everywhere else the generic
sonnet row IS that model. This also restores the designed web→TUI
round-trip no-op: the mirrored alias now equals the persisted override,
so the server-side dedupe skips the write.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(codex-native): report the model a Default launch actually runs

A Default codex launch names no model, so nothing pinned the session's
config.toml — yet the profile still resolved a concrete model and passed it
as `-c model=`, which outranks the config copied from the user's shared
~/.codex home. The pane ran the resolved model while the session reported
the shared file's leftover one: the create dialog promised
"Default (GPT-5.6-Luna)" and the session then said GPT-5.4.

Pin the profile-resolved model in codex's own spelling, so the forwarder
mirror and the cost gate read the model this session runs. Mark that model
as the catalog default too — codex's own isDefault is its built-in
preference and named GPT-5.6-Sol on a session running Luna, which also fed
the composer gear an effort ladder the running model rejects.

Web side: fold catalog and codex spellings when resolving a session's model
onto a picker row, and stop borrowing the default row's effort levels for an
unresolved model.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(runner): keep the codex model-options test off the real launch

Session create launches Codex for real, and that launch owns the bridge
dir: it clears the state and its forwarder task rewrites both the state
and CODEX_HOME/config.toml after the response returns. On a machine
where Codex and a Databricks profile resolve, that wiped the seeded
state no matter which side of create seeded it, so the endpoint answered
503. Stub the launch; the endpoint, the bridge-state read, the
CODEX_HOME read, and the fake app-server client all stay real.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(web): name Codex's Default the same in both model gears

The composer gear and the new-session gear each built their own copy for
the Model row, so one session read a bare "Default" in the composer and
"Default (gpt-5.6-luna)" on the landing page, and the landing page
listed raw catalog ids where the composer listed display names. Neither
gear told the user which model Codex would actually run.

Move both labels into HarnessConfigControls next to the sentinels they
belong to and read them from there in both callers. Row ids are
untouched, so picks still submit the harness's own spelling.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* fix(claude-native): read the custom slot instead of guessing the model row

The terminal->web mirror mapped a concrete model id onto a picker row by
looking for a family name inside the id, so a routed Opus 4.9 landed on
the `opus` row that holds 4.8: the web showed the wrong model, and posting
that row back stepped the session off its launch pin. Resolve rows by
exact comparison against the launch pins instead, and read Claude Code's
one custom model slot to name its row rather than inferring it from the
model's spelling. A `[1m]` resolution stays a distinct row from its
non-bracket sibling.

The legacy `sonnet_5` row id and the substring spellings it used to be
matched by move into claude_model_vocabulary with a 0.10.0 removal note;
the substring leg now runs only when the exact comparison misses.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* refactor(web): drop the substring model-row match the picker never calls

`isModelImplicitlySelected` guessed which picker row a bound model belonged
to by searching for the row id inside the model name, which is why `sonnet`
matched `sonnet-5` and needed a special case per generation. Its only
caller sat in the branch taken when a session has no server-supplied model
list, and every native picker kind is on that list, so the branch ran with
an empty list and the call could not select anything.

Delete the function and collapse the caller to the server-list path. The
two suites that covered it go with it; nothing else exercised it.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* docs(claude-native): say plainly that gateway model discovery never fires

The launch env asks Claude Code to discover the gateway's model inventory,
and the comment claimed the only thing holding it back was a gateway that
did not serve `/v1/models` yet. The gateway serves it now, but the same env
sets CLAUDE_CODE_USE_GATEWAY, and the CLI fires that fetch only on its
first-party provider path — so the artifact is never written and the rows
read from it are always empty.

Name that in all three places a reader lands: the flag, the probe's
env-unset list (popping the nonessential-traffic switch is not enough), and
the artifact read itself.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* test(model-flows): add the harness-truth e2e suite, red-first

The model-flows design lands test-first: this suite encodes the target
behavior for every flow (pre-launch picker, default labels, create→launch
pane truth, gear parity, confirmed switching, terminal-side mirroring) and
is deliberately red today in the ways the analysis measured.

Two tiers. The hermetic tier drives the real SPA over the spawned server
with the session snapshot shaped at the browser edge and SSE frames pushed
through a captured stream controller; it runs in the normal e2e_ui lane.
The live tier (`live_model_flows` marker, opt-in via
OMNIGENT_E2E_MODEL_FLOWS=1) boots a real server + host from any checkout —
OMNIGENT_E2E_MODEL_FLOWS_REPO selects which, so the identical tests
produce the red-on-main matrix — flips provider shapes the way setup
writes default claims, launches real claude/codex TUIs, and asserts pane
truth over tmux.

Recorded pre-implementation: hermetic 4 red / 2 guard-green (the design's
predicted set exactly); live rows 1 and 5 red against unmodified main
(the frozen "Sonnet 4.6" static list; the empty/erroring codex pre-launch
answer).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(models): verbatim reported_model as the single display authority

Landing-order step 3 of the model-flows design. Sessions gain a
reported_model — the model the harness last said it is actually on, in
the harness's own spelling — stored as a new key in the existing
session_overrides blob (no DDL) and served on the snapshot's llm_model
field with precedence reported ?? spec model. external_model_change
writes and dedupes against it verbatim; the user's request
(model_override) is untouched, because requests and reports are separate
roles and only reports are ever displayed.

The claude forwarder now posts the status file's model byte-for-byte:
the alias-collapse mapper (_model_alias_for / _custom_slot_row_id) is
deleted — collapsing a routed Opus 4.9 onto the opus row holding 4.8 is
the bug class this kills — and the first-observation-silent-seed rule is
gone, so the launch's own model reports within seconds of spawn and the
composer is never blank-forever. The codex forwarder already posted raw
ids and needed no change.

The web renders and highlights models from the reported value alone:
exact id/model match against the catalog, with an off-catalog report
appended as its own raw row rather than relabeled onto a same-family
row. The sticky model becomes a pure preference — the silent bind-time
and delayed-catalog model_override PATCHes are removed (they wrote
requests the pane was never asked to honor), and session.model events
land on llmModel instead of the picker selection. Cost attribution
prefers the reported model too.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* feat(models): explicit launches from the shared catalog store

Landing-order step 4. Every native launch now pins its model explicitly,
resolved through the new on-disk catalog store
(omnigent/model_catalog_store.py — one probe result under a
launch-config fingerprint, read by every consumer), so nothing is left
to invisible CLI-private state and a stale config line can never govern
a session.

Claude: the enumeration probe runs in stream-json and captures its own
init-event model — the truthful Default — so claude_model_catalog marks
exactly one isDefault row (appending an off-list default, e.g. a
settings.json pin, as its own launchable row; never appending a bare
Anthropic spelling on an endpoint that rejects it). A Default
subscription launch passes --model with that default; an explicit
request is validated against the catalog and fails the launch loudly
when the list no longer carries it. The runner also records the launch
vocabulary onto the bridge after config resolution
(record_model_vocabulary), closing the model_env gap that made
mid-session /model conversion read the runner's ambient env.

Codex: the session-shaped probe home now links the account's real
auth.json (the catalog must answer for the account that will run — the
Sol-promised/Terra-offered mismatch dies here), a Default launch on
codex's own login resolves the account's real default instead of
inheriting the copied config line (the stale-gpt-5.4 400 class), and
build_codex_native_server emits -c model= alongside the config-copy pin
from one resolved value on every shape. A guard test pins the
argv/config-pin agreement across all provider shapes; on the profile
shape the file deliberately keeps codex's own spelling and the guard
asserts same-model rather than same-bytes (addendum).

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Serve every model surface from the shared harness catalog

One catalog file per (harness, launch-config fingerprint) now backs the
pre-launch picker, launch resolution, and the in-session gear:

- Host: catalog-backed model-options handlers with a concurrent, detached
  boot prewarm; probe failures answer ok+[] plus an error string the web
  new-session dialog displays. The in-memory ModelOptionsCache module is
  removed.
- Runner: unified GET /v1/sessions/{id}/model-options (harness-named
  routes stay as deprecated aliases until 0.11.0); the claude route waits
  briefly on the store's single-flight probe (503-pending past that) and
  the codex route writes live listings back to the store.
- Server: model-options loads go unified-first and fall back to the
  legacy route on 404; the hosts API forwards the host's error string.
- Deletions: static claude alias table, gateway-discovery artifact
  machinery, the configured-union composition, and the host's codex
  catalog reconstruction lanes.
- Tests isolate the catalog store per test so suites cannot touch the
  developer's real ~/.omnigent cache or boot real harness CLIs.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Confirm model switches through the harness before claiming them

Switching is now ask -> pending -> harness-confirmed on every lane:

- Runner (claude): after typing /model, verify against the statusLine
  snapshot the forwarder already polls (10s budget) — expected spellings
  come from the session's own catalog rows; a pane that never switches
  answers 503 so the server surfaces the swallowed-dialog case instead
  of the row silently claiming the pick. A shape with no snapshot stays
  unverifiable-but-successful.
- Runner (codex): the awaited thread/settings/update RPC is the
  confirmation; a missing Codex bridge now answers 503 instead of a
  silent 204, and plan-mode updates re-assert the reported model rather
  than a stale override.
- Server: the visible model_change_not_applied notice now carries the
  runner's own detail string.
- Web: a transient pendingModelChange marks the ask (spinner beside the
  composer chip); the chip keeps the reported model until session.model
  confirms, and the not-applied error (or a switch/bind) settles the
  indicator.

Also repairs tests/runner/conftest.py's REAL_CLAUDE_LAUNCH_CATALOG
export, which the previous commit's lint autofix stripped after its
consumers had been verified.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Name the true Default on every picker and pin gateway models

- The web's claude lane keeps the catalog's isDefault marker and all
  Default rows (new-chat select, its summary line, the session gear)
  label through the one shared defaultModelLabel — both harnesses now
  read "Default (X)" where X is the model a bare launch actually runs.
- Provider entries' models map (the existing flat tier keys — opus,
  sonnet, haiku, fable — beside default) now pins the claude alias
  vocabulary: the launch env derives ANTHROPIC_DEFAULT_*_MODEL from the
  declared tiers, models.default pins its own family when that family
  has no explicit key, and the declared ids become the config's
  routable set. Aliases on gateway endpoints resolve inside the
  gateway's own catalog instead of falling back to canonical Anthropic
  ids the gateway rejects.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Make the live model-flow rig trustworthy end to end

The step-8 before/after pass surfaced six defects in the live CUJ
scaffolding itself; with them fixed the suite is 16/16 green against the
implementation branch and red against main in the documented modes:

- Drop tests/conftest.py's inherited OMNIGENT_DISABLE_CATALOG_LOOKUP for
  the rig's spawned server/host — the databricks catalog was empty only
  inside the rig.
- Wait for the post-create navigation with page.wait_for_url: the sync
  Playwright API pumps events only inside playwright calls, so the old
  time.sleep poll read a page.url frozen at the landing route forever.
- Re-read a model dropdown opened during the host's boot-probe warm-up
  until rows (or the settled error) appear.
- Resolve a codex session's private CODEX_HOME through the bridge's own
  state.json (the dir is named by a runner-generated bridge id).
- Row 17: no Escape after a Radix select pick (it closes the whole gear
  modal), pick an effort that differs from the machine's global default,
  and poll for persistence while the browser is still open (the save's
  model leg holds until the pane confirms, so the effort PATCH is sent
  by the page seconds later).
- Snapshot and restore ~/.claude/settings.json around the suite: the
  real /model switches run under the real HOME and Claude persists every
  switch as the developer's global default.

Also: useHostModelOptions retries with backoff so a picker opened during
the boot-probe warm-up fills in when the single-flight probe completes
instead of pinning the transient error until reopened.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Wait out the boot-probe warm-up when reading Default labels

The codex launch pin now resolves through live Unity-Catalog discovery
(seconds on a cold host), so a landing model label read immediately
after opening the config renders the bare sentinel while the web's
retry loop is still filling the catalog. Give the label the same
warm-up wait the dropdown read already has.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Mark a model ask pending before its PATCH, not after

The PATCH is held open while the runner drives and confirms the switch,
so the harness's session.model report usually arrives before the PATCH
resolves. Setting pendingModelChange from the response overwrote the
report's clear and stranded the spinner until the hygiene timer. The ask
is now marked pending up front (and cleared if the PATCH throws); a
store test pins the report-beats-PATCH ordering.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Type full model ids verbatim on unpinned claude sessions

Picking a full-id catalog row (e.g. the appended default
'Opus 4.8 (1M context)') stepped down to '/model opus' — the family
alias resolves to claude's CURRENT generation, silently switching to
Opus 5 instead. The confirm layer caught and surfaced it; the
translation now passes claude-* full ids verbatim on envs with no alias
pins (claude's /model accepts full ids — the probe resolves them the
same way), while pinned envs keep exact-pin-or-fail-loud.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Defer mid-turn model switches instead of failing them

A /model typed during an active turn queues in Claude's composer and
applies when the turn settles — past the 10s confirm window — so the
runner surfaced a false 'was not switched' error for a switch still on
its way, and the injection's short dialog watch could leave the late
confirm dialog parked on the pane.

The confirm loop now answers the switch dialog whenever it renders
inside the window, and a timeout with the pane mid-turn answers success:
a detached watcher keeps answering the late dialog (hint-matched Enter
only, never blind; bounded budget) and the forwarder's verbatim report
settles the picker when the switch lands. An idle-pane timeout — the
genuine swallowed case — still fails loud.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Mark a provider launch pin as the claude catalog default

Default launches on provider-configured shapes pass --model
<config.model> explicitly, so the pin — not the enumeration run's own
model — is what a Default launch actually runs. The gateway-entry shape
(one pinned alias row) went unmarked when the enumeration reported no
default, leaving the picker on a bare 'Default'. Subscription shapes
keep the enumeration-derived marker, and the appended default row only
borrows the probe's printed label when it names the same model.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Merge origin/main and green the CI suite

The merge brought main's error-pill restyle, per-frame create refactor,
and codex live UC discovery alongside this branch's model-flow work.
Fixes to keep every suite green:

- Restore the SimpleNamespace import main added to tests/host/test_connect
  (the merge dropped it → ruff F821 + 2 NameErrors).
- Regenerate openapi.json for the reported_model wording (session.model
  event + llm_model field descriptions).
- Update the smart-routing-create catalog tests to expect the unified
  /model-options route the server now asks first (legacy alias is the
  404 fallback).
- Update the runner pending-catalog test: a provider shape's launch pin
  is appended as the marked default row.
- Adapt row15's e2e to main's collapsed error pill (expand to read the
  detail); move the routed-modal test's seed to llm_model (routed models
  arrive as the harness report now); pick codex landing options by their
  decorated display name (codex options now render display names like
  claude — the design's decorated-rows contract).
- Seed the in-session gear from the session's request only before any
  harness report exists, so a routed session names its model.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Make row15's error-pill expand retry-safe under suite load

The single headline click could land before the disclosure handler was
wired when the suite ran the pill under load, leaving the detail
collapsed and the assertion timing out. Retry the expand until the
detail shows — same as a person clicking again.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Null-guard the native model-id fold so cursor rows don't blank the page

findNativeModelOption's fold fallback (added when the codex catalog fold
moved client-side) called comparableModelId on option.model/id without a
null guard. Cursor picker rows arrive as { id, displayName } with
model === null on the wire (typed model?: string), and the
option.model !== undefined check let null through — comparableModelId(null)
then threw 'Cannot read properties of null (reading trim)' during render,
blanking the whole chat page for any cursor-native session.

comparableModelId is now null-safe (empty fold never matches a real
target) and the fallback rejects null ids/models. Regression test covers
a cursor-shaped options list with null models.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

* Add a per-harness e2e_ui render-smoke matrix (all native pickers)

One hermetic case per native model-picker harness (claude, codex, cursor,
kiro, opencode, pi): shape a seeded session as that harness with its
realistic model_options — including rows with an explicit model: null
(cursor/kiro/opencode's real wire shape, typed model?: string) and a
hostile null-id row — then render the session, open the gear, and assert
the composer renders, the model control lists the rows, and no uncaught
null-deref fires.

The null-model harness cases carry a non-matching model_override so the
model-id fold actually runs (an exact-id match would return before it),
which is precisely the path that once blanked the page. Validated red on
the pre-fix bundle (cursor/kiro/opencode crash) and green after — the
coverage the earlier per-harness tests missed by using model-omitted
(undefined) rows instead of the null shape.

Signed-off-by: dbczumar <corey.zumar@databricks.com>

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-20 16:24:23 -07:00

3202 lines
136 KiB
Python

"""Codex app-server process and JSON-RPC client for native TUI sessions."""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
import re
import shlex
import socket
import sys
import tempfile
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, TypeAlias, cast
import tomlkit
import websockets
from cachetools import TTLCache
from websockets.asyncio.client import ClientConnection
from omnigent import model_catalog
from omnigent.json_types import JsonObject as _JsonObject
if TYPE_CHECKING:
from omnigent.onboarding.provider_config import ProviderEntry
from omnigent.spec.types import AgentSpec
from omnigent.codex_model_vocabulary import codex_spawn_model
from omnigent.codex_native_bridge import write_policy_hook_config
from omnigent.codex_native_process_registry import (
CodexNativeProcessOwnerLock,
acquire_codex_native_process_owner_lock,
codex_native_session_tag_cmdline_arg,
reconcile_codex_native_process_registry,
register_codex_native_process,
unregister_codex_native_process,
)
from omnigent.inner import _proc
from omnigent.inner.codex_executor import (
_CODEX_ROUTER_HOOK_MODULE,
_clean_codex_env,
_codex_cli_version,
_codex_home_config_source_from_env,
_databricks_codex_auth_command,
_databricks_codex_base_url,
_databricks_codex_config_overrides,
_find_codex_cli,
_populate_codex_home_config,
_provider_codex_config_overrides,
codex_extended_catalog_requested,
codex_router_bridge_dir,
codex_router_hooks_settings,
codex_router_session_id,
codex_routing_hook_skip_reason,
materialize_codex_provider_config,
read_codex_model_catalog,
write_codex_hooks_file,
)
from omnigent.inner.databricks_executor import _databricks_gateway_host
_logger = logging.getLogger(__name__)
CodexMessage: TypeAlias = _JsonObject
CodexParams: TypeAlias = _JsonObject
# A bound app-server JSON-RPC request coroutine (``client.request`` or the
# SDK executor's ``_request``), so the trust helpers work over either transport.
CodexRequestFn = Callable[[str, CodexParams], Awaitable[CodexMessage]]
_CONNECT_RETRY_DELAY_SECONDS = 0.05
_CONNECT_TIMEOUT_SECONDS = 10.0
_MODEL_DISCOVERY_CACHE_SECONDS = 300.0
_STDERR_CHUNK_LIMIT = 65536
_UDS_WEBSOCKET_HANDSHAKE_URI = "ws://localhost/rpc"
_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES = 128 << 20
# hooks.json filename written into the private CODEX_HOME registering the
# Omnigent policy hook. Codex discovers it as a ``user``-layer hook
# source on every config load (see codex hooks ``discover_handlers``).
_CODEX_HOOKS_FILE = "hooks.json"
# Module the codex policy command hook runs. Also the marker used to
# identify *our* hooks in ``hooks/list`` output so the trust step never
# auto-trusts unrelated hooks that the user's symlinked ``config.toml``
# might declare.
_POLICY_HOOK_MODULE = "omnigent.codex_native_hook"
# Codex-side timeout for the policy hook subprocess. A TOOL_CALL/LLM_REQUEST
# ASK is now resolved server-side: ``POST /policies/evaluate`` parks the gate
# (URL-based elicitation) until a human answers or the deciding policy's
# ``ask_timeout`` elapses, and the hook's own request budget
# (``_EVALUATE_POLICY_TIMEOUT_S``) is a day to match. Codex must wait at least
# as long, or it kills the hook mid-park and the tool runs before the verdict
# arrives — exactly the bug that let sub-agent tool calls slip past the cost
# gate. Held at a day so the server-side ``ask_timeout`` is the single cap,
# mirroring claude-native's ``PermissionRequest`` hook (``timeout: 86400``).
_POLICY_HOOK_TIMEOUT_SECONDS = 86400
# Hook trust statuses that allow a hook to execute (see codex
# ``hook_trust_status``). Anything else means the hook is silently
# skipped — which for a policy gate is a fail-open we must reject.
_TRUSTED_HOOK_STATUSES = frozenset({"trusted", "managed"})
# Minimum codex CLI version whose ``hooks/list`` returns the
# ``currentHash`` / ``trustStatus`` fields the trust handshake needs.
# Below this codex never exposes those fields, so the policy hook can
# never be trusted (it just stays ``untrusted`` and is silently skipped)
# — we detect the old version up front and skip registration with a loud
# warning rather than crash startup on an un-trustable hook.
_MIN_POLICY_HOOK_CODEX_VERSION = (0, 129, 0)
# Minimum codex CLI version that accepts ``--dangerously-bypass-hook-trust``.
# Older binaries exit immediately on the unknown flag, so below this floor
# (including a version we could not parse) the flag is omitted and the
# interactive trust prompt may appear instead.
_MIN_BYPASS_HOOK_TRUST_CODEX_VERSION = (0, 131, 0)
_MODEL_MIGRATION_CATALOG_TIMEOUT_SECONDS = 3.0
def _string_object_dict(value: object) -> _JsonObject | None:
"""Return *value* as a string-keyed object mapping when valid."""
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
return None
return cast("_JsonObject", value)
def _object_list(value: object) -> list[object] | None:
"""Return *value* as an object list when valid."""
if not isinstance(value, list):
return None
return cast("list[object]", value)
def _format_codex_version(version: tuple[int, int, int] | None) -> str:
"""
Render a parsed codex version tuple for log / error messages.
:param version: Version tuple, e.g. ``(0, 129, 0)``, or ``None`` when
the version could not be determined.
:returns: A dotted string, e.g. ``"0.129.0"``, or ``"unknown"`` for
``None``.
"""
if version is None:
return "unknown"
return ".".join(str(part) for part in version)
def _toml_table_header_name(line: str) -> str | None:
"""
Return the TOML table name declared by *line*, if any.
This intentionally recognizes only normal table headers because the
injected Codex MCP server config is a normal table. Array tables are
left untouched.
:param line: One config line, e.g.
``"[mcp_servers.omnigent] # generated\n"``.
:returns: The table name, e.g. ``"mcp_servers.omnigent"``, or
``None`` when *line* is not a normal table header.
"""
stripped = line.strip()
if not stripped.startswith("["):
return None
if stripped.startswith("[["):
return None
end = stripped.find("]")
if end < 0:
return None
suffix = stripped[end + 1 :].strip()
if suffix and not suffix.startswith("#"):
return None
return stripped[1:end].strip()
def _remove_toml_table(text: str, table_name: str) -> str:
"""
Remove one TOML table and its subtables from a config document.
Used for generated private Codex config before appending the
Omnigent MCP server table. This avoids accumulating duplicate
``[mcp_servers.omnigent]`` sections across terminal relaunches.
:param text: TOML document text.
:param table_name: Table name to remove, e.g.
``"mcp_servers.omnigent"``.
:returns: TOML text with the target table block removed.
"""
kept: list[str] = []
skipping = False
for line in text.splitlines(keepends=True):
header = _toml_table_header_name(line)
if header is not None:
skipping = header == table_name or header.startswith(f"{table_name}.")
if not skipping:
kept.append(line)
return "".join(kept).rstrip()
#: Omnigent tools the framework calls on every session's behalf, pre-approved
#: so codex never raises an interactive prompt for them. The rename keeps a
#: session's title current, which the framework does unprompted on any session.
_FRAMEWORK_APPROVED_TOOLS: tuple[str, ...] = ("sys_session_rename",)
#: Additionally pre-approved for an auto-harness Smart Routing session, whose
#: spawns the router may move onto the counterpart harness family: these four
#: carry out that cross-harness redirect end to end — discover the agent, start
#: the routed child, deliver the task, collect its result. Without the last one
#: the redirect stalls on an approval prompt nobody is watching. A plain or
#: pinned session can never receive a redirect, so it gets none of them and its
#: approval surface stays a plain codex session's. Mirrors the claude-native
#: ``_ROUTED_SPAWN_ALLOWED_TOOLS`` gate.
_ROUTED_SPAWN_APPROVED_TOOLS: tuple[str, ...] = (
"sys_session_create",
"sys_agent_list",
"sys_session_send",
"sys_read_inbox",
)
def framework_approved_tools(*, routed_spawns: bool) -> tuple[str, ...]:
"""
Name the Omnigent tools this session pre-approves in codex.
:param routed_spawns: ``True`` for an auto-harness Smart Routing session,
which also needs the cross-harness redirect toolkit.
:returns: Tool names, in the order their approval tables are written.
"""
if not routed_spawns:
return _FRAMEWORK_APPROVED_TOOLS
return (*_FRAMEWORK_APPROVED_TOOLS, *_ROUTED_SPAWN_APPROVED_TOOLS)
def _codex_mcp_server_config_section(
bridge_dir: Path,
python_executable: str | None = None,
*,
routed_spawns: bool = False,
) -> str:
"""
Build the generated Codex MCP server TOML section.
:param bridge_dir: Bridge directory containing ``bridge.json`` and
``tool_relay.json``.
:param python_executable: Python executable for serve-mcp, e.g.
``"/path/to/.venv/bin/python"``. ``None`` uses
:data:`sys.executable`.
:param routed_spawns: ``True`` for an auto-harness Smart Routing session,
which pre-approves the cross-harness redirect tools too.
:returns: TOML text for ``[mcp_servers.omnigent]`` and its
framework-managed tool approvals.
"""
python = python_executable or sys.executable
args = [
"-I",
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
]
args_toml = ", ".join(json.dumps(a) for a in args)
approvals = "\n".join(
f'[mcp_servers.omnigent.tools.{tool}]\napproval_mode = "approve"\n'
for tool in framework_approved_tools(routed_spawns=routed_spawns)
)
return (
f"[mcp_servers.omnigent]\n"
f"command = {json.dumps(python)}\n"
f"args = [{args_toml}]\n\n"
f"{approvals}"
)
# Top-level ``model_reasoning_effort = "<value>"`` line, capturing the value so
# it can be clamped to one the pinned model accepts. Tolerates a trailing comment.
_EFFORT_KEY_RE = re.compile(r'^(\s*model_reasoning_effort\s*=\s*")([^"]*)("\s*(?:#.*)?)$')
def _pin_codex_config_model(codex_home: Path, model: str) -> None:
"""
Write *model* as the top-level ``model`` key in the session config.toml.
The per-session ``config.toml`` starts as a copy of the user's shared
one, so its ``model`` line is whatever the user last ran — NOT this
session's launch model. The forwarder mirrors that file into
``model_override`` and the cost gate's hook reads it, so without this
seed a per-dispatch model override is silently misreported (live-caught:
a child launched on ``databricks-gpt-5-4-mini`` was mirrored back as the
shared file's stale ``gpt-5.5``). An in-TUI ``/model`` later overwrites
the same line, so user switches still win.
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param model: Validated model id to pin.
"""
from omnigent.reasoning_effort import clamp_effort_for_model
config_path = codex_home / "config.toml"
# Same symlink-materialization dance as the MCP injection: never edit
# the user's real config.toml through the link.
if config_path.is_symlink():
target = config_path.resolve()
config_path.unlink()
if target.is_file():
import shutil
shutil.copy2(target, config_path)
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
pin_line = f"model = {json.dumps(model)}"
lines = existing.splitlines()
replaced = False
for i, line in enumerate(lines):
if line.startswith("["):
break
if re.match(r"^model\s*=", line):
lines[i] = pin_line
replaced = True
continue
# The config copies the user's default effort (e.g. xhigh), which the
# pinned model may reject (GLM has no xhigh). Clamp it to a value the
# model accepts rather than 400 the turn.
effort_match = _EFFORT_KEY_RE.match(line)
if effort_match:
clamped = clamp_effort_for_model(effort_match.group(2), model)
if clamped and clamped != effort_match.group(2):
lines[i] = f"{effort_match.group(1)}{clamped}{effort_match.group(3)}"
if not replaced:
lines.insert(0, pin_line)
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _sync_codex_developer_instructions(
codex_home: Path,
instructions: str | None,
) -> None:
"""Synchronize framework instructions in the private Codex config.
Codex's top-level ``developer_instructions`` setting is additive to its
built-in operating instructions. The collaboration-mode field is not: a
non-null value replaces the mode's defaults. The private session config
therefore stores the user's original value in a sidecar, then derives the
active value from that base on every launch. Fresh sessions append the
framework directive; resumed sessions restore the unmodified base.
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param instructions: Framework instructions for this launch, or ``None``.
:returns: None.
"""
addition = instructions.strip() if instructions else ""
config_path = codex_home / "config.toml"
base_path = codex_home / ".omnigent-developer-instructions-base"
if config_path.is_symlink():
target = config_path.resolve()
config_path.unlink()
if target.is_file():
import shutil
shutil.copy2(target, config_path)
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
try:
document = tomlkit.parse(existing) if existing else tomlkit.document()
except Exception: # noqa: BLE001 - title metadata must never block Codex startup.
_logger.warning(
"Could not synchronize native Codex framework instructions: invalid private config",
exc_info=True,
)
return
current = document.get("developer_instructions")
if current is not None and not isinstance(current, str):
_logger.warning(
"Could not synchronize native Codex framework instructions: "
"developer_instructions is not a string"
)
return
if base_path.exists():
base = base_path.read_text(encoding="utf-8")
else:
base = current.strip() if isinstance(current, str) else ""
# A previous Omnigent build may have appended the same framework
# directive without writing the sidecar. Recover the user-authored
# prefix instead of permanently capturing the combined value as base.
if addition and base == addition:
base = ""
elif addition and base.endswith(f"\n\n{addition}"):
base = base[: -len(addition)].rstrip()
base_path.write_text(base, encoding="utf-8")
active = f"{base}\n\n{addition}" if base and addition else base or addition
if active:
document["developer_instructions"] = active
elif "developer_instructions" in document:
del document["developer_instructions"]
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
def _codex_model_upgrade_target(catalog: object, model: str) -> str | None:
"""Return Codex's replacement for *model*, when the catalog declares one."""
if not isinstance(catalog, dict):
return None
models = catalog.get("models")
if not isinstance(models, list):
return None
for entry in models:
if not isinstance(entry, dict) or entry.get("slug") != model:
continue
upgrade = entry.get("upgrade")
if not isinstance(upgrade, dict):
return None
target = upgrade.get("model") or upgrade.get("id")
if isinstance(target, str) and target and target != model:
return target
return None
return None
def _acknowledge_codex_model_migration(codex_home: Path, model: str, target: str) -> None:
"""Suppress one model-migration prompt in a private runner-owned config."""
config_path = codex_home / "config.toml"
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
document = tomlkit.parse(existing) if existing else tomlkit.document()
notice = document.get("notice")
if notice is None:
notice = tomlkit.table()
document["notice"] = notice
migrations = notice.get("model_migrations")
if migrations is None:
migrations = tomlkit.table()
notice["model_migrations"] = migrations
migrations[model] = target
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
def _inject_mcp_server_config(
codex_home: Path,
bridge_dir: Path,
python_executable: str | None = None,
*,
routed_spawns: bool = False,
) -> None:
"""
Upsert Omnigent MCP server config into ``config.toml``.
Writes a ``[mcp_servers.omnigent]`` section that points Codex
at the ``serve-mcp`` subprocess. This supplements the ``-c``
overrides (which ``codex app-server`` may not honor) by writing
directly to the config file. The write is idempotent so terminal
relaunches do not corrupt TOML with duplicate table headers.
:param codex_home: Private per-session CODEX_HOME directory.
:param bridge_dir: Bridge directory containing ``bridge.json``
and ``tool_relay.json``.
:param python_executable: Python executable for serve-mcp.
``None`` uses :data:`sys.executable`.
:param routed_spawns: ``True`` for an auto-harness Smart Routing session,
which pre-approves the cross-harness redirect tools too.
:returns: None.
"""
config_path = codex_home / "config.toml"
# Materialize the symlink from _populate_codex_home_config before
# editing so the user's real config.toml stays untouched.
if config_path.is_symlink():
target = config_path.resolve()
config_path.unlink()
if target.is_file():
import shutil
shutil.copy2(target, config_path)
if config_path.exists():
existing = config_path.read_text(encoding="utf-8")
else:
existing = ""
updated = _remove_toml_table(existing, "mcp_servers.omnigent")
section = _codex_mcp_server_config_section(
bridge_dir, python_executable, routed_spawns=routed_spawns
)
rendered = f"{updated}\n\n{section}" if updated else section
config_path.write_text(rendered, encoding="utf-8")
class CodexAppServerClient:
"""JSON-RPC client for a Codex app-server.
Connects via Unix socket (``socket_path``) or TCP websocket
(``ws_url``). Exactly one must be provided.
:param socket_path: App-server Unix socket path, e.g.
``"/tmp/codex.sock"``.
:param ws_url: App-server WebSocket URL, e.g.
``"ws://127.0.0.1:9876"``.
:param client_name: App-server client name for the initialize
handshake, e.g. ``"omnigent-codex-native"``.
"""
def __init__(
self,
socket_path: Path | None = None,
*,
ws_url: str | None = None,
client_name: str = "omnigent",
) -> None:
if socket_path is None and ws_url is None:
raise ValueError("CodexAppServerClient requires socket_path or ws_url")
self._socket_path = socket_path
self._ws_url = ws_url
self._client_name = client_name
self._ws: ClientConnection | None = None
self._reader_task: asyncio.Task[None] | None = None
self._pending_requests: dict[int, asyncio.Future[CodexMessage]] = {}
self._events: asyncio.Queue[CodexMessage] = asyncio.Queue()
self._next_id = 1
async def connect(self) -> None:
"""
Connect to the app-server and run the initialize handshake.
:returns: None.
"""
if self._ws_url is not None:
self._ws = await websockets.connect(
self._ws_url,
max_size=_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES,
compression=None,
)
else:
self._ws = await websockets.unix_connect(
path=str(self._socket_path),
uri=_UDS_WEBSOCKET_HANDSHAKE_URI,
max_size=_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES,
compression=None,
)
self._reader_task = asyncio.create_task(
self._reader_loop(),
name="codex-native-app-server-reader",
)
await self.request(
"initialize",
{
"clientInfo": {
"name": self._client_name,
"version": "0.1",
},
"capabilities": {
"experimentalApi": True,
},
},
)
await self.notify("initialized")
async def close(self) -> None:
"""
Close the app-server client connection.
:returns: None.
"""
if self._reader_task is not None:
self._reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._reader_task
for future in self._pending_requests.values():
if not future.done():
future.cancel()
self._pending_requests.clear()
if self._ws is not None:
await self._ws.close()
self._ws = None
self._reader_task = None
async def request(self, method: str, params: CodexParams) -> CodexMessage:
"""
Send one JSON-RPC request and wait for its response.
:param method: App-server method, e.g. ``"thread/start"``.
:param params: JSON-serializable method parameters.
:returns: Decoded response envelope.
:raises RuntimeError: If the app-server returns an error.
"""
if self._ws is None:
raise RuntimeError("Codex app-server client is not connected")
request_id = self._next_id
self._next_id += 1
loop = asyncio.get_running_loop()
future: asyncio.Future[CodexMessage] = loop.create_future()
self._pending_requests[request_id] = future
await self._ws.send(
json.dumps(
{
"id": request_id,
"method": method,
"params": params,
}
)
)
response = await future
error = response.get("error")
if error:
raise RuntimeError(str(error))
return response
async def notify(self, method: str, params: CodexParams | None = None) -> None:
"""
Send one JSON-RPC notification.
:param method: App-server notification method, e.g.
``"initialized"``.
:param params: Optional JSON-serializable notification params.
:returns: None.
"""
if self._ws is None:
raise RuntimeError("Codex app-server client is not connected")
message: CodexMessage = {"method": method}
if params is not None:
message["params"] = params
await self._ws.send(json.dumps(message))
async def respond(self, request_id: int | str, result: CodexParams) -> None:
"""
Send one JSON-RPC result for an app-server request.
Codex app-server can send server-to-client requests on the
same websocket, such as ``mcpServer/elicitation/request``.
The forwarder handles those requests through Omnigent and replies
with this method using Codex's original request id.
:param request_id: JSON-RPC id from the Codex request, e.g.
``7`` or ``"7"``.
:param result: JSON-serializable result payload for that
request.
:returns: None.
:raises RuntimeError: If the app-server client is not
connected.
"""
if self._ws is None:
raise RuntimeError("Codex app-server client is not connected")
await self._ws.send(json.dumps({"id": request_id, "result": result}))
async def iter_events(self) -> AsyncIterator[CodexMessage]:
"""
Yield app-server notifications until the connection closes.
:returns: Async iterator of notification envelopes.
"""
while True:
yield await self._events.get()
async def _reader_loop(self) -> None:
"""
Read messages from the websocket and route responses/events.
:returns: None.
"""
assert self._ws is not None
async for raw in self._ws:
if not isinstance(raw, str):
continue
decoded: object = json.loads(raw)
message = _string_object_dict(decoded)
if message is None:
_logger.warning("Ignoring non-object Codex app-server message")
continue
if (
"id" in message
and "method" not in message
and ("result" in message or "error" in message)
):
raw_id = message["id"]
request_id: int | None = None
if isinstance(raw_id, int):
request_id = raw_id
elif isinstance(raw_id, str):
with contextlib.suppress(ValueError):
request_id = int(raw_id)
future = (
self._pending_requests.pop(request_id, None)
if request_id is not None
else None
)
if future is not None and not future.done():
future.set_result(message)
continue
await self._events.put(message)
async def list_codex_model_options(client: CodexAppServerClient) -> list[_JsonObject]:
"""Read every visible model from an initialized Codex app-server client.
:param client: Connected Codex app-server client.
:returns: Raw ``model/list`` rows in Codex preference order.
:raises ValueError: When Codex returns a malformed response.
"""
options: list[_JsonObject] = []
cursor: str | None = None
while True:
params: CodexParams = {"includeHidden": False}
if cursor is not None:
params["cursor"] = cursor
response = await client.request("model/list", params)
result = response.get("result")
if not isinstance(result, dict):
raise ValueError("Codex model/list result must be an object")
data = result.get("data")
if not isinstance(data, list):
raise ValueError("Codex model/list data must be a list")
for raw_model in data:
if not isinstance(raw_model, dict):
raise ValueError("Codex model/list item must be an object")
options.append(raw_model)
next_cursor = result.get("nextCursor")
if next_cursor is None:
return options
if not isinstance(next_cursor, str) or not next_cursor:
raise ValueError("Codex model/list nextCursor must be a string or null")
cursor = next_cursor
_model_discovery_cache: TTLCache[str, tuple[_JsonObject, ...]] = TTLCache(
maxsize=8,
ttl=_MODEL_DISCOVERY_CACHE_SECONDS,
)
async def discover_codex_model_options(*, codex_path: str | None = None) -> list[_JsonObject]:
"""Query the installed Codex CLI's credential-free compatibility catalog.
Starts a short-lived app-server with an empty private ``CODEX_HOME`` and
no provider overrides. The resulting ``model/list`` is Codex's own curated
compatibility set; callers can intersect it with a provider's live
availability without exposing provider credentials to the subprocess.
:param codex_path: Optional Codex executable override.
:returns: Raw visible ``model/list`` rows in Codex preference order.
:raises ImportError: When the Codex CLI is unavailable.
:raises RuntimeError: When the discovery app-server exits before connecting.
:raises TimeoutError: When the discovery app-server does not become ready.
"""
resolved_codex = codex_path or _find_codex_cli()
if not resolved_codex:
raise ImportError("Native Codex model discovery requires the 'codex' CLI on PATH.")
cached = _model_discovery_cache.get(resolved_codex)
if cached is not None:
return [dict(option) for option in cached]
with tempfile.TemporaryDirectory(prefix="omnigent-codex-model-discovery-") as raw_dir:
root = Path(raw_dir)
codex_home = root / "codex-home"
codex_home.mkdir(mode=0o700)
port = _allocate_loopback_port()
listen_url = f"ws://127.0.0.1:{port}"
env = _clean_codex_env()
for name in tuple(env):
if name.startswith("OPENAI_") or name in {
"DATABRICKS_BEARER",
"DATABRICKS_CODEX_TOKEN",
}:
env.pop(name)
env["CODEX_HOME"] = str(codex_home)
process = await _start_codex_model_discovery_process(
codex_path=resolved_codex,
listen_url=listen_url,
env=env,
cwd=root,
)
client: CodexAppServerClient | None = None
try:
await _wait_for_discovery_listener(process, port)
client = CodexAppServerClient(
ws_url=listen_url,
client_name="omnigent-codex-model-discovery",
)
await client.connect()
options = await list_codex_model_options(client)
finally:
if client is not None:
with contextlib.suppress(Exception):
await client.close()
_proc.terminate_tree(process)
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except TimeoutError:
_proc.kill_tree(process)
await process.wait()
_model_discovery_cache[resolved_codex] = tuple(dict(option) for option in options)
return options
async def _start_codex_model_discovery_process(
*,
codex_path: str,
listen_url: str,
env: dict[str, str],
cwd: Path,
config_overrides: Sequence[str] = (),
) -> asyncio.subprocess.Process:
"""Start the isolated Codex process used only for model discovery."""
override_args: list[str] = []
for override in config_overrides:
override_args.extend(("-c", override))
return await asyncio.create_subprocess_exec(
codex_path,
"app-server",
"--listen",
listen_url,
*override_args,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
env=env,
cwd=str(cwd),
**_proc.spawn_kwargs(),
)
def _allocate_loopback_port() -> int:
"""Return an ephemeral TCP port on loopback for model discovery."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind(("127.0.0.1", 0))
return int(listener.getsockname()[1])
async def _wait_for_discovery_listener(
process: asyncio.subprocess.Process,
port: int,
) -> None:
"""Wait until a discovery app-server accepts loopback connections."""
deadline = asyncio.get_running_loop().time() + _CONNECT_TIMEOUT_SECONDS
while asyncio.get_running_loop().time() < deadline:
if process.returncode is not None:
raise RuntimeError(f"Codex model discovery exited early ({process.returncode})")
try:
reader, writer = await asyncio.open_connection("127.0.0.1", port)
except OSError:
await asyncio.sleep(_CONNECT_RETRY_DELAY_SECONDS)
continue
writer.close()
await writer.wait_closed()
del reader
return
raise TimeoutError("Timed out waiting for Codex model discovery app-server")
def _probe_codex_home(config_overrides: Sequence[str]) -> Path:
"""
Persistent probe ``CODEX_HOME`` for one provider configuration.
Persistent (unlike the hermetic discovery's temp dir) so Codex's own
``models_cache.json`` ETag handling makes repeat probes cheap; keyed by
the override set so a provider change never replays another provider's
cache. The account's real ``auth.json`` is symlinked in, the same way
a session launch links it: the credential decides which models the
account's catalog lists (login-gated entries, the account default), so
a credential-less probe answers for a catalog no session will see.
:param config_overrides: The probe's ``-c`` overrides.
:returns: The created ``CODEX_HOME`` directory.
"""
key = hashlib.sha256("\n".join(config_overrides).encode("utf-8")).hexdigest()[:12]
home = Path.home() / ".omnigent" / "cache" / "codex-model-probe" / key
home.mkdir(mode=0o700, parents=True, exist_ok=True)
real_auth = _codex_home_config_source_from_env() / "auth.json"
probe_auth = home / "auth.json"
if real_auth.exists():
with contextlib.suppress(OSError):
if probe_auth.is_symlink() or probe_auth.exists():
probe_auth.unlink()
probe_auth.symlink_to(real_auth)
return home
def mark_launch_default(rows: list[_JsonObject], pinned_model: str | None) -> list[_JsonObject]:
"""
Reduce ``model/list`` rows to exactly one ``isDefault`` marker.
The launch-pinned model wins when a row names it (either spelling);
otherwise Codex's own first default stands. Rows are otherwise verbatim.
Codex's own ``isDefault`` is its built-in preference, which says nothing
about the model this session launched on, so a picker that trusted it
would name a model the pane is not running.
:param rows: Raw ``model/list`` rows.
:param pinned_model: The model the session runs, or ``None``.
:returns: The rows with a single default marked.
"""
from omnigent.codex_model_vocabulary import comparable_model_id
codex_default_index: int | None = None
pinned_index: int | None = None
pinned_key = comparable_model_id(pinned_model) if pinned_model else None
marked: list[_JsonObject] = []
for index, row in enumerate(rows):
cleaned = {key: value for key, value in row.items() if key != "isDefault"}
marked.append(cleaned)
if codex_default_index is None and row.get("isDefault") is True:
codex_default_index = index
if pinned_index is None and pinned_key is not None:
for spelling in (row.get("id"), row.get("model")):
if isinstance(spelling, str) and comparable_model_id(spelling) == pinned_key:
pinned_index = index
break
default_index = pinned_index if pinned_index is not None else codex_default_index
if default_index is not None:
marked[default_index]["isDefault"] = True
return marked
async def probe_codex_model_options(*, codex_path: str | None = None) -> list[_JsonObject]:
"""
Ask a session-configured Codex app-server for its own model list.
The harness is the source of truth for what a session's ``/model``
picker would offer, so the probe boots ``codex app-server`` with the
SAME materialization a session launch gets — for every launch shape.
A Databricks profile contributes its provider overrides (gateway base
URL + minted auth + model pin) and ``DATABRICKS_HOST``; other provider
shapes carry their resolved ``-c`` overrides verbatim; the plain
Codex-login shape probes bare, which yields the ACCOUNT's visible
catalog: the probe home is isolated (never the user's real
``~/.codex``) but links the real ``auth.json`` in the way a session
launch does, so login-gated entries and the account default match what
a live session will offer.
:param codex_path: Optional Codex executable override.
:returns: The probe rows with a single default marked.
:raises ImportError: When the Codex CLI is unavailable.
:raises OSError: When a Databricks profile resolves no workspace host.
:raises RuntimeError: When the probe app-server exits before connecting.
:raises TimeoutError: When the probe app-server does not become ready.
"""
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
resolved_codex = codex_path or _find_codex_cli()
if not resolved_codex:
raise ImportError("Native Codex model probing requires the 'codex' CLI on PATH.")
config_overrides = list(launch.config_overrides)
pinned_model = launch.model
env = _clean_codex_env()
if launch.profile is not None:
databricks = await asyncio.to_thread(
_databricks_launch_materialization, model=launch.model, profile=launch.profile
)
config_overrides.extend(databricks.config_overrides)
env["DATABRICKS_HOST"] = databricks.host
pinned_model = databricks.model
codex_home = await asyncio.to_thread(_probe_codex_home, config_overrides)
env["CODEX_HOME"] = str(codex_home)
port = _allocate_loopback_port()
listen_url = f"ws://127.0.0.1:{port}"
process = await _start_codex_model_discovery_process(
codex_path=resolved_codex,
listen_url=listen_url,
env=env,
cwd=codex_home,
config_overrides=config_overrides,
)
client: CodexAppServerClient | None = None
try:
await _wait_for_discovery_listener(process, port)
client = CodexAppServerClient(
ws_url=listen_url,
client_name="omnigent-codex-model-probe",
)
await client.connect()
rows = await list_codex_model_options(client)
finally:
if client is not None:
with contextlib.suppress(Exception):
await client.close()
_proc.terminate_tree(process)
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except TimeoutError:
_proc.kill_tree(process)
await process.wait()
return mark_launch_default(rows, pinned_model)
def codex_catalog_fingerprint(launch: NativeCodexLaunch) -> str:
"""The launch-config fingerprint keying codex's shared model catalog.
One formula for every consumer (host boot probe, runner launch, live
write-back), so they read and write the same catalog file. Callers
fingerprint the SHAPE — a ``model=None`` resolution — so per-session
picks never fragment the catalog.
:param launch: The resolved launch (``resolve_native_codex_launch``).
:returns: A stable fingerprint string.
"""
from omnigent.model_catalog_store import fingerprint_of
return fingerprint_of(
"codex-native", launch.profile, launch.model, tuple(launch.config_overrides)
)
async def codex_launch_catalog(*, codex_path: str | None = None) -> list[_JsonObject] | None:
"""
The shared codex catalog for this host's default shape: store, then probe.
Reads the on-disk catalog for the ``model=None`` launch shape; a miss
pays one session-shaped probe (real auth linked in) and persists the
answer for every later consumer.
:param codex_path: Optional Codex executable override.
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
"""
from omnigent import model_catalog_store
try:
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
except Exception: # noqa: BLE001 — a broken provider config means no catalog
_logger.warning("codex catalog: launch shape resolution failed", exc_info=True)
return None
fingerprint = codex_catalog_fingerprint(launch)
async def _probe() -> list[_JsonObject] | None:
try:
return await probe_codex_model_options(codex_path=codex_path)
except Exception: # noqa: BLE001 — probe failure means "no catalog", never a crash
_logger.warning("codex catalog probe failed", exc_info=True)
return None
return await model_catalog_store.ensure_catalog("codex-native", fingerprint, _probe)
def _build_native_codex_app_server_argv(
*,
tagged_argv0: str,
listen_url: str,
config_overrides: Sequence[str],
) -> list[str]:
"""Build argv for the native Codex app-server subprocess."""
argv = [tagged_argv0, "app-server", "--listen", listen_url]
for override in config_overrides:
argv.extend(["-c", override])
return argv
@dataclass
class CodexNativeAppServer:
"""
Running native Codex app-server subprocess.
:param codex_path: Executable path for the Codex CLI, e.g.
``"/usr/local/bin/codex"``.
:param socket_path: Codex app-server Unix socket path.
:param codex_home: Private per-session ``CODEX_HOME`` path.
:param env: Environment for the app-server subprocess.
:param config_overrides: Codex ``-c`` config override values.
:param developer_instructions: Optional framework-owned instructions
appended to the private session config before app-server startup.
:param cwd: Working directory for the app-server process.
:param bridge_dir: Native Codex bridge directory, e.g.
``Path("~/.omnigent/codex-native/<hash>")``. The policy hook
subprocess is pointed at it via ``--bridge-dir`` and reads the
session id + Omnigent coordinates from it.
:param ap_server_url: Omnigent server base URL the policy hook POSTs tool
calls to, e.g. ``"http://127.0.0.1:8787"``. ``None`` registers
and trusts the hook but writes no Omnigent coordinates, so the hook
no-ops (no enforcement) until coordinates exist.
:param ap_auth_headers: Outbound auth headers for the policy hook's
Omnigent requests, e.g. ``{"Authorization": "Bearer <token>"}``.
:param python_executable: Python executable the policy hook command
runs, e.g. ``"/path/to/.venv/bin/python"``. ``None`` uses
:data:`sys.executable`.
:param policy_hook_disabled_reason: Runtime field set by
:meth:`start`: ``None`` when the tool-call policy hook is active
(registered + trusted), or a human-readable reason string when it
is NOT enforced for this session — codex too old, or the trust
handshake failed. Hook problems are non-fatal (fail-open): the
session still starts and this reason is surfaced as a web-UI
notice rather than blocking session creation. Not a constructor
input — defaults ``None`` until ``start``.
:param pinned_model: Session-pinned model id written into the
per-session ``config.toml`` at start, or ``None``. Keeps the
forwarder's config.toml model mirror (and the cost gate's hook
read) consistent with what the session was launched to run.
:param trust_project: Whether to trust :attr:`cwd` in the private
session config before startup. Runner-owned headless sessions set
this because nobody can answer Codex's project-trust TUI prompt.
Interactive CLI sessions leave it disabled.
:param policy_notice_pending: One-shot flag: ``True`` once a degrade
reason is recorded, until the runner's terminal-ensure handler
surfaces it to Omnigent (which posts a single durable banner). Prevents
re-posting the same notice on every subsequent ensure. Not a
constructor input.
"""
codex_path: str
socket_path: Path
codex_home: Path
env: dict[str, str]
config_overrides: list[str]
cwd: Path
bridge_dir: Path
developer_instructions: str | None = None
ap_server_url: str | None = None
ap_auth_headers: dict[str, str] | None = None
python_executable: str | None = None
listen_url: str | None = None
proc: asyncio.subprocess.Process | None = None
stderr_task: asyncio.Task[None] | None = None
recent_stderr: list[str] | None = None
policy_hook_disabled_reason: str | None = None
policy_notice_pending: bool = False
pinned_model: str | None = None
process_registry_tag: str | None = None
process_owner_lock: CodexNativeProcessOwnerLock | None = None
codex_cli_version: tuple[int, int, int] | None = None
trust_project: bool = False
router_hooks_registered: bool = False
async def start(self) -> None:
"""
Start the Codex app-server and wait for the socket.
:returns: None.
"""
self.codex_home.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.codex_home, 0o700)
if self.listen_url is None or self.listen_url.startswith("unix://"):
with contextlib.suppress(FileNotFoundError):
self.socket_path.unlink()
# Native policy enforcement needs codex's hook-trust protocol
# (``currentHash`` / ``trustStatus`` in ``hooks/list``), added in
# codex 0.129. Below that the hook can never be trusted, so
# registering it would only fail at the trust gate. Probed before
# the home is populated: on an unsupported codex no hooks file is
# generated at all, so the user's hooks.json must still be
# symlinked in rather than left missing. A version we cannot parse
# (``None``) is treated as supported so a flaky probe never
# silently disables enforcement — a genuine trust failure is then
# caught below.
codex_version = await _codex_cli_version(self.codex_path)
self.codex_cli_version = codex_version
policy_hooks_supported = (
codex_version is None or codex_version >= _MIN_POLICY_HOOK_CODEX_VERSION
)
# When the runner advertises a route-subagent endpoint, the generated
# hooks file owns hooks.json, so the user's copy is merged in rather
# than symlinked over. The runner advertises it for auto-harness Smart
# Routing sessions only, so its presence is also this session class's
# signature — see ``ensure_session_router_quietly``.
router_bridge_dir = codex_router_bridge_dir(self.env)
if router_bridge_dir is not None:
# A CLI too old for the spawn gate gets no routing hooks at all, so
# routing no-ops instead of blocking the launch. Everything keyed
# off the advertisement below (generated hooks.json, the routed-spawn
# tool pre-approvals) then falls back to the plain shape.
skip_reason = codex_routing_hook_skip_reason(codex_version)
if skip_reason is not None:
_logger.warning("%s", skip_reason)
router_bridge_dir = None
self.router_hooks_registered = router_bridge_dir is not None and policy_hooks_supported
routed_spawns = router_bridge_dir is not None
config_source = _codex_home_config_source_from_env()
model_migration_target: str | None = None
if self.trust_project and self.pinned_model:
catalog = await asyncio.to_thread(
read_codex_model_catalog,
self.codex_path,
config_source,
timeout=_MODEL_MIGRATION_CATALOG_TIMEOUT_SECONDS,
)
model_migration_target = _codex_model_upgrade_target(catalog, self.pinned_model)
# Off the loop: this copies/symlinks a home AND (on a Smart Routing
# session) shells out to ``codex debug models`` with a 10s timeout. Run
# inline it stalled every other session sharing this event loop for that
# long — which is also why a plain session must never reach the probe.
await asyncio.to_thread(
_populate_codex_home_config,
self.codex_home,
config_source,
inject_hooks=self.router_hooks_registered,
extend_model_catalog=codex_extended_catalog_requested(self.env),
)
if self.trust_project:
_trust_codex_project(self.codex_home, self.cwd)
# Write the MCP server config into config.toml so the app-server
# discovers it at config load. The -c overrides may not be honored
# by `codex app-server`, so we write directly to the file.
_inject_mcp_server_config(
self.codex_home,
self.bridge_dir,
self.python_executable,
routed_spawns=routed_spawns,
)
if self.pinned_model:
_pin_codex_config_model(self.codex_home, self.pinned_model)
if model_migration_target is not None:
_acknowledge_codex_model_migration(
self.codex_home,
self.pinned_model,
model_migration_target,
)
_sync_codex_developer_instructions(
self.codex_home,
self.developer_instructions,
)
self.config_overrides = materialize_codex_provider_config(
self.codex_home,
self.config_overrides,
)
if codex_version is not None and not policy_hooks_supported:
self._disable_policy_hook(
f"Codex CLI {_format_codex_version(codex_version)} is older than "
f"{_format_codex_version(_MIN_POLICY_HOOK_CODEX_VERSION)}; upgrade "
"codex to enforce tool-call policies."
)
else:
# Register the Omnigent policy hook in this private CODEX_HOME
# *before* launching the app-server so codex discovers it at
# config load. The Omnigent coordinates the hook subprocess
# needs go in the bridge dir's policy_hook.json; without
# ap_server_url the hook is still registered + trusted but
# no-ops.
_write_codex_policy_hooks_file(
self.codex_home,
self.bridge_dir,
self.python_executable,
router_bridge_dir=router_bridge_dir,
router_session_id=codex_router_session_id(self.env),
user_hooks_source=config_source / _CODEX_HOOKS_FILE,
# The runner only advertises a route-turn endpoint for a
# session that launched with Smart Routing on, so its presence
# is the switch for the first-message routing hook. Same
# rendezvous-as-switch shape as the subagent router above.
turn_routing=_turn_router_advertised(self.bridge_dir),
)
if self.ap_server_url:
write_policy_hook_config(
self.bridge_dir,
ap_server_url=self.ap_server_url,
ap_auth_headers=self.ap_auth_headers or {},
)
reconcile_codex_native_process_registry()
resolved_listen = self.listen_url or f"unix://{self.socket_path}"
self.process_registry_tag = f"codex-native-{uuid.uuid4().hex}"
tagged_argv0 = (
f"{Path(self.codex_path).name} "
f"{codex_native_session_tag_cmdline_arg(self.process_registry_tag)}"
)
argv = _build_native_codex_app_server_argv(
tagged_argv0=tagged_argv0,
listen_url=resolved_listen,
config_overrides=self.config_overrides,
)
proc_env = {**self.env, "CODEX_HOME": str(self.codex_home)}
self.process_owner_lock = acquire_codex_native_process_owner_lock()
try:
self.proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
env=proc_env,
cwd=str(self.cwd),
executable=self.codex_path,
**_proc.spawn_kwargs(),
)
except BaseException:
if self.process_owner_lock is not None:
self.process_owner_lock.close()
self.process_owner_lock = None
raise
if self.process_owner_lock is not None:
register_codex_native_process(
pid=self.proc.pid,
pgid=_process_group_id(self.proc),
session_tag=self.process_registry_tag,
owner_lock_path=self.process_owner_lock.path,
)
self.recent_stderr = []
self.stderr_task = asyncio.create_task(
self._stderr_loop(),
name="codex-native-app-server-stderr",
)
# Ordering invariant: hooks.json is written before the spawn above,
# and the trust handshake must complete before the first turn — codex
# resolves trust when it dispatches a hook, so trust landing after the
# spawn is fine, but a turn started before it runs unhooked. The
# handshake cannot precede the spawn (``hooks/list`` is an app-server
# RPC), so callers must not launch the TUI or dispatch a turn until
# ``start()`` returns.
# Readiness failure (the app-server never came up) is fatal and
# tears down the subprocess so it is not orphaned. Policy-hook
# trust, by contrast, is best-effort: a trust failure degrades the
# session to "no enforcement" with a surfaced reason rather than
# blocking session creation (fail-open). ``BaseException`` on the
# outer guard so a cancellation mid-trust still tears down.
try:
await self._wait_until_ready()
if self.policy_hook_disabled_reason is None:
try:
await self._trust_policy_hooks()
except Exception as exc: # noqa: BLE001 - degrade, never block startup
self._disable_policy_hook(f"Codex policy hook could not be trusted: {exc}")
except BaseException:
await self.close()
raise
async def _trust_policy_hooks(self) -> None:
"""
Mark the registered Omnigent policy hook as trusted.
A freshly-written non-managed hook is ``untrusted`` and codex
silently skips untrusted hooks — for a policy gate that is a
fail-open. This connects a transient app-server client and runs
the same ``hooks/list`` → ``config/batchWrite`` trust flow codex's
own TUI uses, then verifies the hook is trusted. ``--listen`` may
be a unix socket (local CLI) or a loopback websocket (host
runner); both transports are handled.
:returns: None.
:raises RuntimeError: If the policy hook is missing from the
discovered set or remains untrusted after the trust write —
either condition means enforcement would silently not run.
The message is augmented with codex's captured configuration
error (see :meth:`_codex_config_error_hint`) when present.
"""
if self.listen_url and self.listen_url.startswith("ws://"):
client = CodexAppServerClient(
ws_url=self.listen_url,
client_name="omnigent-policy-trust",
)
else:
client = CodexAppServerClient(
self.socket_path,
client_name="omnigent-policy-trust",
)
await client.connect()
try:
await trust_native_policy_hooks(client, cwd=str(self.cwd))
# Routing hooks live in the same generated file but under a
# different module, so they need their own trust pass. Best
# effort: a routing-trust failure must not disable the policy
# gate, so it is logged instead of raised.
if self.router_hooks_registered:
try:
await trust_codex_router_hooks(client.request, cwd=str(self.cwd))
except Exception: # noqa: BLE001 - routing trust never blocks startup
_logger.warning(
"codex subagent-routing hook trust failed; routing will not be enforced",
exc_info=True,
)
except RuntimeError as exc:
raise RuntimeError(f"{exc}{self._codex_config_error_hint()}") from exc
finally:
await client.close()
def _codex_config_error_hint(self) -> str:
"""
Return a hint drawn from codex stderr when policy-hook trust fails.
Codex logs ``Invalid configuration; using defaults`` when the
per-session ``config.toml`` is not valid TOML, then loads zero
hooks — the most common cause of a "not discovered" failure.
Surfacing the captured stderr line (which the trust handshake
otherwise never sees) turns an opaque failure into a
self-diagnosing error pointing at the offending config file.
:returns: A space-prefixed hint string, or ``""`` when codex
reported no configuration problem.
"""
config_errors = [
line for line in (self.recent_stderr or []) if "Invalid configuration" in line
]
if not config_errors:
return ""
joined = " | ".join(config_errors[-3:])
return (
f" Codex reported a configuration problem (the per-session config "
f"{self.codex_home / 'config.toml'} is likely invalid TOML, so codex "
f"loaded no hooks): {joined}"
)
def _disable_policy_hook(self, reason: str) -> None:
"""
Record that tool-call policy enforcement is inactive (fail-open).
Single entry point used by :meth:`start` for both degrade paths
(codex too old, trust handshake failed). Stores the reason, flags
a one-shot web-UI notice for the runner's ensure handler to
surface, and logs (see :meth:`_warn_policy_hook_disabled`).
:param reason: Human-readable cause, e.g. ``"Codex CLI 0.128.0 is
older than 0.129.0; upgrade codex to enforce tool-call
policies."``.
:returns: None.
"""
self.policy_hook_disabled_reason = reason
self.policy_notice_pending = True
self._warn_policy_hook_disabled()
def _warn_policy_hook_disabled(self) -> None:
"""
Log that tool-call policy enforcement is inactive for this session.
Called by :meth:`start` when it degrades the session to "no
enforcement" — either codex is too old to trust the hook, or the
trust handshake failed. The reason is in
:attr:`policy_hook_disabled_reason`. When Omnigent coordinates are
present (``ap_server_url`` set) enforcement was intended, so this
is a loud ``warning``; otherwise nothing would have been enforced
anyway and it is an ``info``.
:returns: None.
"""
message = "Native tool-call policy enforcement is NOT active for this session: %s"
if self.ap_server_url:
_logger.warning(message, self.policy_hook_disabled_reason)
else:
_logger.info(message, self.policy_hook_disabled_reason)
async def close(self) -> None:
"""
Stop the app-server subprocess.
:returns: None.
"""
if self.proc is not None and self.proc.returncode is None:
_terminate_process_tree(self.proc)
try:
await asyncio.wait_for(self.proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
_kill_process_tree(self.proc)
await self.proc.wait()
if self.process_registry_tag is not None:
unregister_codex_native_process(self.process_registry_tag)
if self.process_owner_lock is not None:
self.process_owner_lock.close()
if self.stderr_task is not None:
self.stderr_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self.stderr_task
self.proc = None
self.stderr_task = None
self.process_registry_tag = None
self.process_owner_lock = None
async def _wait_until_ready(self) -> None:
"""
Wait until the app-server socket accepts an initialized
client.
:returns: None.
:raises RuntimeError: If the app-server exits or never
becomes ready before the timeout.
"""
deadline = asyncio.get_running_loop().time() + _CONNECT_TIMEOUT_SECONDS
last_error: Exception | None = None
while asyncio.get_running_loop().time() < deadline:
if self.proc is not None and self.proc.returncode is not None:
detail = " | ".join((self.recent_stderr or [])[-5:])
raise RuntimeError(f"Codex app-server exited early: {detail}")
try:
if self.listen_url and self.listen_url.startswith("ws://"):
client = CodexAppServerClient(
ws_url=self.listen_url,
client_name="omnigent-probe",
)
else:
client = CodexAppServerClient(
self.socket_path,
client_name="omnigent-probe",
)
await client.connect()
await client.close()
return
except Exception as exc: # noqa: BLE001 - readiness retry boundary
last_error = exc
await asyncio.sleep(_CONNECT_RETRY_DELAY_SECONDS)
detail = " | ".join((self.recent_stderr or [])[-5:])
raise RuntimeError(
f"Timed out waiting for Codex app-server socket {self.socket_path}: "
f"{last_error}; stderr={detail}"
)
async def _stderr_loop(self) -> None:
"""
Capture recent app-server stderr for diagnostics.
:returns: None.
"""
assert self.proc is not None and self.proc.stderr is not None
while True:
line = await self.proc.stderr.readline()
if not line:
return
text = line.decode("utf-8", errors="replace").rstrip()
if len(text) >= _STDERR_CHUNK_LIMIT:
text = f"{text[:_STDERR_CHUNK_LIMIT]}...[truncated]"
if self.recent_stderr is not None:
self.recent_stderr.append(text)
if len(self.recent_stderr) > 20:
self.recent_stderr.pop(0)
_logger.debug("codex-native app-server stderr: %s", text)
def _codex_policy_hook_command(bridge_dir: Path, python_executable: str | None) -> str:
"""
Build the shell command codex runs for the policy hook.
Runs python in isolated mode (``-I``): codex executes hooks with the
session's workspace as cwd, and ``-m`` would otherwise put that
workspace first on ``sys.path``. A workspace holding a directory named
like one of our packages (the omnigent checkout itself, most obviously)
then shadows the installed one and the hook dies on an import error
that codex discards — a silent fail-open. Mirrors the ``-I`` the
bridge's MCP server command already uses.
:param bridge_dir: Native Codex bridge directory passed to the hook
via ``--bridge-dir``.
:param python_executable: Python executable to run, e.g.
``"/path/to/python"``. ``None`` uses :data:`sys.executable`.
:returns: A shell-escaped command string, e.g.
``"/path/python -I -m omnigent.codex_native_hook evaluate-policy
--bridge-dir /home/u/.omnigent/codex-native/abc"``.
"""
python = python_executable or sys.executable
return shlex.join(
[
python,
"-I",
"-m",
_POLICY_HOOK_MODULE,
"evaluate-policy",
"--bridge-dir",
str(bridge_dir),
]
)
def _turn_router_advertised(bridge_dir: Path) -> bool:
"""
Report whether the runner advertised a ``route-turn`` endpoint here.
:param bridge_dir: Native Codex bridge directory.
:returns: ``True`` when a usable ``turn_router.json`` is present, i.e. the
session launched with Smart Routing on.
"""
from omnigent.inner.hook_scripts.subagent_router import read_router_endpoint
from omnigent.runner.turn_routing import ADVERTISEMENT_FILE
return read_router_endpoint(bridge_dir, filename=ADVERTISEMENT_FILE) is not None
def _codex_policy_hooks_settings(
bridge_dir: Path,
python_executable: str | None,
*,
turn_routing: bool = False,
) -> _JsonObject:
"""
Build the ``hooks.json`` payload registering the policy hook.
Registers one catch-all (no ``matcher``) command hook on
``PreToolUse`` (blocks before execution), ``PostToolUse`` (warns
after), and ``UserPromptSubmit`` (blocks a user prompt before the
model sees it — the request-phase gate for native sessions, since
the server-level ``_evaluate_input_policy`` skips native message
events). ``mcp__*`` tools are filtered out inside the hook itself,
not by a matcher, so the relay path remains the single MCP
enforcement point.
:param bridge_dir: Native Codex bridge directory.
:param python_executable: Python executable for the hook command.
:param turn_routing: ``True`` when the runner advertised a ``route-turn``
endpoint for this session, i.e. it launched with Smart Routing on.
``False`` leaves the first-message routing hook unregistered, so a
session that will never route pays no per-prompt round trip.
:returns: A ``hooks.json``-shaped dict.
"""
hook: _JsonObject = {
"type": "command",
"command": _codex_policy_hook_command(bridge_dir, python_executable),
"timeout": _POLICY_HOOK_TIMEOUT_SECONDS,
}
prompt_submit: list[_JsonObject] = [hook]
if turn_routing:
prompt_submit.append(_codex_route_turn_hook(bridge_dir, python_executable))
return {
"hooks": {
"PreToolUse": [{"hooks": [hook]}],
"PostToolUse": [{"hooks": [hook]}],
"UserPromptSubmit": [{"hooks": prompt_submit}],
}
}
def _codex_route_turn_hook(bridge_dir: Path, python_executable: str | None) -> _JsonObject:
"""
Build the ``UserPromptSubmit`` entry for first-message model routing.
A second command alongside the policy gate rather than a module of its
own: codex trusts hooks by command, and the trust pass filters on
:data:`_POLICY_HOOK_MODULE`, so keeping the subcommand there rides the
existing handshake. It no-ops (exit 0, no output) unless the runner has
advertised a ``route-turn`` endpoint and nothing has pinned the
session's model yet; when it does route, it blocks the prompt and the
runner replays it on the routed model. See
:mod:`omnigent.runner.turn_routing`.
:param bridge_dir: Native Codex bridge directory, holding both the
endpoint advertisement and the marker file.
:param python_executable: Python executable for the hook command.
:returns: One ``hooks.json`` command-hook entry.
"""
from omnigent.runner.turn_routing import HARNESS_HOOK_TIMEOUT_S
return {
"type": "command",
"command": shlex.join(
[
python_executable or sys.executable,
"-I",
"-m",
_POLICY_HOOK_MODULE,
"route-turn",
"--bridge-dir",
str(bridge_dir),
"--harness",
"codex-native",
]
),
"timeout": HARNESS_HOOK_TIMEOUT_S,
}
def _write_codex_policy_hooks_file(
codex_home: Path,
bridge_dir: Path,
python_executable: str | None,
*,
router_bridge_dir: Path | None = None,
router_session_id: str | None = None,
user_hooks_source: Path | None = None,
turn_routing: bool = False,
) -> None:
"""
Write ``hooks.json`` into the private CODEX_HOME (atomically).
This file is the only ``hooks.json`` codex loads, so the policy hooks,
the subagent-routing hooks and the user's own hooks all go through the
shared :func:`write_codex_hooks_file` into one payload — written
separately, whichever ran last would erase the other.
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param bridge_dir: Native Codex bridge directory for the hook command.
:param python_executable: Python executable for the hook command.
:param router_bridge_dir: Directory advertising the route-subagent
endpoint. ``None`` leaves native subagent spawns unrouted.
:param router_session_id: Session id baked into the routing hook
commands.
:param user_hooks_source: The user's real ``hooks.json`` to merge when
the private home holds no symlink to it (the routing path unlinks
it before this runs).
:param turn_routing: ``True`` when the session launched with Smart Routing
on, which registers the ``UserPromptSubmit`` first-message routing
hook.
:returns: None.
"""
payloads: list[Mapping[str, object]] = [
_codex_policy_hooks_settings(bridge_dir, python_executable, turn_routing=turn_routing)
]
if router_bridge_dir is not None:
payloads.append(
codex_router_hooks_settings(
router_bridge_dir,
session_id=router_session_id,
harness="codex-native",
python_executable=python_executable,
)
)
_ = write_codex_hooks_file(codex_home, payloads, user_hooks_source=user_hooks_source)
def _our_hooks_from_list(listed: _JsonObject, cwd: str, module: str) -> list[_JsonObject]:
"""
Extract the hooks for *cwd* whose command runs *module*.
Filtering by module keeps the trust step from ever touching hooks the
user's own ``hooks.json`` contributed to the merged file.
:param listed: Parsed ``hooks/list`` response envelope, with
``result.data`` a list of ``{cwd, hooks: [...]}`` entries.
:param cwd: The cwd whose hook set to read, e.g.
``"/home/user/repo"``.
:param module: Hook-script module marker, e.g.
``"omnigent.codex_native_hook"``.
:returns: The matching hook metadata dicts (possibly empty), each
with ``key``, ``currentHash``, ``trustStatus``.
"""
result = _string_object_dict(listed.get("result"))
if result is None:
result = listed
data = _object_list(result.get("data")) or []
for raw_entry in data:
entry = _string_object_dict(raw_entry)
if entry is not None and entry.get("cwd") == cwd:
hooks = _object_list(entry.get("hooks")) or []
return [
hook
for raw_hook in hooks
if (hook := _string_object_dict(raw_hook)) is not None
and module in str(hook.get("command", ""))
]
return []
def _our_policy_hooks_from_list(listed: _JsonObject, cwd: str) -> list[_JsonObject]:
"""
Extract *our* policy hooks for *cwd* from a ``hooks/list`` response.
:param listed: Parsed ``hooks/list`` response envelope.
:param cwd: The cwd whose hook set to read, e.g.
``"/home/user/repo"``.
:returns: The matching Omnigent policy-hook metadata dicts.
"""
return _our_hooks_from_list(listed, cwd, _POLICY_HOOK_MODULE)
def _hooks_list_diagnostics(listed: _JsonObject, cwd: str) -> str:
"""
Summarize a ``hooks/list`` response for a discovery-failure error.
Turns an opaque "hook not discovered" into a self-diagnosing message
by reporting what codex actually returned. Distinguishes the common
causes:
- **zero entries / zero hooks** — codex loaded no hooks at all,
typically because the per-session ``config.toml`` is invalid TOML
and codex fell back to defaults;
- **cwd mismatch** — entries came back but none for the queried
*cwd*;
- **module mismatch** — an entry matched but no hook command
references :data:`_POLICY_HOOK_MODULE` (e.g. a stale / renamed
module from an out-of-date install).
:param listed: Parsed ``hooks/list`` response envelope.
:param cwd: The cwd that was queried, e.g. ``"/home/user/repo"``.
:returns: A one-line diagnostic, e.g.
``"hooks/list returned no hooks (codex loaded none — likely an "
"invalid per-session config.toml)"``.
"""
result = _string_object_dict(listed.get("result"))
if result is None:
result = listed
data = _object_list(result.get("data")) or []
entries = [entry for raw in data if (entry := _string_object_dict(raw)) is not None]
if not entries or all(not (_object_list(entry.get("hooks")) or []) for entry in entries):
return (
"hooks/list returned no hooks (codex loaded none — likely an "
"invalid per-session config.toml, so codex fell back to defaults)"
)
matched_cwd = any(e.get("cwd") == cwd for e in entries)
parts: list[str] = []
for entry in entries:
hooks = _object_list(entry.get("hooks")) or []
ours = sum(
1
for raw_hook in hooks
if (hook := _string_object_dict(raw_hook)) is not None
and _POLICY_HOOK_MODULE in str(hook.get("command", ""))
)
parts.append(f"cwd={entry.get('cwd')!r}: {len(hooks)} hook(s), {ours} ours")
prefix = "" if matched_cwd else f"no entry matched queried cwd {cwd!r}; "
return f"hooks/list returned [{prefix}{'; '.join(parts)}]"
def _untrusted_hook_detail(hooks: Sequence[_JsonObject]) -> str:
"""
Render untrusted hook metadata for a trust-failure error.
Surfaces codex's own per-hook ``trustStatus`` / ``statusMessage`` /
``isManaged`` (which the trust handshake otherwise discards) so the
error explains *why* a hook could not be trusted — e.g. a managed
requirement rejecting a user hook, or an old codex that omits
``trustStatus`` entirely.
:param hooks: Untrusted Omnigent hook metadata dicts from
``hooks/list``.
:returns: A semicolon-joined per-hook detail string.
"""
return "; ".join(
f"{h.get('key')!r} trustStatus={h.get('trustStatus')!r} "
f"isManaged={h.get('isManaged')!r} statusMessage={h.get('statusMessage')!r}"
for h in hooks
)
async def _persist_hook_trust(request: CodexRequestFn, untrusted: Sequence[_JsonObject]) -> None:
"""
Write ``hooks.state.<key>.trusted_hash`` for each untrusted hook.
Persisted trust is the *only* mechanism that makes a hook run under
``codex app-server``: the ``--dangerously-bypass-hook-trust`` CLI flag
is honored by the interactive/exec paths only, so app-server threads
silently skip anything left ``untrusted``.
:param request: Bound app-server JSON-RPC request coroutine, e.g.
``client.request``.
:param untrusted: Hook metadata dicts from ``hooks/list`` carrying
``key`` and ``currentHash``.
:returns: None.
"""
trust_value = {
str(h["key"]): {"trusted_hash": h["currentHash"]}
for h in untrusted
if h.get("key") and h.get("currentHash")
}
if not trust_value:
return
await request(
"config/batchWrite",
{
"edits": [
{
"keyPath": "hooks.state",
"mergeStrategy": "upsert",
"value": trust_value,
}
],
"reloadUserConfig": True,
},
)
async def trust_codex_router_hooks(request: CodexRequestFn, *, cwd: str) -> list[str]:
"""
Trust the generated subagent-routing hooks so codex runs them.
Codex skips untrusted hooks without a word, which for the routing gate
is a fail-open, and app-server threads honor persisted trust only (the
``--dangerously-bypass-hook-trust`` flag covers the interactive /
``exec`` paths, not this one), so the handshake is the only way in.
The routing gate (``PreToolUse`` on the spawn tool) lives in the same
generated ``hooks.json`` as the policy hook but under a different
module, so the policy trust pass leaves it ``untrusted``. Same
``hooks/list`` → ``config/batchWrite`` flow, but best-effort: a
routing-trust failure must not disable policy enforcement, so it is
reported instead of raised.
:param request: Bound app-server JSON-RPC request coroutine, e.g.
``client.request`` (or the SDK executor's ``_request``).
:param cwd: The session cwd the hooks are scoped to, e.g.
``"/home/user/repo"``.
:returns: Keys of routing hooks still untrusted afterwards; empty when
every routing hook is trusted (or none are registered).
"""
listed = await request("hooks/list", {"cwds": [cwd]})
ours = _our_hooks_from_list(listed, cwd, _CODEX_ROUTER_HOOK_MODULE)
if not ours:
_logger.info(
"codex subagent-routing hooks: none discovered for cwd %s (%s)",
cwd,
_hooks_list_diagnostics(listed, cwd),
)
return []
untrusted = [h for h in ours if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES]
if not untrusted:
_logger.info(
"codex subagent-routing hooks: all %d already trusted for cwd %s", len(ours), cwd
)
return []
await _persist_hook_trust(request, untrusted)
relisted = await request("hooks/list", {"cwds": [cwd]})
still_untrusted = [
h
for h in _our_hooks_from_list(relisted, cwd, _CODEX_ROUTER_HOOK_MODULE)
if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES
]
if still_untrusted:
_logger.warning(
"codex subagent-routing hooks still untrusted after config/batchWrite; "
"native subagent routing will NOT be enforced: %s",
_untrusted_hook_detail(still_untrusted),
)
return [str(h.get("key")) for h in still_untrusted]
_logger.info(
"codex subagent-routing hooks trusted (%d of %d newly): %s",
len(untrusted),
len(ours),
", ".join(sorted(str(h.get("eventName")) for h in ours)),
)
return []
async def trust_native_policy_hooks(client: CodexAppServerClient, *, cwd: str) -> None:
"""
Trust the Omnigent policy hook so codex actually runs it.
Runs the same flow codex's TUI uses for hook trust: ``hooks/list``
to read each hook's content hash, then ``config/batchWrite`` (with
``reloadUserConfig`` so loaded threads hot-reload) writing
``hooks.state.<key>.trusted_hash = currentHash``. Re-lists and
verifies every Omnigent hook ended ``trusted``/``managed``.
:param client: A connected Codex app-server client.
:param cwd: The session cwd the hooks are scoped to, e.g.
``"/home/user/repo"``.
:returns: None.
:raises RuntimeError: If no Omnigent hook is discovered (it was
not registered or not loaded) or if a hook remains untrusted
after the trust write. Either is a silent fail-open for a policy
gate, so it must fail loud.
"""
listed = await client.request("hooks/list", {"cwds": [cwd]})
ours = _our_policy_hooks_from_list(listed, cwd)
if not ours:
raise RuntimeError(
f"Omnigent policy hook was not discovered for cwd {cwd!r}; "
"tool-call policy enforcement would silently not run. "
f"{_hooks_list_diagnostics(listed, cwd)}."
)
untrusted = [h for h in ours if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES]
if not untrusted:
return
await _persist_hook_trust(client.request, untrusted)
relisted = await client.request("hooks/list", {"cwds": [cwd]})
still_untrusted = [
h
for h in _our_policy_hooks_from_list(relisted, cwd)
if h.get("trustStatus") not in _TRUSTED_HOOK_STATUSES
]
if still_untrusted:
missing_protocol = any(
h.get("currentHash") is None or h.get("trustStatus") is None for h in still_untrusted
)
minimum = _format_codex_version(_MIN_POLICY_HOOK_CODEX_VERSION)
hint = (
" The hooks/list metadata is missing currentHash/trustStatus, so the "
f"codex CLI is older than {minimum} (upgrade codex)."
if missing_protocol
else ""
)
raise RuntimeError(
"Omnigent policy hook still untrusted after config/batchWrite; "
"tool-call policy enforcement would not run. Untrusted hooks: "
f"{_untrusted_hook_detail(still_untrusted)}.{hint}"
)
def _trust_codex_project(codex_home: Path, cwd: Path) -> None:
"""
Trust a runner-selected workspace in the private Codex config.
Codex 0.146 introduced a project-trust screen before the remote TUI
creates its thread. Headless sessions cannot answer it, so startup waits
until Omnigent reports a timeout. The config is already a private copy;
this never modifies the user's shared ``~/.codex/config.toml``.
:param codex_home: Private per-session ``CODEX_HOME`` directory.
:param cwd: Workspace selected for this runner-owned session.
:returns: None.
"""
config_path = codex_home / "config.toml"
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
document = tomlkit.parse(existing) if existing else tomlkit.document()
projects = document.get("projects")
if projects is None:
projects = tomlkit.table()
document["projects"] = projects
project_key = str(cwd.resolve())
project = projects.get(project_key)
if project is None:
project = tomlkit.table()
projects[project_key] = project
project["trust_level"] = "trusted"
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
@dataclass(frozen=True)
class _DatabricksLaunchMaterialization:
"""
The Databricks-profile pieces of a Codex launch, shared by the real
app-server build and the model-options probe so the two cannot drift.
:param config_overrides: ``-c`` overrides routing Codex through the
profile's AI Gateway (provider block + auth command + model pin).
:param model: The model the overrides pin, e.g. ``"databricks-gpt-5-4"``
— the explicit *model* when given, else the catalog default.
:param host: The profile's workspace origin for ``DATABRICKS_HOST``.
"""
config_overrides: list[str]
model: str
host: str
def _databricks_launch_materialization(
*, model: str | None, profile: str
) -> _DatabricksLaunchMaterialization:
"""
Resolve the Databricks-profile routing pieces of a Codex launch.
Uses the profile's own host so the gateway base URL matches the token
the profile-pinned auth command mints; a ``DATABRICKS_HOST`` override in
the runner env must not point the base URL at another workspace.
:param model: Optional explicit model pin; ``None`` resolves the
catalog default.
:param profile: ``~/.databrickscfg`` profile name, e.g. ``"oss"``.
:returns: The materialized overrides, pinned model, and host.
:raises OSError: When the profile resolves no workspace host.
"""
host = _databricks_gateway_host(profile)
if not host:
raise OSError(
f"Native Codex with Databricks profile {profile!r} (from your "
"provider config) requires a matching ~/.databrickscfg section "
"with a host visible to the runner process."
)
host = host.rstrip("/")
# Resolve against what the workspace actually serves (live UC listing →
# ucode state → bundled catalog), never the bundled catalog alone — its
# legacy ``databricks-`` spellings can 501 on today's gateway.
resolved_model = _resolve_databricks_codex_model(host, profile, model)
return _DatabricksLaunchMaterialization(
config_overrides=_databricks_codex_config_overrides(
model=resolved_model,
base_url=_databricks_codex_base_url(host),
auth_command=_databricks_codex_auth_command(host, profile),
),
model=resolved_model,
host=host,
)
# DATABRICKS-PATCH(codex-live-model-discovery)
def _resolve_databricks_codex_model(host: str, profile: str, requested: str | None) -> str:
"""Resolve the codex launch model against what the workspace serves.
Codex used to take its model from the bundled MLflow catalog — a
third-party listing whose Databricks ids carry the legacy
``databricks-`` spelling the gateway now answers with ``501
NOT_IMPLEMENTED ... Use Unity Catalog model services (v3)`` — so a launch
could pin a model the workspace will not serve. Resolve from the workspace
instead, as claude-native already does: the live Unity Catalog listing,
then ucode's cached copy of it, then the bundled catalog as the documented
last resort.
An explicit model is matched against the servable ids, so a legacy
``model_override`` persisted before this change still launches; one the
workspace does not serve passes through untouched, because the gateway's
error beats a silent substitution.
:param host: Workspace origin, e.g. ``"https://example.com"``.
:param profile: Databricks CLI profile backing the launch.
:param requested: Explicit model id, or ``None`` to take the newest
servable one.
:returns: The model id to pin on the codex launch.
"""
from omnigent.databricks_model_discovery import (
discover_databricks_codex_models,
select_servable_model,
)
servable: tuple[str, ...] = ()
try:
from omnigent.runtime.credentials.databricks import resolve_databricks_workspace
creds = resolve_databricks_workspace(profile)
# Discover against the host the launch actually posts to. This resolver
# honors ``DATABRICKS_HOST`` while the launch host comes from the
# profile section alone (``_databricks_gateway_host``), so using
# ``creds.host`` here can pin a model discovered on workspace A onto a
# launch targeting workspace B. A token that does not match ``host``
# simply fails the listing and drops to the ucode-state fallback below,
# which is already keyed by ``host``.
servable = discover_databricks_codex_models(host, creds.token)
except Exception: # noqa: BLE001 — cached ucode state is the launch fallback
_logger.warning(
"native-codex: live Databricks model discovery failed for profile %r; "
"falling back to ucode state",
profile,
exc_info=True,
)
try:
from omnigent.onboarding.ucode_state import read_ucode_state
workspace_state = read_ucode_state(host)
if workspace_state is not None:
servable = tuple(workspace_state.codex_models)
except Exception: # noqa: BLE001 — the bundled catalog is the last resort
_logger.warning(
"native-codex: could not read ucode state for %r", profile, exc_info=True
)
if requested:
return select_servable_model(requested, servable) or requested
if servable:
return servable[0]
return model_catalog.resolve_catalog_model("databricks", family="openai").model_id
def build_codex_native_server(
*,
socket_path: Path,
codex_home: Path,
cwd: Path,
model: str | None,
profile: str | None,
bridge_dir: Path,
ap_server_url: str | None = None,
ap_auth_headers: dict[str, str] | None = None,
python_executable: str | None = None,
codex_path: str | None = None,
extra_config_overrides: list[str] | None = None,
developer_instructions: str | None = None,
bypass_sandbox: bool = False,
trust_project: bool = False,
) -> CodexNativeAppServer:
"""
Build a configured native Codex app-server process wrapper.
:param socket_path: Unix socket path for the app-server.
:param codex_home: Private per-session ``CODEX_HOME`` path.
:param cwd: Working directory for Codex, e.g. the user's repo.
:param model: Optional Codex model id, e.g. ``"gpt-5.4-mini"``.
:param profile: Optional Databricks CLI profile, e.g.
``"<your-profile>"``.
:param bridge_dir: Native Codex bridge directory; the policy hook is
pointed at it and reads the session id + Omnigent coordinates from it.
:param ap_server_url: Omnigent server base URL the policy hook POSTs tool
calls to, e.g. ``"http://127.0.0.1:8787"``. ``None`` registers
the hook but writes no Omnigent coordinates (hook no-ops).
:param ap_auth_headers: Outbound auth headers for the policy hook's
Omnigent requests, e.g. ``{"Authorization": "Bearer <token>"}``.
:param python_executable: Python executable the policy hook command
runs. ``None`` uses :data:`sys.executable`.
:param codex_path: Optional executable override. ``None`` searches
``PATH``.
:param extra_config_overrides: Additional ``-c`` config overrides
appended after Databricks routing overrides, e.g. MCP server
registration for the Omnigent tool relay.
:param developer_instructions: Optional framework-owned instructions
appended to Codex's private per-session config.
:param bypass_sandbox: When ``True``, append config overrides that put
the app-server's threads into the full-bypass stance
(``approval_policy="never"`` + ``sandbox_mode="danger-full-access"``)
so the chat/forwarder seam matches the ``--remote`` TUI launched
with ``--dangerously-bypass-approvals-and-sandbox``. DANGEROUS:
disables both approval prompts and the command sandbox; gated
behind an explicit, typed-confirmation opt-in in the web UI.
Default ``False``. See issue #657.
:param trust_project: Whether to trust ``cwd`` in the private session
config before app-server startup. Intended for runner-owned headless
sessions whose hidden TUI cannot answer Codex's project-trust prompt.
:returns: Configured app-server process wrapper.
:raises ImportError: If no Codex CLI is available.
:raises OSError: If Databricks routing was requested but no
credentials can be resolved.
"""
resolved_codex = codex_path or _find_codex_cli()
if not resolved_codex:
raise ImportError(
"Native Codex requires the 'codex' CLI on PATH. If codex is "
"installed on a PATH the host daemon didn't inherit (e.g. an "
"nvm-managed bin dir), set OMNIGENT_CODEX_PATH=/path/to/codex."
)
env = _clean_codex_env()
config_overrides: list[str] = []
pinned_model = model
if profile is not None:
databricks = _databricks_launch_materialization(model=model, profile=profile)
config_overrides.extend(databricks.config_overrides)
env["DATABRICKS_HOST"] = databricks.host
# A launch that names no model still routes through the profile's
# resolved model via ``-c model=``, which outranks the config.toml
# copied from the user's shared home. Pin that model in codex's own
# spelling — the vocabulary config.toml and its readers use — so the
# forwarder mirror and cost gate report the model this session runs
# instead of whatever the shared file was last left on.
pinned_model = codex_spawn_model(databricks.model) or databricks.model
if extra_config_overrides:
config_overrides.extend(extra_config_overrides)
if bypass_sandbox:
# Mirror the --remote TUI's --dangerously-bypass-approvals-and-sandbox
# on the app-server threads: never prompt for approval, and run
# commands with no command sandbox. Emitted last so it wins over any
# earlier approval/sandbox override.
config_overrides.extend(
[
'approval_policy="never"',
'sandbox_mode="danger-full-access"',
]
)
# Every launch is explicit: the resolved model rides argv (``-c model=``)
# AND the private config copy's ``model =`` line (pinned in ``start``),
# both written from this one value — so a stale line copied from the
# user's shared config can never govern a session, and the two artifacts
# cannot drift.
if pinned_model and not any(
override.split("=", 1)[0] == "model" for override in config_overrides
):
config_overrides.append(f"model={json.dumps(pinned_model)}")
return CodexNativeAppServer(
codex_path=resolved_codex,
socket_path=socket_path,
codex_home=codex_home,
env=env,
config_overrides=config_overrides,
cwd=cwd,
bridge_dir=bridge_dir,
developer_instructions=developer_instructions,
ap_server_url=ap_server_url,
ap_auth_headers=ap_auth_headers,
python_executable=python_executable,
pinned_model=pinned_model,
trust_project=trust_project,
)
@dataclass(frozen=True)
class NativeCodexLaunch:
"""How a native Codex terminal should be launched, across all offerings.
Resolved by :func:`resolve_native_codex_launch` so a native Codex
session honors ``configure harnesses`` like the in-process codex harness.
:param config_overrides: Codex ``-c`` overrides that route through a
generic provider (``model_provider`` + base_url + auth + wire);
empty for the Databricks-profile and CLI-login paths.
:param model: Model id to pin, or ``None`` to keep Codex's default.
:param profile: Databricks profile for the ucode path, or ``None`` (a
generic provider routes via *config_overrides*; CLI login uses
neither).
:param summary: Human-readable one-line description of the routing
outcome (provider / profile / model, or the login-fallback state),
set at resolution time and surfaced in the startup-timeout error so
hosted users can diagnose without runner-log access (see #2745).
"""
config_overrides: list[str]
model: str | None
profile: str | None
summary: str = ""
def codex_session_meta_model_provider(launch: NativeCodexLaunch) -> str:
"""Return the provider id a launch routes through, for rollout synthesis.
Synthesized rollouts (fork carry-history, cross-machine cold resume) must
name the model provider in ``session_meta``: codex >= 0.133 backfills
rollouts written before app-server start into its thread-store sqlite,
and ``thread/resume`` of a backfilled row whose provider is empty or
unresolvable fails config load (``Model provider `` not found``),
silently dropping the carried history. The correct value is whatever
provider the launch itself routes through:
- a ``model_provider`` ``-c`` override (cli-config / key / gateway /
local providers) pins it explicitly — the override value is a TOML
basic string, which is also valid JSON;
- a Databricks profile launch carries no override here; the provider
table is generated at app-server start under the fixed
``omnigent_databricks`` id (see ``_databricks_codex_config_overrides``);
- otherwise the launch defers to Codex's own login, the built-in
``openai`` provider.
:param launch: Resolved native-Codex launch, e.g. one returned by
:func:`resolve_native_codex_launch`.
:returns: Provider id for ``session_meta.model_provider``, e.g.
``"omnigent_databricks"``.
"""
prefix = "model_provider="
for override in launch.config_overrides:
if override.startswith(prefix):
decoded: object = json.loads(override.removeprefix(prefix))
if not isinstance(decoded, str):
raise ValueError("model_provider override must decode to a string")
return decoded
if launch.profile is not None:
return "omnigent_databricks"
return "openai"
def native_codex_launch_base_url(launch: NativeCodexLaunch) -> str | None:
"""Inference base URL a resolved launch pins, or None when it defers to Codex's own login.
Mirrors how the launch is actually applied: the Databricks-profile branch of
:func:`build_native_codex_app` derives the base URL from the profile host,
while a generic provider carries it inside the generated
``model_providers.…`` config override.
:param launch: Resolved native-Codex launch, e.g. one returned by
:func:`resolve_native_codex_launch`.
:returns: The base URL the launch routes through, or ``None`` when the
launch pins none.
"""
if launch.profile is not None:
host = _databricks_gateway_host(launch.profile)
if not host:
return None
return _databricks_codex_base_url(host.rstrip("/"))
for override in launch.config_overrides:
_, sep, table = override.partition("=")
if not sep or not override.startswith("model_providers."):
continue
marker = "base_url="
index = table.find(marker)
if index < 0:
continue
decoder = json.JSONDecoder()
try:
base_url, _ = decoder.raw_decode(table[index + len(marker) :])
except ValueError:
continue
if isinstance(base_url, str):
return base_url
# A cli-config entry pins only a provider *name*; its table (with the
# base_url) lives in the user's shared ~/.codex/config.toml. Read that
# file to resolve the base URL a cli-config launch actually routes through.
if _launch_pins_model_provider(launch):
return _cli_config_provider_base_url(codex_session_meta_model_provider(launch))
# No provider pinned at all: the deliberate config-default path leaves
# overrides empty so Codex uses its own config.toml top-level
# ``model_provider`` default (a Databricks-wide setup). Resolve that.
return _config_default_provider_base_url()
def _launch_pins_model_provider(launch: NativeCodexLaunch) -> bool:
"""Whether a launch carries an explicit ``model_provider=`` override.
Distinguishes a launch that pins a provider name (cli-config, or the
literal ``model_provider="openai"`` the subscription / dismissed paths
set) from the empty-override config-default launch, which pins none.
"""
return any(override.startswith("model_provider=") for override in launch.config_overrides)
def _config_default_provider_base_url() -> str | None:
"""Base URL Codex's config.toml top-level ``model_provider`` default routes to.
When omnigent pins no provider, Codex falls back to the top-level
``model_provider`` in the user's shared ``config.toml`` — unless the user
dismissed that default, which pins Codex's built-in ``openai`` instead.
:returns: The default provider table's ``base_url``, or ``None`` when the
default is dismissed, absent, or unreadable.
"""
import tomllib
from omnigent.inner.codex_executor import _codex_home_config_source_from_env
from omnigent.onboarding.detected import codex_config_provider_dismissed
from omnigent.onboarding.provider_config import load_config
if codex_config_provider_dismissed(load_config()):
return None
config_path = _codex_home_config_source_from_env() / "config.toml"
try:
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
provider_name = data["model_provider"]
except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError):
return None
if not isinstance(provider_name, str):
return None
return _config_toml_provider_base_url(provider_name)
def _cli_config_provider_base_url(provider_name: str) -> str | None:
"""Base URL a cli-config provider name resolves to in the user's codex config.
A ``cli-config`` launch pins only a ``model_provider`` name; the provider
table lives in the user's shared ``config.toml``, which the launch never
inlines. Read it here so the gateway-inference probe can see the URL.
Only genuine cli-config provider names are looked up: ``"openai"`` is
Codex's own login (no pinned AIGW) and ``"omnigent_databricks"`` is the
profile branch's generated id, so both return ``None``.
:param provider_name: Provider id from
:func:`codex_session_meta_model_provider`.
:returns: The provider table's ``base_url``, or ``None`` when it cannot be
read.
"""
if provider_name in ("openai", "omnigent_databricks"):
return None
return _config_toml_provider_base_url(provider_name)
def _config_toml_provider_base_url(provider_name: str) -> str | None:
"""Read ``[model_providers.<provider_name>].base_url`` from the shared config.toml.
:param provider_name: A provider table key in the user's ``config.toml``.
:returns: That table's ``base_url``, or ``None`` when it cannot be read.
"""
import tomllib
from omnigent.inner.codex_executor import _codex_home_config_source_from_env
config_path = _codex_home_config_source_from_env() / "config.toml"
try:
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
base_url = data["model_providers"][provider_name]["base_url"]
except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError):
return None
return base_url if isinstance(base_url, str) else None
def _codex_provider_launch(entry: ProviderEntry, model: str | None) -> NativeCodexLaunch | None:
"""Build a native-Codex launch that routes through a single provider entry.
Mirrors the in-process codex harness routing for the ``openai`` surface:
- a ``databricks`` entry routes via its ucode profile (the Databricks
branch of :func:`build_native_codex_app` turns the profile into config
overrides), so the launch carries ``profile`` and empty overrides;
- a ``cli-config`` entry routes via a single ``model_provider`` ``-c``
override pinning the custom provider its ``~/.codex/config.toml``
defines (the provider table + credential live in that file, which the
native server bridges into the session ``CODEX_HOME``);
- a ``key`` / ``gateway`` / ``local`` entry routes via a generated
``model_provider`` ``-c`` override (base_url + bearer-token auth command
+ wire protocol).
Returns ``None`` when *entry* cannot route Codex on its own — a
``subscription`` entry (which defers to Codex's own stored login), a
provider that does not serve the ``openai`` surface, or a key/gateway/local
entry with no credential or whose secret reference does not resolve in this
process. A ``None`` lets the caller fall through to another provider rather
than crash at terminal launch or strand the user at Codex's login screen.
:param entry: The provider entry to route through, e.g. a ``key`` entry for
``openai`` or a ``databricks`` entry.
:param model: An explicit/session model override that wins over the
provider's default model, e.g. ``"gpt-5.5"``; ``None`` keeps the
provider's default.
:returns: A routable :class:`NativeCodexLaunch`, or ``None`` when *entry*
cannot route without Codex's own login.
"""
from omnigent.errors import OmnigentError
from omnigent.onboarding.provider_config import (
CLI_CONFIG_KIND,
DATABRICKS_KIND,
GATEWAY_KIND,
KEY_KIND,
LOCAL_KIND,
OPENAI_FAMILY,
)
if entry.kind == DATABRICKS_KIND:
return NativeCodexLaunch(
config_overrides=[],
model=model,
profile=entry.profile,
summary=f"Databricks ucode profile {entry.profile!r}",
)
if entry.kind == CLI_CONFIG_KIND:
# Pin the config.toml-defined provider by name; its table (and
# credential) ride along via the bridged config.toml. json.dumps
# yields a valid TOML basic string for the -c override value.
return NativeCodexLaunch(
config_overrides=[f"model_provider={json.dumps(entry.model_provider)}"],
model=model,
profile=None,
summary=(
f"provider {entry.name!r} via cli-config (model_provider={entry.model_provider!r})"
),
)
if entry.kind not in (KEY_KIND, GATEWAY_KIND, LOCAL_KIND):
return None
try:
family = entry.family(OPENAI_FAMILY)
except OmnigentError:
# The credential reference (``env:...`` / ``keychain:...``) does not
# resolve in this process — treat the provider as unroutable so the
# caller can try another instead of crashing at terminal launch.
return None
if family is None:
return None
if family.auth_command:
auth_command = family.auth_command
elif family.api_key:
auth_command = f"printf %s {shlex.quote(family.api_key)}"
else:
# Serves openai but carries no usable credential.
return None
pinned = model or family.default_model
overrides = _provider_codex_config_overrides(
model=pinned,
base_url=family.base_url,
auth_command=auth_command,
wire_api=family.wire_api or "responses",
)
return NativeCodexLaunch(
config_overrides=overrides,
model=pinned,
profile=None,
summary=f"provider {entry.name!r} (model={pinned})",
)
def _first_routable_codex_provider(
config: dict[str, object], *, exclude: str, model: str | None
) -> NativeCodexLaunch | None:
"""Find a provider other than *exclude* that can route a native Codex launch.
Used when the resolved default is a ``subscription`` entry but Codex has no
usable stored login: rather than strand the user at Codex's login screen,
route through the first *other* provider serving the ``openai`` surface that
can produce a launch (a real key/gateway/local credential or a Databricks
profile). Explicit providers are tried before ambient detections — the
user's config is authoritative — and ambient detections (e.g. a real
``OPENAI_API_KEY`` in the environment) are honored only as a fallback.
This does **not** mutate the persisted default: the dead subscription
remains the configured default, so if the user re-logs into Codex it
resumes being used. This is a runtime safety net, not a default change.
:param config: The explicit parsed config mapping (``providers:`` block);
ambient detections are merged in read-only.
:param exclude: The provider name to skip — the dead subscription default,
e.g. ``"codex"``.
:param model: An explicit/session model override, or ``None``.
:returns: The first routable :class:`NativeCodexLaunch`, or ``None`` when no
other provider can route Codex.
"""
from omnigent.onboarding.detected import effective_config_with_detected
from omnigent.onboarding.provider_config import (
OPENAI_FAMILY,
load_providers,
provider_families,
)
explicit_providers = load_providers(config)
merged_providers = load_providers(effective_config_with_detected(config))
# Explicit first (authoritative), then ambient-only detections.
ordered = list(explicit_providers.items()) + [
(name, entry) for name, entry in merged_providers.items() if name not in explicit_providers
]
for name, candidate in ordered:
if name == exclude:
continue
if OPENAI_FAMILY not in provider_families(candidate):
continue
launch = _codex_provider_launch(candidate, model)
if launch is not None:
_logger.warning(
"native-codex: subscription default %r has no usable Codex login; "
"routing through provider %r instead.",
exclude,
name,
)
return launch
return None
def _resolve_subscription_launch(
entry: ProviderEntry, model: str | None, explicit: dict[str, object]
) -> NativeCodexLaunch:
"""Resolve a native-Codex launch when the Codex default is a ``subscription``.
A subscription defers to Codex's own stored login — correct only when Codex
is actually logged in. An empty / logged-out ``auth.json`` would otherwise
route to Codex's login screen even though the user configured a real
credential, so when Codex is not logged in this falls through to the first
other configured provider that can route (runtime only — the persisted
default is untouched). With no usable login and nothing to fall through to,
Codex's own login is the correct outcome (the user must re-authenticate).
:param entry: The resolved ``subscription`` provider entry (the Codex
default), e.g. a ``codex`` CLI-login entry.
:param model: An explicit/session model override, or ``None``.
:param explicit: The explicit parsed config mapping (``providers:`` block),
used for the fall-through search over other configured/detected
providers.
:returns: The resolved :class:`NativeCodexLaunch`.
"""
from omnigent.onboarding.ambient import codex_auth_has_credential
# Pin codex's built-in ``openai`` provider: the bridged config.toml may
# set a custom default ``model_provider`` (e.g. isaac's Databricks AI
# Gateway), which would silently hijack a Subscription selection. A
# no-op when the user's config sets no custom default.
subscription_overrides = ['model_provider="openai"']
# Resolve against the same CODEX_HOME the native server bridges from
# (``_populate_codex_home_config``) so this "is Codex logged in?" check reads
# the exact auth.json the launched Codex process will use.
real_codex_home = _codex_home_config_source_from_env()
if codex_auth_has_credential(real_codex_home / "auth.json"):
_logger.info(
"native-codex routing: Codex CLI login (subscription provider %r; Codex is logged in)",
entry.name,
)
return NativeCodexLaunch(
config_overrides=subscription_overrides,
model=model,
profile=None,
summary=f"Codex CLI login (subscription provider {entry.name!r}; Codex is logged in)",
)
fallback = _first_routable_codex_provider(explicit, exclude=entry.name, model=model)
if fallback is not None:
return fallback
_logger.info(
"native-codex routing: Codex CLI login (subscription provider %r has no usable "
"Codex login and no alternative provider is configured)",
entry.name,
)
return NativeCodexLaunch(
config_overrides=subscription_overrides,
model=model,
profile=None,
summary=(
f"Codex CLI login (subscription provider {entry.name!r} has no usable Codex "
"login and no alternative provider is configured) — the TUI likely renders "
"the sign-in screen and never starts a thread"
),
)
def resolve_native_codex_launch(
*, model: str | None, spec: AgentSpec | None = None
) -> NativeCodexLaunch:
"""Resolve the native Codex launch config across all offerings.
Mirrors the in-process codex harness routing precedence
(:func:`omnigent.runtime.workflow._resolve_provider_for_build`) for the
``openai`` surface, so ``omnigent codex`` and a host-spawned native
Codex session route through ``omnigent setup``:
0. (with *spec*) a spec-level credential — ``executor.auth`` naming a
provider (:class:`~omnigent.spec.types.ProviderAuth`, fails loud when
undeclared), a spec :class:`~omnigent.spec.types.DatabricksAuth`, or a
legacy ``executor.profile`` / ``executor.config.profile`` — resolved
through :func:`~omnigent.runtime.workflow._resolve_provider_for_build`
itself, the same resolver the in-process harness uses, so a spec that
routes in-process routes natively too (a spec ``ApiKeyAuth`` resolves
to ``None`` for every harness — the resolver leaves bare keys to the
claude-sdk / openai-agents builders — so codex-native falls through
exactly as in-process codex does);
1. an explicit per-family default provider →
- ``key`` / ``gateway`` / ``local`` → provider ``-c`` overrides
(base_url + token + wire), ``profile=None``;
- ``databricks`` → the ucode profile path (its profile);
- ``subscription`` → the Codex CLI's own stored login (no overrides)
**when Codex is actually logged in**; otherwise (empty / logged-out
``auth.json``) fall through to the first other configured provider
that can route, so a real credential is not shadowed by a dead
subscription default;
2. else a global Databricks ``auth:`` block → ucode;
3. else an ambient-detected provider (first run without configure);
4. else the codex CLI's own login.
Without a *spec* (or when the spec carries no spec-level credential),
credentials are controlled by ``omnigent setup`` provider config (or the
legacy global ``auth:`` block) exactly as before — there is no CLI/env
profile override, and machine-level flows are unchanged.
:param model: An explicit/session model override that wins over the
provider's default model, or ``None``.
:param spec: The custom agent spec launching this session, when there is
one, so its ``executor.auth`` / legacy profile win over machine-level
config (issue #2744 — parity with the in-process codex harness).
:returns: The resolved :class:`NativeCodexLaunch`.
"""
from omnigent.onboarding.ambient import codex_config_detection
from omnigent.onboarding.detected import (
dismissed_detection_names,
effective_config_with_detected,
)
from omnigent.onboarding.provider_config import (
SUBSCRIPTION_KIND,
default_provider_for_harness,
load_config,
)
from omnigent.runtime.workflow import _load_global_auth, _resolve_provider_for_build
from omnigent.spec.types import DatabricksAuth
explicit = load_config()
config_detection = codex_config_detection()
config_provider_dismissed = (
config_detection is not None
and config_detection.name in dismissed_detection_names(explicit)
)
# When the launch ends up on codex's own login with NO provider routing,
# the bridged config.toml's custom default model_provider would still
# apply — including one the user explicitly Removed (dismissed). Pin
# codex's built-in provider in that case so the dismissal holds at run
# time. An undetectable/undismissed custom provider keeps its routing.
no_provider_overrides = ['model_provider="openai"'] if config_provider_dismissed else []
if spec is not None and (
spec.executor.auth is not None
or spec.executor.profile
or spec.executor.config.get("profile")
):
# Spec-level credential (issue #2744): resolve it through the same
# resolver the in-process codex harness uses, so switching a working
# spec from ``harness: codex`` to ``codex-native`` keeps its auth
# working. A named provider that is undeclared raises loud here
# instead of parking the TUI on the sign-in screen for a 30s timeout.
# A spec ``ApiKeyAuth`` resolves to ``None`` for every harness (the
# shared resolver leaves bare keys to the claude-sdk / openai-agents
# builders; the in-process codex builder has no ApiKeyAuth branch
# either), so codex-native falls through to the machine-level chain
# below exactly as in-process codex does — as does a spec credential
# that cannot route openai.
spec_entry = _resolve_provider_for_build(spec, harness_type="codex", for_launch=True)
if spec_entry is not None:
if spec_entry.kind == SUBSCRIPTION_KIND:
# A spec-named subscription defers to Codex's own login,
# logged in or not. The machine-default path would substitute
# the first OTHER routable provider on a logged-out Codex
# (:func:`_resolve_subscription_launch`) — never do that for
# an explicit spec declaration: silently running a credential
# the spec did not name is worse than the login screen.
from omnigent.onboarding.ambient import codex_auth_has_credential
if codex_auth_has_credential(_codex_home_config_source_from_env() / "auth.json"):
state = "Codex is logged in"
else:
state = (
"Codex is not logged in — the TUI likely renders the "
"sign-in screen and never starts a thread"
)
return NativeCodexLaunch(
config_overrides=['model_provider="openai"'],
model=model,
profile=None,
summary=f"Codex CLI login (spec provider {spec_entry.name!r}; {state})",
)
launch = _codex_provider_launch(spec_entry, model)
if launch is not None:
if launch.profile is not None:
_logger.info(
"native-codex routing: Databricks ucode profile %r (spec auth)",
launch.profile,
)
else:
_logger.info(
"native-codex routing: provider %r (spec auth, model=%s)",
spec_entry.name,
launch.model,
)
return launch
_logger.warning(
"native-codex: spec-level provider %r has no usable openai credential — "
"falling back to machine-level resolution.",
spec_entry.name,
)
entry = default_provider_for_harness(explicit, "codex")
if entry is None:
# No explicit provider default: global auth wins over ambient
# (parity with _resolve_provider_for_build).
global_auth = _load_global_auth()
if isinstance(global_auth, DatabricksAuth):
return NativeCodexLaunch(
config_overrides=[],
model=model,
profile=global_auth.profile,
summary=f"Databricks ucode profile {global_auth.profile!r} (global auth block)",
)
if global_auth is not None:
return NativeCodexLaunch(
config_overrides=[],
model=model,
profile=None,
summary="Codex CLI login (global auth block, non-Databricks; no provider routing)",
)
entry = default_provider_for_harness(effective_config_with_detected(explicit), "codex")
if (
entry is None
and config_detection is not None
and config_detection.model_provider is not None
and not config_provider_dismissed
):
# An adopted cli-config entry can explicitly shadow the same ambient
# detection without being marked the Omnigent default. Codex still
# selects that provider from config.toml, so pin the already-resolved
# detection instead of describing this as an OpenAI-login launch.
# This keeps rollout metadata, app-server, and remote TUI routing on
# one immutable provider selection during cold resume.
provider_id = config_detection.model_provider
_logger.info(
"native-codex routing: config.toml provider %r (ambient fallback, model=%s)",
provider_id,
model,
)
return NativeCodexLaunch(
config_overrides=[f"model_provider={json.dumps(provider_id)}"],
model=model,
profile=None,
summary=f"Codex config.toml provider {provider_id!r} (ambient fallback)",
)
if entry is None:
_logger.info(
"native-codex routing: Codex CLI login (no provider configured for the Codex "
"harness, no Databricks profile). Run `omnigent setup --no-internal-beta` to route "
"through a provider."
)
return NativeCodexLaunch(
config_overrides=no_provider_overrides,
model=model,
profile=None,
summary=(
"Codex CLI login (no provider configured for the codex harness, no "
"Databricks profile) — the TUI likely renders the ChatGPT sign-in "
"screen and never starts a thread; run `omnigent setup` to route through "
"a provider"
),
)
if entry.kind == SUBSCRIPTION_KIND:
return _resolve_subscription_launch(entry, model, explicit)
launch = _codex_provider_launch(entry, model)
if launch is not None:
if launch.profile is not None:
_logger.info("native-codex routing: Databricks ucode profile %r", launch.profile)
else:
_logger.info("native-codex routing: provider %r (model=%s)", entry.name, launch.model)
return launch
# Default provider can't route on its own (no openai surface / no usable
# credential / unresolvable secret) → Codex's own login.
_logger.warning(
"native-codex: provider %r is the Codex default but has no usable openai "
"credential — falling back to Codex's own login.",
entry.name,
)
return NativeCodexLaunch(
config_overrides=no_provider_overrides,
model=model,
profile=None,
summary=(
f"Codex CLI login (provider {entry.name!r} is the codex default but has no "
"usable openai credential) — the TUI likely renders the sign-in screen "
"and never starts a thread"
),
)
def client_for_transport(
transport: str,
*,
client_name: str = "omnigent",
) -> CodexAppServerClient:
"""
Build an app-server client for a persisted transport string.
The native Codex app-server is reachable over either a loopback
WebSocket (``"ws://IP:PORT"`` / ``"wss://..."``) or a Unix socket
path (legacy ``"/path/app-server.sock"``). Both the host-spawned
runner and the local CLI now listen on ``ws://`` — the only
transport Codex CLI ``app-server`` accepts since it dropped
``unix://`` support — and persist whichever was used as the bridge
state's ``socket_path``. Every connect site (executor steering /
interrupt / run_turn, forwarder, initial turn) routes through this
one rule so a ``ws://`` transport is never mistakenly wrapped in
``Path(...)`` and dialed as a (nonexistent) Unix socket.
:param transport: App-server transport from bridge state, e.g.
``"ws://127.0.0.1:9876"`` or
``"/home/u/.omnigent/codex-native/x/app-server.sock"``.
:param client_name: App-server initialize-handshake client name,
e.g. ``"omnigent-codex-native"``.
:returns: A client configured for the transport (not yet connected).
"""
if transport.startswith(("ws://", "wss://")):
return CodexAppServerClient(ws_url=transport, client_name=client_name)
return CodexAppServerClient(Path(transport), client_name=client_name)
def normalize_codex_permission_launch_args(
terminal_launch_args: Sequence[str] | None,
) -> list[str]:
"""Complete legacy Full Access args with their approval policy."""
args = list(terminal_launch_args or ())
full_access = False
has_approval_policy = "--dangerously-bypass-approvals-and-sandbox" in args
index = 0
while index < len(args):
arg = args[index]
assignment: str | None = None
if arg in {"--ask-for-approval", "-a"} or arg.startswith(("--ask-for-approval=", "-a=")):
has_approval_policy = True
elif arg in {"--config", "-c"} and index + 1 < len(args):
index += 1
assignment = args[index]
elif arg.startswith(("--config=", "-c=")):
assignment = arg.split("=", 1)[1]
if assignment is not None:
key, _, raw_value = assignment.partition("=")
if key.strip() == "approval_policy":
has_approval_policy = True
elif key.strip() == "default_permissions":
full_access = _codex_config_string(raw_value) == ":danger-full-access"
index += 1
if full_access and not has_approval_policy:
args.extend(["-c", 'approval_policy="never"'])
return args
def _codex_config_string(raw_value: str) -> str:
try:
value = tomlkit.parse(f"value = {raw_value}")["value"]
except Exception: # noqa: BLE001 - Codex accepts some unquoted CLI config values.
value = raw_value.strip().strip('"').strip("'")
return value if isinstance(value, str) else ""
def _codex_resume_permission_params(terminal_launch_args: Sequence[str] | None) -> CodexParams:
"""Convert persisted Codex permission args into thread-resume overrides."""
params: CodexParams = {}
args = normalize_codex_permission_launch_args(terminal_launch_args)
index = 0
while index < len(args):
arg = args[index]
value: str | None = None
if arg == "--dangerously-bypass-approvals-and-sandbox":
params.update(approvalPolicy="never", sandbox="danger-full-access")
elif arg in {"--ask-for-approval", "-a", "--sandbox", "-s"}:
if index + 1 < len(args):
value = args[index + 1]
index += 1
if value is not None:
field = "approvalPolicy" if arg in {"--ask-for-approval", "-a"} else "sandbox"
params[field] = value
elif arg.startswith(("--ask-for-approval=", "-a=")):
params["approvalPolicy"] = arg.split("=", 1)[1]
elif arg.startswith(("--sandbox=", "-s=")):
params["sandbox"] = arg.split("=", 1)[1]
elif arg in {"--config", "-c"} and index + 1 < len(args):
index += 1
key, _, value = args[index].partition("=")
_set_codex_resume_config_param(params, key, value)
elif arg.startswith(("--config=", "-c=")):
key, _, value = arg.split("=", 1)[1].partition("=")
_set_codex_resume_config_param(params, key, value)
index += 1
if "permissions" in params:
params.pop("sandbox", None)
return params
def _set_codex_resume_config_param(params: CodexParams, key: str, raw_value: str) -> None:
field = {
"approval_policy": "approvalPolicy",
"approvals_reviewer": "approvalsReviewer",
"default_permissions": "permissions",
"sandbox_mode": "sandbox",
}.get(key.strip())
if field is None:
return
value = _codex_config_string(raw_value)
if value:
params[field] = value
async def preload_codex_thread_for_resume(
transport: str,
thread_id: str,
*,
terminal_launch_args: Sequence[str] | None = None,
) -> None:
"""
Load an existing Codex thread into a freshly started app-server.
A rollout JSONL on disk is not enough for immediate web-message
injection: a new app-server can reject ``turn/start`` with
``thread not found`` until some client has resumed the thread. This
helper runs the lightweight ``thread/resume`` call before bridge
state is exposed to the web-message executor.
:param transport: App-server transport, e.g. ``"ws://127.0.0.1:9876"``
or ``"/tmp/app-server.sock"``.
:param thread_id: Codex thread id to load, e.g.
``"019e96aa-0be2-7343-8d3b-6f914d60936b"``.
:param terminal_launch_args: Persisted permission overrides for the resumed thread.
:returns: None.
:raises RuntimeError: If the app-server rejects the resume.
"""
client = client_for_transport(
transport,
client_name="omnigent-codex-native-preload",
)
await client.connect()
try:
await client.request(
"thread/resume",
{
"threadId": thread_id,
"excludeTurns": True,
**_codex_resume_permission_params(terminal_launch_args),
},
)
finally:
await client.close()
def codex_terminal_env(app_server: CodexNativeAppServer) -> dict[str, str]:
"""
Build terminal env overrides for the native Codex TUI.
:param app_server: Running app-server wrapper.
:returns: Environment variables for the terminal process.
"""
return {
key: value
for key, value in {**app_server.env, "CODEX_HOME": str(app_server.codex_home)}.items()
if key in {"CODEX_HOME", "DATABRICKS_HOST", "DATABRICKS_CODEX_TOKEN"}
or key.startswith(("OPENAI_", "HTTP_", "HTTPS_", "NO_PROXY", "ALL_PROXY"))
}
# Codex's full-bypass flag. Disables BOTH the approval prompts and the
# command sandbox in one switch. Verified against codex-cli 0.140.0-alpha.2:
# it is mutually exclusive with the approval flag only — passing
# ``--ask-for-approval`` (or its ``-a`` alias, in any spelling) alongside it
# aborts at startup with "cannot be used with
# --dangerously-bypass-approvals-and-sandbox". ``--sandbox`` / ``-s`` do NOT
# conflict (the bypass already implies ``danger-full-access``), so leaving
# them in is harmless. We strip BOTH anyway when bypass is on — the approval
# flag because it MUST go, the sandbox flag for hygiene so the launched arg
# list reflects a single coherent stance.
_CODEX_BYPASS_SANDBOX_FLAG = "--dangerously-bypass-approvals-and-sandbox"
_CODEX_BYPASS_HOOK_TRUST_FLAG = "--dangerously-bypass-hook-trust"
# Granular approval/sandbox flags to drop when bypass is on. The "Full
# access" / "Read only" approval presets emit the long ``--flag value`` form
# (see web CODEX_NATIVE_APPROVAL_MODES), but ``terminal_launch_args`` is
# client-supplied (validated only for count/length), so the short aliases
# (``-a`` / ``-s``) are included too: ``-a`` triggers the same startup abort
# as ``--ask-for-approval`` and must never reach codex. Each is matched in
# both the space-separated (``-a never``) and joined (``-a=never``) spellings
# by :func:`_strip_approval_sandbox_flags`.
_CODEX_APPROVAL_SANDBOX_FLAGS = frozenset({"--sandbox", "-s", "--ask-for-approval", "-a"})
def _strip_approval_sandbox_flags(codex_args: tuple[str, ...]) -> list[str]:
"""
Drop granular approval/sandbox flags (and values) when bypass is on.
Removes every flag in :data:`_CODEX_APPROVAL_SANDBOX_FLAGS` —
``--ask-for-approval`` / ``-a`` (which codex *rejects* alongside the
bypass flag) and ``--sandbox`` / ``-s`` (harmless, dropped for hygiene).
Both CLI spellings of each are handled:
- ``--sandbox=read-only`` (single ``--flag=value`` token) is dropped
whole.
- ``--sandbox read-only`` (separate flag + value) drops the flag and
its following value — but ONLY when that next token is actually a
value (it does not itself start with ``-``). A following
``--something`` is a separate flag, not this flag's value, so it is
left in place (e.g. ``("--sandbox", "--model", "gpt")`` keeps
``"--model", "gpt"``). A trailing flag at end-of-list is dropped
cleanly with no value to consume.
Any already-present bypass flag is also dropped so the caller can
re-add a single canonical copy. Unrelated args (model, config
overrides, ...) pass through untouched.
:param codex_args: Raw Codex CLI args, e.g.
``("--sandbox", "read-only", "--model", "gpt-5.4-mini")``.
:returns: ``codex_args`` with the conflicting flags removed, e.g.
``["--model", "gpt-5.4-mini"]``.
"""
cleaned: list[str] = []
i = 0
n = len(codex_args)
while i < n:
arg = codex_args[i]
if arg in _CODEX_APPROVAL_SANDBOX_FLAGS:
# ``--flag value``: drop the flag, and consume the NEXT token as
# its value ONLY when that token is a real value — it exists and
# does not itself start with ``-`` (a leading ``-`` marks a
# separate flag, e.g. ``("--sandbox", "--model", "gpt")`` keeps
# ``--model``; a trailing flag at end-of-list consumes nothing).
if i + 1 < n and not codex_args[i + 1].startswith("-"):
i += 2
else:
i += 1
continue
if any(arg.startswith(f"{flag}=") for flag in _CODEX_APPROVAL_SANDBOX_FLAGS):
# ``--flag=value`` single token: drop it whole, consume nothing.
i += 1
continue
if arg == _CODEX_BYPASS_SANDBOX_FLAG:
# Drop any pre-existing bypass flag; a single canonical copy is
# re-added by the caller so it is never duplicated.
i += 1
continue
cleaned.append(arg)
i += 1
return cleaned
def build_codex_remote_args(
*,
codex_args: tuple[str, ...],
thread_id: str | None,
remote_url: str,
config_overrides: tuple[str, ...] = (),
bypass_sandbox: bool = False,
bypass_hook_trust: bool = False,
) -> list[str]:
"""
Build Codex CLI args for an app-server-backed TUI session.
The TUI attaches to an already-running Codex app-server over its
``--remote`` transport so the terminal, the chat forwarder, and the
web-UI message bridge all drive the same thread. The transport is
passed verbatim so callers can attach over either a Unix socket
(``"unix://PATH"``, the local CLI path) or a loopback TCP websocket
(``"ws://IP:PORT"``, the host-spawned runner path — see
:class:`CodexNativeAppServer` ``listen_url``).
The ``config_overrides`` are the same ``-c key=value`` provider/model
overrides the app-server is launched with. The ``--remote`` TUI is a
*separate* process that loads its own config from ``CODEX_HOME`` and
does NOT inherit the app-server's ``-c`` flags; without them it falls
back to the built-in OpenAI provider, whose ``requires_openai_auth``
is ``true``, so the TUI renders the first-run "Sign in with ChatGPT"
onboarding screen and never creates a thread. On a host-spawned
(web-UI-driven) session there is nobody at the terminal to dismiss
that screen, so ``wait_for_thread_started`` times out and the session
hangs in ``running`` with no response. Passing the provider overrides
through makes the TUI resolve the Omnigent provider
(``requires_openai_auth = false``), skip onboarding, and start the
thread immediately. Codex global ``-c`` flags must precede the
``resume`` subcommand, so they are emitted first.
:param codex_args: Raw Codex CLI args that precede the attach flags,
e.g. ``("--model", "gpt-5.4-mini")``. Empty when the thread's own
settings already cover everything.
:param thread_id: Codex thread id to resume, e.g. ``"thread_abc123"``.
``None`` starts a fresh remote Codex TUI thread instead of
resuming an existing one.
:param remote_url: App-server endpoint the TUI attaches to, e.g.
``"unix:///home/user/.omnigent/codex-native/x/app-server.sock"``
or ``"ws://127.0.0.1:9876"``.
:param config_overrides: Codex ``-c`` config override values to apply
to the TUI, e.g.
``('model="databricks-gpt-5-5"', 'model_provider="omnigent_databricks"')``.
Each is emitted as a ``-c <value>`` global flag. Empty for a
plain Codex-login launch that needs no provider routing.
:param bypass_sandbox: When ``True``, emit a single
``--dangerously-bypass-approvals-and-sandbox`` flag and strip any
conflicting ``--sandbox`` / ``--ask-for-approval`` pairs from
*codex_args* (codex aborts at startup if the bypass flag is
combined with either). DANGEROUS: this disables both the approval
prompts and the command sandbox; it is gated behind an explicit,
typed-confirmation opt-in in the web UI. Default ``False`` keeps
the granular flags untouched. See issue #657.
:param bypass_hook_trust: When ``True``, emit
``--dangerously-bypass-hook-trust`` so the TUI runs all enabled
hooks without the interactive "Hooks need review" trust prompt.
Intended for runner-owned headless sessions where the private
``CODEX_HOME`` is provisioned by Omnigent and there is no terminal
user to answer the prompt. Default ``False`` for interactive
``omnigent codex`` sessions where the user faces the terminal and
can accept hooks normally.
:returns: Codex argv tail after the executable.
"""
override_args: list[str] = []
for override in config_overrides:
if override.lstrip().startswith("model_providers."):
raise ValueError(
"Codex remote provider definitions must be materialized in CODEX_HOME"
)
override_args.extend(["-c", override])
if bypass_sandbox:
# Strip the conflicting granular flags, then prepend one canonical
# bypass flag (a global flag, so it precedes any ``resume``).
passthrough = [_CODEX_BYPASS_SANDBOX_FLAG, *_strip_approval_sandbox_flags(codex_args)]
else:
passthrough = normalize_codex_permission_launch_args(codex_args)
if bypass_hook_trust:
passthrough = [_CODEX_BYPASS_HOOK_TRUST_FLAG, *passthrough]
if thread_id is None:
return [*override_args, *passthrough, "--remote", remote_url]
return [*override_args, *passthrough, "resume", "--remote", remote_url, thread_id]
def _terminate_process_tree(process: asyncio.subprocess.Process) -> None:
"""
Send SIGTERM to a subprocess process group when possible.
:param process: Subprocess handle to terminate.
:returns: None.
"""
_proc.terminate_tree(process)
def _process_group_id(process: asyncio.subprocess.Process) -> int:
"""
Return the child process group id used for crash-safe reaping.
:param process: Subprocess handle.
:returns: Process group id, falling back to pid on non-POSIX hosts.
"""
if os.name == "posix":
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
return os.getpgid(process.pid)
return process.pid
def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
"""
Send SIGKILL to a subprocess process group when possible.
:param process: Subprocess handle to kill.
:returns: None.
"""
_proc.kill_tree(process)