Commit Graph

77 Commits

Author SHA1 Message Date
Harry Yao 72b4469d72 perf(host): open daemon readiness polls tight, then back off (#5190)
The host-online, runner-online and Claude-terminal-ready waits all polled
on a flat 0.5s cadence. Those waits gate every native-harness launch and
usually resolve on the first probe or two — a warm host is already online,
a fresh runner connects in a second or two — so a flat cadence spends up
to a full interval doing nothing after the thing is already ready.

Replace the fixed sleep with `daemon_poll_intervals()`: open at 0.1s, grow
geometrically, hold at the existing 0.5s for the long tail. Fast launches
notice readiness sooner without a long wait hammering the server.

`DAEMON_POLL_INTERVAL_S` keeps its name and value as the steady-state cap,
so `connect.py`'s runner-exit watcher still matches it; its comment now
notes the client's opening probes are tighter.

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-22 09:21:41 +00:00
Edwin He 4b6779febb feat(auth): refreshable credential for unattended host daemons (#4743)
* feat(auth): refreshable credential for unattended host daemons

Implements login-issued refresh grants so unattended hosts can renew
their session tokens instead of crashing when the initial JWT expires.

- Server: OIDC callback persists refresh material and issues a login-scoped
  renewal grant (30-day TTL, reuses device-grant store/rotation machinery)
- CLI: load_token() calls refresh_stored_token() on expiry, mints a fresh
  session JWT from the grant via POST /oauth/token
- Host: treats post-connection 401/403 as retryable-with-reauth (attempts
  token refresh before failing); improves error text for expired tokens
- Auth: login grants (no scope) bypass the delegated-token allowlist,
  keeping full authority; delegated tokens stay scope-restricted
- Tests: new coverage for refresh cycles, OIDC mode token router, env
  override of grant lifetime

Fixes OMNI-1127 / closes #1953.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(auth): address review findings for refreshable host credential

Fixes P0-P3 security and robustness issues discovered in review:

P0 (FEATURE-BREAKING): create_redeemed_grant called self._session() with
no query_name, causing TypeError and breaking the entire login-grant
feature. Now uses "insert_redeemed_device_grant" per CLAUDE.md conventions.

P1 (SECURITY): LoginRequest.issue_refresh was a client-controllable bool,
allowing XSS/form-hijack to obtain 30-day unattended credentials via
browser login. Removed the field entirely; browser /auth/login now NEVER
issues refresh material — only CLI/device flows do (server-side enforcement).

P2.1 (ROBUSTNESS): _check_cookie dropped isinstance(grant_id, str) guard,
allowing malformed grant_id claims to reach _grant_revoked(). Restored guard.

P2.2 (ROBUSTNESS): _store_entry assumed token file was dict but _load_entry
guards with isinstance(data, dict). Mirror the guard on write to prevent
TypeError on corrupt files. Treat non-dict as empty (fail-safe).

P3.1 (CLEANUP): load_token's expiry warning said "attempting automatic
refresh" but load_token never refreshes. Reworded to reflect actual behavior.

P3.2 (CLEANUP): _make_client_secret_gate was built twice (once in
create_device_auth_router, again in included create_oauth_token_router),
duplicating env reads and logs. Pass gate as parameter to avoid rebuild.

P3.3 (CLEANUP): _grant_max_lifetime_seconds() re-parsed os.environ on every
refresh/purge. Now called once at router mount, captured in closure.

Added regression tests:
- test_redeemed_grant_persistence_regression: grant row must be created
  (catches P0 TypeError)
- test_browser_login_never_issues_refresh_token: browser login must NOT
  return refresh_token (catches P1 client-controllable flow)

Follow-up (test reconciliation + lint, from running the suite):
- Re-point the login-grant round-trip and session-authority tests to mint
  via issue_login_grant (the CLI/device path) now that browser /auth/login
  no longer returns refresh material.
- Fix pre-existing runner-entry test lag: load_token mocks now accept the
  min_remaining_seconds kwarg the factory passes, and drop a stray
  OMNIGENT_RUNNER_DELEGATED_AUTH that contradicted a test's documented
  no-delegation scenario.
- Fix a latent NameError: _grant_max_lifetime was referenced in
  create_device_auth_router but only bound in create_oauth_token_router;
  resolve it once at mount in the device router too.
- Remove the now-dead device_grant_store wiring from the accounts auth
  router (its only use was the removed issue_refresh path).

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(host): escalate to a re-auth prompt on sustained post-connect auth rejection

A host rejected with 401/403 after it has already connected retries forever, so a transient VPN or proxy drop self-heals. Until now it only logged "check your VPN/network", so a permanently-rejected credential (a revoked or expired grant) looped silently and never told the operator to re-authenticate. After a sustained streak it now escalates with a louder warning plus a stderr line naming the omnigent login command, re-emitted periodically. It stays retryable and never fatal, so a recoverable daemon is not killed.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* test(cli): use one import style in the refresh test

Drop the mixed import omnigent.cli_auth + from-import inside test_refresh_survives_unwritable_state_dir; call store_token/refresh_stored_token via the ca alias. Resolves the code-quality bot finding on the PR.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(cli): drop dead accounts issue_refresh and cover the refresh-factory wiring

Accounts /auth/login issues no refresh material (only the OIDC CLI-ticket flow does), so the accounts-login POST no longer sends the ignored issue_refresh field, and its misleading "older servers ignore it" comment is removed. Updates the CLI accounts-login test that asserted the field.

Also adds a runner-entry test proving the load->refresh->fallback auth-token factory returns the refreshed token when the stored OIDC token has lapsed but a refresh grant is present — the integration that actually keeps an unattended host alive, previously only unit-tested at the refresh function itself.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* fix(server): mount login-grant token router only for UnifiedAuthProvider

The elif that mounts /oauth/token for login grants when the device flow is off only checked for a grant store, leaving auth_provider typed as the base AuthProvider (pyrefly bad-argument-type at the create_oauth_token_router call) and — for a non-Unified custom provider with a store — a latent runtime failure in _resolve_signing_config. Guard the branch on isinstance(auth_provider, UnifiedAuthProvider), matching the sibling device-flow branch.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

* test(e2e): accept min_remaining_seconds in managed-runner load_token mocks

The auth-token factory now calls load_token(url, min_remaining_seconds=...); two managed-runner e2e tests monkeypatched load_token with a lambda that rejected the kwarg, raising TypeError. Accept **_kw, matching the runner-entry unit mocks.

Co-authored-by: Isaac
Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>

---------

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-20 19:14:41 -07:00
Lee moon soo 5689ef33ee perf(host): shorten tunnel recovery (#4981)
* perf(host): shorten tunnel recovery

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>

* Preserve loopback reconnect tolerance

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>

* Trigger CI retry

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>

---------

Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>
2026-08-20 23:31:17 +00:00
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
Dhruv Gupta 741f2d29e4 fix(host,runner): reconnect host + all sessions promptly on laptop wake (#5054)
On macOS sleep the OS freezes every Omnigent process and drops the network,
killing the host control-channel WebSocket and every runner (session) tunnel.
On wake nothing reconnected promptly: the only liveness signal was the
websockets keepalive ping (30s interval / 90s timeout), so a half-open
post-sleep socket took up to ~120s to be noticed — and the server had already
deregistered the host, so the desktop app showed "disconnected" that whole
time while the terminal (a one-time startup banner) still read "connected".

Add omnigent/suspend_watch.py: watch_for_resume() detects a resume by polling
a short interval and comparing wall-clock vs monotonic-clock drift. The
monotonic clock freezes during sleep on macOS/Linux while the realtime clock
keeps counting, so a resume shows up as a large divergence; a merely-blocked
event loop advances both equally, so this never false-fires on CPU stalls.
Uses time.monotonic (never loop.time, which under uvloop includes sleep on
macOS and would zero out the divergence).

Wire it into both reconnect loops:
- Host (connect.py): a watcher aborts the live tunnel (ws.transport.abort())
  on wake and flags a prompt reconnect, so run() reattaches at the base
  backoff instead of the escalated one (required on a loopback server, where
  an abrupt close is not auto-classified as a benign recycle).
- Runner (serve.py): a per-connection watcher aborts the tunnel and notes the
  resume so serve_tunnel reconnects promptly; each session self-heals on its
  own event loop, so no host orchestration is needed.

Result: opening the laptop reconnects the host and all its sessions within
~5s instead of up to ~2 minutes. Windows degrades to todays keepalive
behavior (its monotonic clock counts suspend) with no regression.

Tests: unit tests for the detector (fires once on divergence, never on a
blocked loop, survives a raising callback) plus host and runner integration
tests (a simulated wake aborts the live tunnel and forces a prompt reconnect).

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-08-19 14:04:09 -07:00
Corey Zumar 05eaa253d1 fix(host): retry Databricks auth refresh (#5014)
Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-19 10:12:39 -07:00
Zeyi (Rice) Fan 03c7907966 fix(host): keep capability probes out of tunnel handshake (#4769)
## Related issue

Closes [OMNI-2964](https://linear.app/omnigent/issue/OMNI-2964/fix-host-tunnel-connection-issue-when-it-fails-to-detect-hanress)

## Summary

- Move harness and gateway capability discovery out of reconnect handshakes, bound startup discovery, and degrade probe failures to visible warnings with unknown metadata.
- Add a backward-compatible `host.connection_error` frame so accepted tunnels can surface server-side setup failures with their stage and retryability.
- Make background startup wait for the existing server-side host status before reporting success and retain reconnect regression coverage.

ELI5: checking which agent CLIs are installed is optional setup information. A broken CLI should not prevent the host from introducing itself to the server, so the host now connects with that information marked unknown and refreshes it later.

```text
host startup ── capability probe ──┬─ success → cached metadata
                                  └─ failure/timeout → warning + unknown
                                                    │
                                                    ▼
WebSocket upgrade → host.hello → connected receive loop
                                      ▲
server setup failure → host.connection_error
```

## Test Plan

- `uv run pytest tests/host/test_frames.py tests/server/integration/test_host_tunnel_route.py tests/host/test_connect.py tests/host/test_cli_host.py -q`
- `uv run pytest tests/host/test_connect.py::test_silent_connect_streak_escalates_and_slows_reconnects tests/host/test_connect.py::test_inbound_frame_resets_silent_connect_streak -q`
- `uv run ruff check` on all changed Python and test files.
- `uv run pyrefly check omnigent/host/connect.py omnigent/host/frames.py omnigent/server/routes/host_tunnel.py omnigent/cli.py`

## Demo

N/A — backend/CLI reliability change with no visual UI.

## Type of change

- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [x] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Automated coverage exercises capability exceptions and timeouts, server error propagation, background registration checks, retryability, and silent reconnect backoff.

## Changelog

`omnigent host` now stays connected when optional harness detection fails and surfaces server-side tunnel setup errors.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-18 17:18:32 -07:00
Tomu Hirata adcf83ccb6 feat(pi): Add searchable model picker for new sessions with Databricks Unity AI Gateway OAuth (#4961)
* feat(pi): add searchable start model picker

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

* fix(pi): harden model picker compatibility

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

* refactor(pi): simplify model picker filtering

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>

---------

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-08-19 00:02:35 +00:00
Tomu Hirata c2439c6fd7 fix(windows): pass Windows process essentials through harness env filters (#4886)
On native Windows, `agent_env.BASE_ALLOW_EXACT` (the shared deny-by-default
env filter used by all harness executors) did not include SYSTEMROOT, COMSPEC,
USERPROFILE, or the other Windows-mandatory constants. Any harness CLI spawned
via `clean_agent_env` (codex, pi, claude-sdk, antigravity, …) died instantly
on spawn because Winsock/crypto cannot initialise without SYSTEMROOT — the
subprocess exited before reading stdin, causing the executor to await a
JSON-RPC response that never arrived and silently idle to the 600s watchdog.

The constant set already existed as `WINDOWS_ENV_PASSTHROUGH` in `_platform.py`
and was already wired into `os_env._DEFAULT_ENV_PASSTHROUGH` and
`connect._RUNNER_ENV_ALLOWLIST`. This commit adds it to `BASE_ALLOW_EXACT` so
every harness executor inherits it automatically, matching the pattern used
elsewhere.

Also fixes three related Windows issues surfaced in omnigent-ai/omnigent#4851:

- `PYTHONUTF8` was not forwarded through `_RUNNER_ENV_ALLOWLIST`, so the host
  daemon / runner subprocess printed Unicode status chars (✓ ↑) on the Windows
  ANSI code page (cp1252), raising `UnicodeEncodeError` and killing the host
  tunnel in an infinite reconnect loop.

- `_session_create_validation.validate_existing_host_workspace` and
  `_workspace_validation.validate_workspace` required `workspace.startswith("/")`,
  rejecting every Windows drive-letter path (C:\…) from a connected Windows host.
  Windows absolute paths matching `^[A-Za-z]:[/\\]` are now accepted.

- `harness_install._harness_cli_version_satisfies` returned `False` on
  `packaging.version.InvalidVersion`, so pre-release versions like
  `0.146.0-alpha.9.2` (newer than the declared floor) were reported as
  too-old and the harness was refused at the version gate. The fix extracts
  the leading X.Y.Z segment as a fallback for non-PEP-440 strings.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-17 01:23:12 +00:00
Corey Zumar f75c07ba56 perf(host): cut host-launched session-create latency (#4752)
Creating a claude-native session from the web UI paid three serial,
avoidable costs between the create POST and the terminal appearing:

- The host tunnel handled inbound frames strictly serially, so every
  create's launch frame queued behind that create's own background
  host.model_options CLI exec (650-794ms measured), and workspace
  validation's host.stat (2-9ms uncontended) queued behind landing-page
  prefetches for up to 1.3s. Frames now run on their own tasks;
  launch/stop keep arrival order via a lifecycle lock; a crashing
  handler is contained instead of tearing down the tunnel.

- Terminal auto-create resolved ambient provider credentials (a ~0.7s
  `claude auth status` subprocess on macOS) inside the user-visible
  "Starting up..." window. The host now stamps the session's harness
  into the runner env, and claude-native runners prewarm the detection
  at boot, overlapping it with tunnel connect; the resolve consumes it
  one-shot. Other harnesses pay nothing.

- The first launch of a daemon's life paid the runner zygote's one-time
  import (~1.5s) inline. run() now pre-starts the zygote at daemon boot
  via a helper shared with the launch path.

Same rig, pristine main vs this change: workspace validation
1508-5003ms -> 2-5ms; launch-frame queueing 1185-1712ms -> 8-17ms;
first-launch zygote import 1532ms -> 0ms; click->chat-page-open
1.6-2.0s -> 0.18-0.24s; click->"Starting up..." cleared 5.9-8.3s ->
3.6-4.9s.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-13 13:21:04 -07:00
Edwin He 9e5b8741d2 feat(cli): route host-scoped requests to the replica holding the host's tunnel (#4185)
Adds a per-host routing key (the ``X-Databricks-Omnigent-Slice-Key`` header)
so that, on a horizontally-scaled multi-tenant deployment, every request
scoped to a given host or session lands on the replica that holds that host's
runner tunnel: the host's control tunnel, its runners' tunnels, and all of a
session's turn/resource/stream traffic converge on one replica when they carry
the same key (the host_id). On an unsharded / single-replica deployment the key
is never emitted, so this is a no-op there.

Client-side only. The key is built centrally in
``cli_auth.databricks_request_headers`` (gated on the workspace-hosted mount)
and threaded through the one factory ``open_server_client`` plus
``_remote_headers`` / ``open_daemon_client``. Callers pass a host_id when they
have one; runner-side callers (forwarders, permission checks) inherit it
automatically from the ``OMNIGENT_RUNNER_SLICE_KEY`` env var the host stamps at
runner launch, so no per-callsite change is needed there. The WebSocket attach
handshake and its reconnects carry the same key.

``chat._remote_headers`` gains a ``host_id`` keyword (defaulting to ``None`` so
probes and health checks are unaffected). ``_DatabricksTokenAuth`` resolves the
session's host per request from the session→host map and can be repointed via
``pin_session`` when a client outlives its session (e.g. a ``--fork`` in the
REPL lands under a new conversation id on a new host). Session-host state is
always written on attach — clearing a stale mapping when the server reports no
host matters as much as setting one.

A ``tests/cli`` conftest fixture isolates the runner machine's own host
identity so "no slice key on this call" assertions are hermetic regardless of
whether the box running the suite is itself a host.

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-12 21:06:44 -07:00
Dhruv Gupta 59bedc1fac fix(host): keep the session workspace off the runner's sys.path (OMNI-2963) (#4688)
* fix(host): keep the session workspace off the runner's sys.path (OMNI-2963)

Opening a session inside an omnigent checkout ran a different omnigent than
the installed one. Runners are spawned with `python -m`, which prepends the
process cwd to sys.path, and since 3419de8d the runner's cwd is the session
workspace, so a workspace that is itself a checkout won over site-packages.
A long-lived daemon plus a mid-flight `git pull` then left the host and the
zygote on different code, surfacing as "runner fork request requires a cwd".

Spawn the runner, the zygote and the harness runner with -P so cwd never
lands on sys.path, and re-add the workspace in the runner entry once the real
omnigent is imported (and so can no longer be shadowed), keeping
spec-declared local tools importable by dotted path. Also pass -I to the
hermes MCP bridge, the only native bridge that was missing it.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>

* chore(tests): reword the shadowing docstrings

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>

* fix(tests): hand spawned harness children the project root via PYTHONPATH

Harnesses now spawn with -P, so a directly-exec'd harness no longer inherits
the repo root through its cwd. Tests that register a fixture harness module
(tests._fixtures.runner_test_harness) must pass that path in the environment,
which is what tests/runtime/harnesses/conftest.py already does; mirror that
fixture for tests/runner. Also update the hermes MCP-config assertion for the
added -I, matching the qwen bridge test.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>

* fix(cli): spawn the local runner with -P too

The CLI's own runner spawn inherits the CLI's cwd, so running omnigent from
inside a checkout shadowed the installed package exactly as the daemon path
did. Raised by review; the earlier audit missed it because this argv sits on
one line.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>

* fix(codex-native): pass -I to the codex serve-mcp bridge

codex_mcp_config_overrides built its own args list without -I, so the one
bridge codex launches stayed open to the workspace shadowing that every other
bridge already blocks. Raised by review, which also caught that the PR
description wrongly claimed codex already had it.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>

---------

Signed-off-by: Dhruv Gupta <dhruv0811@gmail.com>
2026-08-12 17:07:21 -07:00
Corey Zumar 5cd1f1c8cb fix(host): silent-endpoint backoff, slow-boot adoption, zygote respawn (#4563)
Four host-side fixes from a host-log forensics pass:
- An endpoint that accepts the WS upgrade but never sends a frame no
  longer spins on the 0.5s recycle cadence forever (observed: ~6s
  cycles for 7 hours, silently): past 10 consecutive accepted-but-
  silent connections the host logs one ERROR, notifies the terminal
  once, and drops to normal backoff until a frame arrives.
- ensure_local_omnigent_server no longer strands a slow-booting child:
  while the process is alive the readiness wait extends to a 120s boot
  ceiling (a ~39s first boot was observed failing the old 45s cutoff),
  and a final failure terminates and reaps the child before raising —
  previously it cleared the pidfile and left the server running,
  untracked.
- A runner zygote that died mid-life is reaped and respawned on the
  next launch instead of latching _zygote_disabled for the daemon's
  life; start failures and alive-but-broken channels still disable it.
- Self-allocated process logs that never received a record are swept
  at exit, and host shutdown awaits the reaper/watcher cancellations.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-10 19:14:03 -07:00
Harry Yao a2de2b44ac fix(host): keep CLAUDE_CODE_USE_GATEWAY / ENABLE_TOOL_SEARCH in the runner env allowlist (#4553)
A launcher (e.g. Databricks' isaac) sets CLAUDE_CODE_USE_GATEWAY=1 and
ENABLE_TOOL_SEARCH=true in its process env so the native-claude harness keeps
MCP tool search on (schemas load on demand). But the host daemon env
(`_build_host_daemon_env`) and the runner env (`_build_runner_env`) are both
built from `_RUNNER_ENV_ALLOWLIST`, and neither var was on it — so they were
stripped at daemon spawn and never reached the runner process.

The native-claude provider path (`_provider_config_for_native_claude`,
`_ucode_config_for_profile`, `_bedrock_config_for_native_claude`) reads
CLAUDE_CODE_USE_GATEWAY from os.environ to decide whether to set
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1. With it stripped, the runner saw it
absent, re-added the disable flag, and Claude Code turned tool search off —
loading every MCP tool schema eagerly (~88k tokens for ~190 MCP tools at
startup instead of on demand).

Add both non-secret boolean flags to `_RUNNER_ENV_ALLOWLIST`, beside the
existing CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_SKIP_BEDROCK_AUTH flags (same
category). The single allowlist is consulted by both gates, so the vars now
survive daemon spawn and runner spawn and reach the guard.

Tests: assert both vars survive `_build_host_daemon_env` (local + remote) and
`_build_runner_env`. They fail before this change and pass after.

Co-authored-by: harry-yao_data <harry.yao@databricks.com>
2026-08-10 18:53:34 -07:00
Corey Zumar b1e775e1c5 fix(host): exit after sustained connection-refused against a loopback server (#4544)
A host whose loopback server died reconnected forever at the 10s
backoff cap — zombie 'omnigent host' processes looped for days against
dead local ports. Connection-refused on loopback means nothing listens
and no network path can recover, so after 30 consecutive refusals
(~5 minutes at the cap) the host now logs one clear ERROR and exits
through the same fail-loud path as permanent auth failures. Dual-stack
refusals (asyncio's combined 'Multiple exceptions' OSError or exception
groups) count only when every sub-error is refused; any successful
connect or non-refused error resets the streak. Remote server URLs are
unaffected and retry indefinitely so network outages recover.

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-10 14:46:32 -07:00
Dhruv Gupta 43762a9892 fix(host): forward SSH_AUTH_SOCK to runners and harness CLIs (#4377)
Every runner-spawned context lost the ssh-agent socket, so any agent doing
git-over-SSH or SSH-cert-authenticated tooling failed with "dial unix:
missing address" (often surfacing as a confusing 401 from the endpoint,
since such tools have no cached-token fallback).

Two independent gates dropped it:

- `_build_runner_env` filters the host env through `_RUNNER_ENV_ALLOWLIST`,
  which omitted SSH_AUTH_SOCK. This is also the list both host-daemon modes
  consult, so the one entry fixes the daemon hop too, including remote mode.
- `clean_agent_env` is the shared deny-by-default filter for every vendor
  CLI, and its safe base omitted it. Fixing the shared base covers all
  seven harnesses rather than only the one whose report surfaced this.

Classified as a path, not a bearer secret: it names a unix socket, and
reaching the agent behind it still requires the user's own ssh-agent to be
running and holding the key. Same footing as KUBECONFIG, already allowlisted.

An ACTIVE OS sandbox deliberately keeps excluding it: that boundary exists
to confine the agent, and signing with the user's keys is what it confines.
`os_env.py` previously justified its exclusion by calling the variable "a
credential surface masquerading as a path", which contradicts the
classification above; that rationale is rewritten to rest on the sandbox
boundary instead, so the codebase states one position.

Downstream paths needed no change: `sys_os_shell` (sandbox inactive) and
`sys_terminal_launch` both mirror the parent env, so they inherit the fix.

Codex's `shell_environment_policy.inherit` was reported as a third gate
requiring omnigent to force `inherit="all"`. It does not reproduce: on
codex-cli 0.144.3 the default already passes SSH_AUTH_SOCK through
(identical 72-var env), and only an explicit `inherit="core"` drops it.
Forcing `all` would override that deliberate user choice, so no override
is added.

Co-authored-by: Isaac
2026-08-07 20:52:32 +00:00
David O'Keeffe 3419de8da6 fix(host): run session runners in the workspace, not the daemon's cwd (#3974)
* fix(host): run session runners in the workspace, not the daemon's cwd

A host daemon started from a directory that later disappears (a temp
checkout, a removed worktree) passes that dead cwd to every runner it spawns.
Path.cwd() then raises FileNotFoundError inside the runner and native
sessions fail with "Native Pi terminal failed to start" — hit live while
verifying the pi-native gateway fix.

Spawn the runner with cwd=<session workspace>, which _build_runner_env
already documents as the runner's cwd and which is verified to exist just
above the spawn.

Signed-off-by: David O'Keeffe <david.okeeffe@databricks.com>

* chore: retrigger CI (flaky integration test)

Signed-off-by: David O'Keeffe <david.okeeffe@databricks.com>

* fix(host): require an explicit runner workspace on the zygote fork path

fork_runner defaulted workspace to os.getcwd() — the daemon's cwd, the
exact value the workspace fix exists to avoid. The forked child was
already strict (it raises when the request carries no cwd), so the
manager was the only lenient link: a call site that omitted the argument
silently resurrected the deleted-cwd crash instead of failing loudly.

Make the parameter required so both ends agree, and cover the zygote
fork path's cwd, which had no test — only the direct Popen path did.

---------

Signed-off-by: David O'Keeffe <david.okeeffe@databricks.com>
Co-authored-by: dbczumar <corey.zumar@databricks.com>
2026-08-07 10:23:15 -07:00
Corey Zumar b624d47ef8 fix(runner): name the runner log file in "see runner logs" errors (#4295)
* fix(host): keep the tunnel receive loop responsive during readiness refresh

The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.

Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.

Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.

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

* test: accept the readiness probe timeout kwarg in harness CLI stubs

Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.

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

* test(host): cover off-loop readiness refresh and bounded CLI probe

Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.

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

* fix(runner): name the runner log file in "see runner logs" errors

`omnigent codex` (and its siblings) surface the runner's message verbatim, so
a failed native terminal start read:

    Codex terminal ensure failed (500): Native Codex terminal failed to start;
    see runner logs for details.

which left the user hunting for a file whose name they could not know. The
runner already knows its own log path — the host passes it as
OMNIGENT_PROCESS_LOG_FILE when it spawns the subprocess — so name it:

    ... failed to start; see the runner log for details:
    ~/.omnigent/logs/runner/runner-<session>-<timestamp>.log

Same treatment for the generic runner detail string (_client_safe_error_detail,
~40 call sites: harness spawn, spec resolve, model change, compact, MCP
dispatch). The client-safe contract is unchanged: the raw cause still goes to
the log only, and the path is home-relative so it points somewhere without
leaking the account name.

process_logging grows current_process_log_path() / process_log_reference() to
publish the path, and display_log_path() is promoted out of host/connect.py
(it was private there) so both sides format paths the same way. The
daemon_launch "runner did not connect" message stops hardcoding
~/.omnigent/logs/runner/ and computes the real dir, so it is correct under
OMNIGENT_DATA_DIR.

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

* test(runner): pin the runner log path instead of trusting test order

The three tests asserting the new "see the runner log for details: <path>"
messages set OMNIGENT_PROCESS_LOG_FILE and expected the message to name it.
That holds only until some earlier test in the same xdist worker runs the real
configure_process_logging: test_runner_entry's
test_main_preserves_unexpected_runtime_errors calls main() without stubbing it,
which allocates ~/.omnigent/logs/runner/runner-<timestamp>.log and publishes
that path process-wide. The published path outranks the environment (it is what
the process actually logs to), so the assertions saw the leaked path and the
runner-app group failed in CI while passing when run alone.

Pin both sources in one place: a pinned_runner_log fixture in
tests/runner/conftest.py sets the published path and the env var, so the
assertions hold whatever else the worker ran first.

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

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-06 18:19:51 -07:00
Bryan Qiu b268130340 Smart Routing MVP: per-task model and harness routing (#4074)
* feat(telemetry): routing decision and setting-change events

Routing needs to be answerable after the fact: which arm the router
picked, whether it was applied, and what the user changed. Adds
``RoutingDecisionEvent`` and ``RoutingSettingChangedEvent`` plus a
``model_labels`` helper that reduces a model id to a family/tier pair, so
records stay useful without carrying raw model ids.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(sessions): persist routing decisions and session warnings

A routing decision has to survive the turn that produced it, so the UI
can show what the router chose and — crucially — whether it was actually
applied. Adds ``RoutingDecisionData`` to the conversation entity with
store support, and a ``session_warnings`` module for the non-fatal
routing conditions a session needs to surface (router unreachable,
verdict not applied) without failing the turn.

Records are honest by construction: a decision that could not be applied
is stored with ``applied=false`` and its reason rather than being
dropped or reported as a success.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(routing): session-start smart routing core

Adds the server-side routing core behind Smart Routing: an external
``task_v1`` route-options seam that offers the router the frozen arm menu
its scenario requires, maps a pick back onto a servable catalog id via
nearest-cost substitution, and derives the harness that can actually run
it. Routing settings become one value object on ``RuntimeCaps`` so every
consumer reads the same knobs instead of re-parsing config. Databricks
model discovery resolves catalog spellings deterministically so the same
endpoint is named the same way on every path.

Reconciled against main's catalog-driven routing:

- Main's ``_fetch_runner_catalog`` / ``_RunnerModel`` plumbing and its
  cost-tier ordering are the single source of live model availability;
  ``fetch_runner_models`` remains the id-only adapter over it.
- Main's ``ModelIntent``-parameterized judge rubric replaces the
  family-specific tier hints.
- Main's catalog wire-API check survives as
  ``_redirect_wire_incompatible_pick``, layered after the static
  ``_HARNESS_EXCLUDED_MODELS`` bar list. The two cover different things:
  the catalog knows what an endpoint advertises, the bar list knows the
  client-side rejections it does not.
- ``model_family_token`` defers to ``is_codex_compatible_model`` so the
  GLM/Kimi delegate arms read as the codex family everywhere.

The static ``MODEL_LISTS`` table is retained, unlike main, because the
nearest-cost substitution needs a family cost ordering on paths with no
catalog in reach (hook scripts, pre-session creates).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(server): route sessions at start and expose the decision

Wires the routing core into session lifecycle. A session created in
Smart Routing mode is routed once, at start, from the first user message:
the verdict picks the harness and the model before the runner launches,
and pre-launch host model options supply the candidate catalog when no
runner exists yet. Later turns never re-route — a session's harness is
settled once so a conversation cannot change identity underneath the
user.

The decision is exposed on the session snapshot and event stream with
its applied state, so the UI can distinguish "the router picked X and we
are running X" from "the router picked X and we could not apply it",
rather than silently showing the request as the outcome.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(claude): apply a routed model to Claude Code

A routed arm only matters if the harness actually runs it. Adds a Claude
model vocabulary that maps between router arm ids, catalog spellings, and
the ``/model`` names Claude Code accepts, and pins the CLI's family
aliases to the frozen task_v1 Claude arms at launch so the first turn's
switch can reach whatever the router picked.

The vocabulary reads its catalog prefixes from one definition shared with
the server seam, so the hook path — which cannot read server config —
cannot drift from it.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(codex): apply a routed model to Codex

The Codex side of the apply layer: the native app server and executor
accept a routed model override and enforce it on the session they launch,
so a verdict that names a GLM/Kimi delegate arm reaches the CLI instead
of being dropped for the harness default.

Codex spawns with no routable signal skip the router outright rather
than routing on an empty prompt and recording a decision nobody asked
for.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(routing): route sub-agent spawns from harness hooks

Sub-agents spawned by a native CLI never pass through the server's
session-create path, so they were unroutable. Adds hook scripts the
Claude and Codex CLIs invoke at spawn time, plus a runner-side router
that answers them, so a spawned child is routed on its own task text and
launched on the chosen model.

A child is only ever offered its parent's harness family: routing may
change which model a sub-agent runs, never which vendor it belongs to.
Hook commands run under ``python -I`` so a repo-local module on the CLI's
cwd cannot shadow the interpreter's own imports.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(web): surface routing decisions and Smart Routing controls

Adds the Smart Routing harness option to new-chat, a routing chip that
shows the routed model on the session, a sub-agent routing row, and a
warning banner for the non-fatal routing conditions the server reports.

The chip reports what actually happened. When a decision could not be
applied it says so and names the model in use, instead of showing the
router's request as though it were the outcome.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test(routing): cover the routing apply layer end to end

Adds the remaining routing coverage: the CLI's routing-client build, the
native Smart Routing create path, an end-to-end routing integration test,
and the discovery/override unit tests. Also updates the existing native
bridge, forwarder, and launch-arg tests for the model-override plumbing.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs(routing): record the routing design and verification state

Captures the plan the implementation followed, the per-CUJ verification
status, and the observed live-model state the harness bar list is derived
from — the gateway rejections that catalog metadata does not advertise.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: registry stamps — rebased-tree battery green, session-start verified live

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: re-sync CUJ walkthrough with the rebased tree

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(routing): offer Smart Routing only where the apply layer can work

Smart Routing rewrites a launch's model through the Databricks AI Gateway,
so a host whose claude-native or codex inference resolves anywhere else
(Bedrock, a plain API key, the vendor CLI's own login) got an option that
could never take effect. Gate each surface on the fact that decides it.

The host already resolves this at launch, so reuse those resolutions as a
cheap config-only check — no process launch, no network — and report a
`gateway_inference` map alongside `configured_harnesses` on registration
and every readiness refresh. It rides the host frames into the store and
out through GET /v1/hosts. A host that never reports it sends `null`, and
`null` means unknown: nothing is gated away on older host builds.

Web gates the three surfaces independently, classified in the single
`smartRoutingAvailability` point as a new `not-gateway-backed` cause:
Configure Claude Code's Model row needs the claude family, Configure
Codex's needs the codex family, and the top-level Smart Routing harness
row needs both (it drives the five-arm menu).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs(routing): record the gateway-backed availability decision

Plan §10 gains decision 9 (Smart Routing offered only where the apply
layer can work, with the per-surface rule and the absent-means-unknown
compatibility contract), and §8 gains the two follow-ups it defers: a
liveness probe, and moving the routes:select call host-side so routing
auth/workspace always matches the host's inference.

CUJ_STATUS gains recipe R9 (point a host at a non-AIGW config and assert
the option disappears) plus one pending check row per gated surface.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: rewrite the CUJ walkthrough in simplified technical English

Rewrite designs/CUJ_IMPLEMENTATION.md in ASD-STE100-inspired Simplified
Technical English so every sentence parses one way only: active voice with a
named actor, simple tenses, one statement per sentence, noun clusters of at
most three words, and lists for any sequence of three or more steps. Add a
six-term glossary (arm, seam, pane, rollout, canary, spelling) to the intro.
Remove the hard 80-column wrapping so each paragraph is one soft-wrapped line.

No facts change: every sha citation and every file:line reference is
byte-identical to bc4b6c0.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: stamp the gateway-inference positive half

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: keep the routing design docs local-only

The four routing design documents (plan, test registry, CUJ walkthrough,
live model state) stay on disk for local reference but leave version
control — they are working notes, not reviewable deliverables.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): serve turn routing the launch-exact claude vocabulary

Two claude-path defects from the live verification round.

Turn-1 routing on a claude-native pane could substitute the routed arm.
`_native_turn_catalog` read `_model_options_cache` without consulting
`_model_options_stale`, so a catalog hydrated from the session's *host*
before launch (whose family aliases carry the workspace default) became
the offered vocabulary. With the launch pinning `opus ->
databricks-claude-opus-4-8` and turn 1 routing ~100ms later, the pinned
arm had no spelling on offer and the router substituted sonnet. Turn
routing now awaits a refetch from the bound runner's
`claude-model-options` endpoint — which reports the launch-pinned
aliases — whenever the cached entry is stale, and falls back to the
stale catalog when no runner can answer.

Every claude-native turn also 400'd with `invalid beta flag`: the ucode
gateway launch env never set `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`,
and Claude Code 2.1.220 sends three flags the Databricks gateway
rejects (`prompt-caching-scope-2026-01-05`, `advisor-tool-2026-03-01`
and, under `ENABLE_TOOL_SEARCH`, `advanced-tool-use-2025-11-20`), which
fails the whole request. Set the knob on that path too.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): no substitution arrow for prefix-only subagent raw picks

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): float the session warning banner over the chat

The session warning strip rendered in-flow between the chat header and
<main>, so a warning arriving mid-session pushed the whole conversation
down. Render it as an overlay instead, on the same positioning contract
as the chat header: anchored inside the chat column, below the header,
stopping short of the workspace panel via --workspace-panel-offset, and
transparent to pointer events outside its own rows so the chat stays
scrollable. Multiple warnings stack downward inside the overlay.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): gate the codex canary check on a real turn, clear it per launch

`subagent_routing_unenforced` was posted on codex-native sessions whose
routing hooks were in fact trusted and running. Codex dispatches
`SessionStart` (the canary) when a thread's *first turn* begins, but the
enforcement watcher's first-turn gate was released by any
`thread/status/changed → active` or `item/*` event — and the MCP startup
round activates the thread and emits items without running a turn. So a
session that had not been asked anything yet (or whose first turn was
interrupted before it started) failed the canary check 30s later. Live
evidence (session e6074fb1...): thread activated by the MCP startup round
at 13:58:06, warning posted at 13:58:36, and the canary file for that same
session/app-server finally appeared at 14:01:36 when a real turn ran —
proving the hooks were trusted and effective. The stale warning stuck only
because the runner was stopped before the repair tick.

Direct probes against `codex app-server` (isolated CODEX_HOME) also
disprove the "codex captures hook trust at process start" theory: trust
written after the spawn (the shipped ordering) takes effect, even for a
turn already in flight when `config/batchWrite` lands. The real invariant
is that trust must land before the first *turn*, which `start()` already
guarantees — now written down where it can be broken.

Second fix: the canary is the proof that *this* launch's hooks ran, so
`clear_bridge_state` now drops it. The per-workspace bridge dir is reused
across launches, and a canary left by an earlier launch masked a genuine
fail-open for the rest of the session. Transition-only posting still
clears a previous launch's warning on the new forwarder's first check.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): clear the codex spawn audit per launch too

Same staleness class as the canary (51e36c8c): the audit is reconciled
against the routing decisions *this* launch's endpoint relayed, so a line
left by a previous launch — whose approving decision lives in that
launch's router — reads as a spawn the router never approved. The
per-workspace bridge dir is reused across launches, so `clear_bridge_state`
now drops the audit alongside the canary.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(routing): apply the glm arm under the gateway's model route

The task_v1 codex arm `glm-5-2` resolved to the catalog's
`databricks-glm-5-2`, which the codex turn then failed to serve: that
serving endpoint advertises chat-completions only and 400s on
`/codex/v1`. Probes on staging and prod (2026-08-01) show the Responses
API does serve GLM — but only under the gateway model route
`system.ai.glm-5-2`. GLM appears in no discovery listing, so the working
name can only be pinned, not discovered.

Add a per-model servable-alias map next to the arm tables and consult it
when an arm resolves to a servable id, so the codex apply layer writes
`system.ai.glm-5-2`. Subagent candidates are offered under the same
spelling, so a rewrite spawns with the id routing resolves to. The
router's arm id stays `glm-5-2`, and the alias strips to the same bare id
so decision records show no substitution.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: track the routing design docs again

Re-adds the plan (with the decision log), the test registry, the
enumerated CUJ walkthrough, and the codex model-state notes, all
current as of the post-verification state.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(routing): route the model at create time for a fixed native harness

A native terminal launches with the session row and its turns originate in
the TUI, so the server never sees the first message pre-inference — the turn
gate that routes a plain claude/codex session never fires for a CLI-driven
one. Create-time routing existed only on the `harness_override: "auto"` path,
which picks harness AND model.

A create that carries `cost_control_mode_override: "on"`, a non-empty
`smart_routing_message`, and a FIXED native harness (claude-native /
codex-native, via the wrapper agent, `harness_override`, or the spec) now
routes its MODEL during the create: candidates come from the host's
pre-launch catalog for that one harness, the pick is constrained to it, and
the routed id is persisted as `model_override` with the routing-decision
label plus a session-scoped decision record. Fails open — an unconfigured
router, or a pick the harness cannot run, pins nothing and records the
reason, so the session still opens on the CLI's default model.

Session-start cadence is unchanged: the pinned model closes the per-turn gate
exactly as the auto path's create pin does. The branch is skipped for SDK
harnesses (which still route on their first turn), child and sub-agent
sessions, and a create that pinned its own model.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat(cli): route the model (and harness) before a native TUI launch

Smart Routing was web-only: a CLI user who wanted the server to pick a
model had to start the session in the browser. Add the two launch surfaces
Bryan asked for, both of which route *before* anything starts — the harness
pick is physical (a session is a live claude/codex process) and the model is
applied as a launch flag, so there is nothing to change after the fact.

- `omnigent claude|codex --smart-routing -p "<prompt>"` and
  `run --harness <native> --smart-routing -p ...` route the model and keep
  the requested harness.
- `omnigent run --smart-routing -p "<prompt>"` (no --harness, or
  `--harness auto`) routes harness *and* model, then launches that wrapper.

One session, routed at create: the CLI creates it through the standard JSON
`POST /v1/sessions` (bound to the host it will run on, whose model options
are the router's candidate catalog) and the wrapper ATTACHES to it instead
of bundling its own. The row the server writes already carries the agent
binding, the wrapper's presentation labels, the routed model and the
decision card, so a routed CLI launch gets the same chip and provenance the
web UI does. The resolved harness is read from `SessionResponse.harness`;
native rows leave `harness_override` null on purpose.

`--smart-routing` requires `-p`: routing needs text, and the degraded
route-on-turn-2 mode is not shipping, so an empty invocation is a usage
error pointing at `-p` or the web UI. It also rejects an AGENT, the
REPL-only flags, and `--resume`/`--continue` (routing is a create-time
decision, so a routed launch is always a new session). Preflight
(`smart_routing_enabled` plus the host's per-harness `gateway_inference`)
is a hard error naming the reason, because a routed model the pane cannot
reach is worse than no pick; the create itself always fails open — the
wrapper then starts a plain session behind one notice line.

`omnigent claude` also gains `-p`, and claude/codex now accept a prompt
through `run --harness <native> -p` instead of rejecting it. The prompt
travels as argv (Claude Code's positional prompt; Codex keeps its existing
first-turn delivery), so multi-line prompts survive intact.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(cli): resolve the claude agent name from harness_plugins on this branch

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: PR rewrite plan — cut list, commit series, CLI integration

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* chore: track the isolated dev-stack scripts the test registry references

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: cover the glm gateway-route fix

907f8886 pins the id the glm arm is applied under: the gateway serves GLM
on the Responses API only as the model route `system.ai.glm-5-2`, so the
catalog's `databricks-glm-5-2` row 400s every codex turn. Record the
mechanics in CUJ_IMPLEMENTATION.md §3.5h (with the §1.3 spelling note and
the residual "pinned, not discovered" open item), and close the C1 /
§2.8 blocker in CUJ_STATUS.md against the live session 80fb6d1f: config
mirror and every rollout turn context on system.ai.glm-5-2, zero
BAD_REQUEST, real generation. The only error left on that thread is a
gateway-capacity 429, which is load and not routing.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: cover the CLI smart-routing entry points

`omnigent claude|codex --smart-routing -p` (tier 2) and `omnigent run
--smart-routing -p` (tier 3) were undocumented. Record the fourth surface:
CUJ_IMPLEMENTATION.md gains §6 (commands and tiers, prompt delivery,
preflight, the create-time MODEL route for a fixed native harness, the
create the CLI drives, rejected combinations, the routed launch, decision
persistence, and the agent-name import fix), and known-open moves to §7.

CUJ_STATUS.md gains recipe R10 and §2.10 — unit rows stamped from the three
suites that pass at HEAD, every process-truth row  because no routed CLI
launch has run live yet.

PR_REWRITE_PLAN.md §2d/§5 corrected: both CLI halves have merged, and the
tier-2 server half is already its own commit, so the commit-3/commit-8 split
is mechanical. The CLI commit did not extend `_resolve_native_smart_routing`
— the fixed-harness route is a parallel path — but it does share the auto
path's lifted `_routing_host_for_create` helper, which the assembler must
keep.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: track the PR review fix list (rounds 1-2, all items addressed)

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: high-level routing system map for slimming iteration

Add designs/ROUTING_OVERVIEW.md: a one-altitude map of the Smart Routing
feature — the four user journeys, the fifteen subsystems with size and
rewrite fate, the invariants that must survive any cut, and the five open
decisions. Written in ASD-STE100 style with block IDs so the slimming
pass can cut and keep by reference.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: fold Bryan's critique decisions into the rewrite plan

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: fold the model-resolution rulings into the plans; STE pass on the rewrite plan

Bryan ruled on the three open resolution questions (2026-08-01): revert
the resolution machinery to main's shape (cut MODEL_LISTS, the cost
table, the allowlist), drop pi from the routed set for now (bar list
goes with it), and use one fixed fallback model per family (claude ->
sonnet, gpt -> terra) with an honest decline behind it. The rewrite
plan is now fully decided and rewritten in ASD-STE100 style; the
overview's subsystem fates, invariants, and decision records match.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: finish the STE pass, restructure 3i to the three rulings, pin the fallback-id assumptions

Reconciles the fold-agent's late completion (it amended 0baeea1c
locally; this lands the same tree as a follow-up commit instead of a
force-push). The whole plan now meets the STE caps, 3i lists Bryan's
three rulings as ruled (pi had been displaced by a mechanism bullet),
and the open-assumption list grows to three: glm declines with no
fallback; terra is today only a pi-exclusion entry, so the code must
add it as a servable target; sonnet pins to databricks-claude-sonnet-5.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: luna is the gpt+glm fallback, sonnet follows the alias pin; add verification criteria (6c-6e)

Bryan's final fallback rulings (2026-08-01): the gpt and glm families
both fall back to luna (databricks-gpt-5-6-luna, itself a frozen arm,
so a glm fallback never leaves the codex harness), and the claude
fallback is whatever the sonnet alias pin resolves to rather than a
hardcoded id. Terra is out; glm no longer declines. No open
assumptions remain in the plan.

New plan blocks 6c-6e state the verification criteria: the evidence
bars per layer, the registry recipe handles (R0-R10; R8 dies with the
enforcement cut), and the per-slice verification gates for the fleet.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: switch the plan to a from-scratch rewrite (7g)

Bryan chose a complete rewrite from scratch (2026-08-02) to keep the
new code as clean as possible, reversing the plan's earlier 'assemble,
do not re-implement' constraint.

The scope decisions all survive; the method and the safety net change.
New blocks: 0c names the three inputs an agent must read before it
writes a slice (the behavior inventory, the trap list, and the
reference implementation on routing-mvp-v1), 0d says to rewrite the
shape but transcribe the empirically-derived constants, 3l reframes
the cut list as 'do not build', 4e contains the integration risk that
moves to the end, 6f records that no evidence transfers, and 7g is the
decision itself. 3j becomes a ceiling rather than a subtraction, which
also retires its old arithmetic gap, and 5b turns the two CLI commits
into specifications rather than patches to apply.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: request-time managed flag, parallel wave plan, and four scope reversals

Bryan's review of the rewrite plan (2026-08-02) produced five changes.

The managed preview flag is evaluated per request, not at
construction, and it moves out of 2a into its own block 2f: flag off
routes through the naive LLM judge, flag on routes through the AI
Gateway, so a flag-off workspace degrades rather than loses the
feature. That also dissolves the managed-swap report's objection.

The glm gateway route is codex work, not CLI work, and the Smart
Routing harness inherits it because it runs codex underneath.

Cross-harness spawning is reinstated: harness agents get
sys_session_create instead of a deny message (3c, 7i). Telemetry
leaves the PR entirely for a follow-up Bryan owns (3e, 7j). The design
docs ride the branch for his reference and a final commit deletes them
before merge, so no docs PR exists (3a, 7j).

Execution is now three waves of five or six parallel workstreams on
one branch, preceded by a lead-authored wave-0 contract commit that
declares every shared signature and pre-creates every shared touch
point (4a, 4b, 4e, 6a, 6e, 7k). Size is a preference for
reviewability, not a target (3j).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: make the rewrite plan readable without session context

The plan hands off to a fresh fleet that has none of this session's
history, so the spec sections (0-6) now read as instructions rather
than as diffs against earlier drafts. Removed the negations of
assumptions a new reader never held (the glm route is "not CLI work",
managed readiness is "not 2a", 3c "reverses the earlier cut"), the
RESOLVED-with-date tags inside spec blocks, and references only this
session could resolve. Section 7 keeps the full decision record, which
is its job. Empirical findings survive the trim: the A-sub
deny-message result, the zero-live-triggers evidence, and the
authorization-order trap now cite the document that records them.

Wave design is now the lead's rather than a placeholder: a wave-0
contract commit, 7 foundation streams, 6 integration streams, and a
4-stream closure wave. The turn gate and the create paths move into
separate modules so they stop colliding in orchestration.py; web and
CLI move into wave 2 behind the wave-0 HTTP contract, which keeps the
two largest surfaces off the critical path. Barrier 1 gains a real
check (apply a hardcoded model to a claude pane and a codex session
with no router involved) and barrier 3 gains the flag-off backend row.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: clear the last session-only references from the plan

3g was still written as "rewrite, not transplant" against a suite the
fleet never sees, and it cited a commit's method rather than a rule.
It now states the rule directly: start from the behavior inventory in
CUJ_STATUS.md section 2, one test per behavior, coverage as the gate.
The reference suite is described as what not to copy and why.

Also replaced the two remaining "three review waves" references, which
name history a fresh reader cannot resolve, with "the reference
implementation".

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: close the cold-read audit's blockers on the rewrite plan

A subagent with no context from this session read the plan as an
executor would and found that its load-bearing inputs are unreachable
from the branch it tells you to start on. Confirmed and fixed.

Blockers:
- routing-mvp-v1 was an aspiration, not a branch. It now exists,
  pinned at f200a8bd, and 0c/1a cite the sha.
- None of the required-reading docs, and none of the R0/R6/R9/R10
  verification harness, exists on origin/main. Wave 0 now carries all
  twelve paths across, or every stream stops at its first instruction
  and both live barriers have no stack to run on.
- 2f never named the preview flag. It is managed-side
  (databricks.mas.omnigent.intelligentRouting, default off), so OSS
  gets a per-request predicate the deployment supplies, plus a
  default; stream 2 builds the seam, not a flag system.
- The migration had two owners. Wave 0 creates the empty revision and
  stream 4 fills it.
- The file partition existed only as a promise, and where implied it
  double-booked subagent_routing.py. New block 4f is the table, with
  named modules for the transport/policy and turn-gate/create-path
  splits, and cli.py declared lead-owned.

Also: new 2g records what main already ships (both routing clients and
the wire-compat redirect), which shrinks stream 2; wave 0 slims the
registry so waves 1-2 are gated on a true list; 6d had R5 and R6
transposed; 6e dropped row B3 and now names CUJ_STATUS as the row
authority; barrier-1's apply script has an owner; the UI acceptance
names Bryan, since no agent can close it; and the size figures in 1a
and 3h are re-measured (29,924/155, and web/src minus its lockfile).

One gap only Bryan can close, now flagged in 6e: INTELLIGENT_ROUTING_
PLAN.md section 11.1 does not embed the P-SOL prompt, and rows A3, B2,
C2 need it.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: add LOCAL_SETUP.md; drop the stray npm lockfile

R0 documented how to run the stack but not how to build it, and two
things stopped a fresh machine cold: .omnigent-local/config.yaml is
gitignored, so run-server.sh exits immediately with nothing explaining
what belongs in it, and run-frontend.sh hardcoded this machine's nvm
path. LOCAL_SETUP.md now covers prerequisites, uv sync + pnpm install,
the databricks profile the router needs, the config template (with the
two details that break things quietly: system.ai. keeps its trailing
dot, and router_name must be task_v1), bring-up, a health check, the
known local quirks, and teardown. R0 points at it and wave 0 carries
it across.

run-frontend.sh now resolves node from PATH, falling back to the newest
nvm install, and fails with a pointer if pnpm is missing.

Separately: web/package-lock.json was tracked again after the rebase.
The repo uses pnpm (pnpm-lock.yaml, packageManager pnpm@11.15.1) and
main has no npm lockfile, so this was 3,451 lines of generated
wrong-package-manager noise in the PR diff. Untracked, deleted, and
gitignored so it cannot come back.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the personal CLI setup and the provider topology

LOCAL_SETUP.md covered the repo, but a fresh clone still does not
reproduce the environment: the whole Claude Code and Codex setup lives
in $HOME. New section 9 carries it - the three personal ~/.claude
files, the model-serving proxy mode and its refresh hook, the Codex
Databricks provider block and the five personal hooks that Omnigent's
generated hooks.json must merge with, the two secrets that have to
move out of band, and the transfer order.

Section 9.5 records the provider topology, which is easy to misread:
the global config's default provider is a Claude subscription, its
AIGW provider (the /ai-gateway/anthropic route, which is the Gateway
despite the path) is not default, and the worktree config is a
separate staging workspace. Measured with omnigent.gateway_inference:
global reports False for both families, the worktree True for both.

That measurement surfaced a real defect, now recorded in plan block
3f: the codex check reads the base URL Omnigent resolves, so a
kind: cli-config provider (which defers to the user's own
~/.codex/config.toml) yields None and is reported as not-backed rather
than unknown. False hides the Smart Routing option; unknown does not.
The rewrite must read the delegated config or report unknown.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Trim routing PR: cut enforcement/telemetry/machinery, fix GLM effort + blank page

Wave-1 trim of the routing reference implementation, plus two live-caught
bug fixes and test trims from a parallel cleanup pass.

Cuts (per designs/PR_REWRITE_PLAN.md §3):
- Enforcement stack: canary, watcher, spawn-audit, warning banner,
  session_warnings (3b). Hook generation + trust handshake kept.
- Routing telemetry: telemetry/routing.py, model_labels.py (3e).
- Fork-spawn exemption from the hook script (3d).
- Model-resolution machinery in smart_routing.py: MODEL_LISTS cost-ladder
  (_cost_position, _ARM_SUBSTITUTES) replaced by a fixed per-family
  fallback (claude->sonnet, gpt/glm->luna) + honest decline (3i). The
  static infer_models catalog is kept: subagent_routing.py consumes it.

Fixes:
- GLM reasoning effort: GLM rejects xhigh; a routed GLM codex turn now
  clamps effort to medium at every config-write and thread-settings point
  (clamp_effort_for_model / effort_for_model_switch). Locked down in
  tests/test_reasoning_effort.py.
- Blank-page crash: chipPendingBeforeRegion indexed past a shortened block
  array on a stale cache (session switch / history reload), reading
  undefined.type and unmounting ChatPage. Guarded + regression-tested.

Tests trimmed to the surviving surface; suites collect clean (2277) and
the core routing sets pass (266).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Remove unused `act` import left by the warning-banner test cut

The enforcement/banner cut removed the AppShell test cases that used
`act`, but left the import — oxlint (a pre-commit + CI gate) fails on it.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Substitute an unservable arm within its model tier before the family fallback

task_v1's frozen arms name a model *tier* (claude-opus-4-8 is the opus tier,
gpt-5-6-sol the sol tier), not a specific servable id. When the workspace
serves a different model of the same tier — claude-opus-5 for a
claude-opus-4-8 pick — that model is the arm the router meant, so
substitute_model now applies it (highest version within the tier) ahead of the
family fallback. Only when no same-tier model is servable does it fall to the
per-family fallback, then decline. Still no cost walk: an unservable pick never
slides down to a cheaper tier.

Adds _model_tier (the id's last alphabetic segment, None for a bare generation
id like gpt-5-5) and _version_key (numeric version, higher = newer) to rank
within a tier.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Route unnamed codex subagent spawns on a placeholder instead of inheriting

Codex encrypts the spawn message, so an unnamed codex spawn carries no prompt
to route on. It previously fell through to allow-on-the-parent-model ("No
routable signal … inherits the session model"). Route it on a fixed
"Codex subagent task" placeholder instead, so it lands on the router's floor
arm rather than the parent's possibly-expensive model — matching ucode PR 251's
default_task_label. Precedence is unchanged: a real prompt (claude) wins, then
task_name/agent_name, then the placeholder.

Tradeoff, recorded honestly: every unnamed spawn scores the same placeholder
and so gets the same floor arm — a cheap sensible default, not per-spawn
routing. A named spawn still routes on its task_name; empirically that field
has been null on every observed codex spawn, so the placeholder is the whole
fix in practice. Per-prompt codex subagent routing is not reachable while the
message is encrypted.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: design plan for in-harness first-message routing (follow-up)

Route the main agent's model on the FIRST real user message via a
UserPromptSubmit hook + loopback callback (the route-subagent pattern),
so a bare `omni codex` / `omni claude` launch still routes, and web UI
and TUI share one mechanism. Marker = conv.model_override (authoritative,
existing cadence semantics) + a bridge-dir fast-skip file. Apply reuses
the verified composer forward path: thread/settings/update-then-turn/start
for codex, locked /model-injection-then-send-keys for claude
(block-and-replay). Cross-harness selection stays outside; create-time
routing stays for prompt-ful launches and composes via the marker.

Grounded in LIVE_MODEL_STATE.md probes and the official Claude Code hook
docs (block erases the prompt and injected input then proceeds; no hook
output can change the model; 30s synchronous timeout). Four spikes
ordered before any product code. Not part of the trim PR.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the conservative ruling on in-harness routing

Bryan's decision (2026-08-03): keep both paths. The server/create-time
path is the UI path and stays as the primary; the in-harness hook is
additive, covering only what the server cannot see (a prompt typed into
the TUI on a bare launch). One decision seam, three triggers, arbitrated
by model_override so exactly one fires per session. The outside path also
stays because it shares route_session_harness with cross-harness
selection - it is the cross-harness code, not a parallel implementation.

The maximal collapse (hook as sole trigger, CLI tier-2 entry machinery
deleted) is recorded as a deferred phase gated on determinism evidence
from the spikes plus live use, requiring an explicit go.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* Point a routed spawn at a tool the session actually has, and say why

The redirect told the model to "Use sys_session_send with args.harness=,
args.model=" — parameters that do not exist on the tool it holds. Those are
sys_session_send's named-spawn mode, which ToolManager only advertises for a
spec with declared sub-agents; the native harnesses declare none, so their
send tool exposes only {args, session_id} and the instruction was
unfollowable. Matrix row A-sub recorded the result: the model read the deny
and abandoned the spawn.

Name sys_session_create instead, which a spawn:True harness does hold (both
claude-native and codex-native set it) and whose schema really does take
model, message, and agent_id. Lead with the user's own choice to enable Smart
Routing and state that the sub-task is approved, so the deny reads as an
authorized re-route rather than a refusal, and close with the concrete call to
make. The same instruction now backs the deny branch when the verdict names a
model, instead of a bare "Spawn denied by Omnigent smart routing."

The redirect tests assert the properties that matter — denies, names the
routed model, names sys_session_create, never names sys_session_send — rather
than pinning the prose.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: register the bundle-agent and GLM-subagent CUJs (2.11, 2.12)

Two new surfaces enter the registry per Bryan. 2.11: Smart Routing on
bundle agents (debby/polly) reaches routing only through the gear
config's brain-harness override - a different code path from the native
Model row, previously untested; rows cover the menu render, the right
model/harness selection, and the live apply. 2.12: codex GLM subagents,
which ucode PR 251 explicitly skips; rows track the three blockers
(static candidates, placeholder floor-arm, and the effort wall) with
the sys_session_create child path recorded as already working.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): keep the bundle-agent harness row visible under Smart Routing

Two bugs in the debby/polly gear-config flow when Smart Routing is picked
as the brain harness:

- Picking Smart Routing unmounted the Agent Harness dropdown that made the
  pick (it was gated on !autoRouting), leaving a lone locked Permissions
  row with no way to read the pick back or switch away without Cancel.
  The row now stays rendered, ordered above Permissions, and the gear
  tooltip mirrors both rows.
- A remembered fully-auto pick had no degrade path when the server turns
  smart routing off: the modal showed a blank harness select while the
  create still sent harness_override "auto". The bundle flavor now drops
  the pick quietly, matching the top-level auto-native rule, and keeps the
  stored pick in case routing returns.

Adds 15 vitest cases on real debby/polly (claude-sdk) fixtures covering
menu shape, pick persistence, payloads, per-agent memory, and the
degrade; updates the one existing test that encoded the unmount bug.
NewChatDialog.test.tsx 228/228; shell suite 1754 pass; tsc/oxlint/
prettier clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: flip the §2.11 bundle-agent rows to vitest-backed

The gear-config menu bugs are fixed and covered (1f99705f); the two render
rows move to 🟡 pending a user eyeball, and the first-turn row records the
payload half as vitest-verified with the live end-to-end still owed.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the spawn-family policy in the GLM-subagent CUJ section

Subagent spawns stay within the parent harness family; GLM is
codex-family (all codex subagents may spawn gpt and glm arms when smart
routing is on); the auto harness alone spawns cross-family.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: let codex sessions spawn GLM subagents

GLM belongs to the codex spawn family: with smart routing on, every
codex spawn may target both the gpt arms and glm-5-2 (the auto harness
alone spawns cross-family; claude parents stay claude-only). Three
layers had to move:

- Catalog: databricks-glm-5-2 joins _CURRENT_GENERATION_MODELS[gpt], so
  infer_models offers it and a routed glm pick resolves exactly instead
  of substituting down to luna (this also removes the create-path C1
  substitution arrow). Since no discovery listing ever advertises glm, a
  live catalog row would still hide it — candidate_models now tops up
  known-unadvertised arms for the gpt family only, nested spawns
  included, without widening multi-model harnesses like pi.
- Vocabulary: codex's spawn_agent validates model ids client-side
  against a closed enum of its own slugs, which silently killed EVERY
  catalog-id rewrite, not just glm. New codex_model_vocabulary maps
  catalog ids to codex slugs (databricks-gpt-5-6-luna -> gpt-5.6-luna)
  and clamps spawn effort in agreement with clamp_effort_for_model; the
  router hook rewrites through it and falls open when no slug exists.
- Catalog file: glm has no codex slug at all, so the executor reads the
  installed CLI's own catalog (codex debug models, cached per binary and
  CODEX_HOME per host process) and writes the session's private
  model_catalog_json with a glm entry cloned from the cheapest arm,
  carrying its own low/medium/high effort ladder — codex then clamps an
  inherited xhigh instead of refusing the spawn. Every failure path
  leaves codex on its bundled catalog.

Live-proven on the local stack: a native spawn_agent glm subagent off an
xhigh codex parent ran at system.ai.glm-5-2/medium and completed, with a
luna sibling in the same turn unaffected. Family policy pinned by tests
in both directions and both modes.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: give codex spawn routing a real signal and honor explicit asks

Live verification exposed that no codex spawn could ever land glm even
with it offered: this codex's spawn_agent has no task-name field, the
spawn message was withheld from the router on a disproven encryption
premise, and an explicit model in the spawn arguments was overridden by
the placeholder-scored default. Every spawn therefore routed on the
19-char placeholder and landed the default arm (verified live: three
spawns, including one explicitly asking for system.ai.glm-5-2, all ran
gpt-5.6-sol).

- The codex hook now forwards the spawn message (plaintext in hook
  payloads — measured) as the routing prompt via a new prompt_keys seam,
  so the router scores the actual task and can pick delegate arms.
- The hook also forwards an explicit spawn model as requested_model. The
  server honors the ask when it is an arm the spawn's own harness could
  have been routed to (bare-arm match, so any spelling lands the
  servable one); a cross-family or unoffered ask is routed over and
  recorded truthfully as attempted_override. The honor is restricted to
  the requesting harness's candidate row because a rewrite runs
  in-place — an auto-harness session must not hand codex a claude arm.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: carry requested_model across the runner relay hop

The relay resolver rebuilds the route-subagent body field by field, so
the new requested_model never reached the server: live, a spawn that
explicitly asked for system.ai.glm-5-2 was routed to luna with no
attempted_override recorded. The relay test now pins every routing
input surviving the hop.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: close the §2.12 GLM-subagent rows with live evidence

All four layers verified on the shipping path 2026-08-04: glm in the
live spawn menus, exact in-family resolution, and a live glm subagent
(turn_context system.ai.glm-5-2/medium off an xhigh parent). Records the
two extra layers live testing surfaced: message-as-signal (spawn_agent
has no task-name field here) and honoring explicit in-family model asks.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): scope a bundle agent's Smart Routing brain to that agent

Picking Smart Routing as Debby/Polly's brain-harness renamed the whole
composer selection — chip, tooltip, and modal title all flipped to
"Smart Routing" as if the top-level auto harness had been picked, and
re-clicking the agent's own row silently dropped the brain. The two
flavors share no state (auto vs auto-native sentinels, per-agent
memory), but the derived autoRoutingSelected union was used for
identity, not just row gating.

Identity readers (agentLabel, triggerTooltip, configSummary, modal
title) now key on smartRoutingHarnessSelected alone; the union keeps
its one honest reader (the routing-seed skip) and a comment stating the
rule. The bundle modal shows the Agent Harness row alone (locked
Permissions belongs to the top-level flavor whose creates actually send
permission fields), the permission-reset effect and handleSelectAgent
key on the top-level sentinel only, and create payloads are
byte-identical in all four flavor combinations.

Tests: 292 pass across the three NewChatDialog suites — includes a new
"Smart Routing flavors are scoped separately" describe (mixed fixture)
pinning both leak directions, plain-create isolation, and the brain
surviving a re-pick; the old chip test that encoded the leak now pins
the fix; the two locked-Permissions tests moved to the top-level
flavor's describe. tsc/oxlint/prettier clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: add CUJ_MASTER.md, the consolidated routing CUJ registry

One doc merging the full CUJ_STATUS registry (matrix, recipes, tiers,
all section areas), the v4 in-harness routing phases (phase 1 landed
with evidence; phase 2 blockers), tonight's six live-feedback rows, and
a new adversarial section: 23 Breakage CUJs (X1-X23) grounding how this
setup fails for other people — missing/old CLIs, non-AIGW credentials,
router timeouts vs the hook ladder, hook-merge precedence, shared
bridge roots across worktrees, and the static glm fallback offering an
arm a workspace may not serve. Includes stack bring-up with a pinned
random-port convention, the R11 bare-launch recipe, a 112-row registry,
and a revisit list split by needs-human vs headless.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): honest subagent-routing display — fresh reads and gated chips

The gear modal's Subagent routing row could show Inherit while "on"
was stored: the override hydrates only at session bind (no SSE event
carries it, the session query never refetches), and the modal seeded
its draft once per open — so the row displayed a stale value and Save
could PATCH a value the user never picked. The row now holds a pick
that reads through to the live store value until touched, save() writes
only a pick that still differs from a fresh store read, opening the
gear re-reads the two override switches (refreshSessionOverrides — slim
snapshot only, so it cannot trigger the sticky-model PATCH), and a
session switch under an open modal re-seeds instead of writing the old
session's drafts onto the new one.

Per the user's ruling, native_subagent routing chips now render only
when the override is explicitly "on": on Inherit (or off) the chip
would advertise a setting the user didn't choose. Display gate only —
the decision rows stay persisted as the audit trail, and an inheriting
session's spawns are still routed server-side. Flip-side caveat,
deliberate: toggling the setting retro-hides/reveals historical chips.

453 tests pass across the three touched suites (display/write matrix,
stale-under-open-modal regression proven failing pre-fix, chip-gate
scope table); tsc/oxlint/prettier clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test(web): unit-cover the sub-agent routing chip gate

Pins stripGatedSubagentRoutingChips at the unit level alongside the
composer-level coverage: explicit "on" keeps spawn chips, Inherit hides
them while the session's own (and legacy scope-less) decisions stay.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: gate Smart Routing per harness on AI-Gateway backing

A harness whose CLI runs off a personal subscription (ChatGPT codex,
Bedrock claude) cannot run a routed pick — routing rewrites the launch
model to a gateway catalog id. Verified across four mocked credential
states (neither/claude-only/codex-only/both backed) and closed the
holes where routing could still be reached:

- gateway_inference: gateway_inference_state / not_gateway_backed read
  a host's reported map under any harness spelling; unknown (older
  host, unevaluable family) never gates.
- server create: the auto path refuses to route when either arm is
  unbacked (no safe half-menu — the pick lands after the create
  commits), and an explicit routing-on create pinned to an unbacked
  native harness 400s with the way out named, instead of minting a
  session whose routing silently never applies. Children and subagent
  sessions stay with their parents' spawn/turn gates.
- CLI preflight: --smart-routing consulted only the server's host row
  and silently proceeded when no host had registered — pinning a
  databricks model onto a ChatGPT-backed pane. The launch always runs
  on this machine, so the local gateway-inference map is now the
  authoritative first gate, with the host row as fallback; the two
  failure modes get distinct messages (no routing model configured vs
  not AI-Gateway-backed).

328 tests pass across the CLI/gateway/create/routing suites, including
a parametrized A-D truth table over both arms and the auto route.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): require gateway backing for the bundle-agent Smart Routing brain

The Debby/Polly Agent Harness menu offered Smart Routing whenever the
server flag was on, even when this host backs only one model family
with the AI Gateway — the router could then land the session's work on
an arm that cannot run its routed model (a codex pane on a ChatGPT
subscription). The auto option now requires both families
gateway-backed, mirroring the server-side create gate. Gateway backing
only: unlike the top-level harness row, the bundle brain routes across
SDK harnesses, so native wrappers/CLIs are deliberately not required.
The gate drops only the OPTIONS entry — membership checks and the
summary label for an existing pick keep the unfiltered map, so a saved
pick still reads back honestly.

235 NewChatDialog tests pass, including the new offers/hides matrix per
gateway state; tsc/oxlint/prettier clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: make cross-harness spawn redirects actionable in native sessions

An auto-harness claude session's redirected spawn was denied with an
instruction naming sys_session_create — a tool the model could not find
(claude spells MCP tools mcp__omnigent__<tool>, schemas are deferred
behind tool search, and no allowlist pre-approved them), so it treated
the deny reason as prompt injection and refused. The omnigent MCP was
attached all along; the actuation was unreachable.

- The deny/redirect reason now names the requesting harness's own
  spelling (claude: mcp__omnigent__sys_session_create; codex: the bare
  name plus its omnigent.<tool> display form — verified empirically
  against codex-cli 0.145: the flattened omnigentsys_session_create is
  log-only and not callable), notes the tools come from the attached
  omnigent server and may need a tool search, and degrades gracefully —
  when the session's relay does not advertise the spawn tool, it tells
  the model to do the sub-task itself instead of naming a tool that is
  not there.
- Auto-harness claude launches (label or harness_override 'auto', both
  metadata loaders) add --append-system-prompt with the routing note and
  an --allowedTools list of the four redirect-loop tools
  (sys_session_create/sys_agent_list/sys_session_send/sys_read_inbox —
  the inbox read was live-proven required to close the loop); pinned
  launches stay byte-identical, pinned sessions never see redirects.
- Auto-harness codex launches get the note as developer_instructions
  (through the reversible sidecar sync) and per-tool
  approval_mode=approve tables in the generated mcp_servers section.

Live-proven on the incident's exact shape: auto-harness claude parent,
spawn redirected to gpt-5-6-sol/codex-native, model called
sys_agent_list then sys_session_create, child session created on the
codex arm with parent linkage, result returned via the inbox, parent
reported it. Control session (pinned) carried neither flag. 229 tests
pass across the five touched suites.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the e2e sweep's evidence across the CUJ_MASTER registry

Overnight sweep on both stacks: 9/9 create matrix exact (the C1 glm
arrow is gone), GLM subagent rows live-proven including the effort
clamp firing, cross-harness redirect actuation end to end, codex
bare-launch 8/8 including crash durability, gating row 65 closed live,
1,627 pytest + 1,446 vitest with only the two accepted baseline
failures. Registry corrections from false greens the sweep caught:
deleting the routing block does not disable routing (only
provider:none does), the audit/canary rows are unreproducible since the
machinery was cut, the turn-path fail-open is silent, and several
recipe spellings fixed.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: switch claude models via the picker, never the global-default arg form

Every routed claude-native switch (and the web model picker) typed
'/model <arg>' + Enter into the pane — claude's arg form saves that
model as the user's GLOBAL default in ~/.claude/settings.json, caught
live rewriting the file during the e2e sweep. Ported the v4 actuator:
inject_model_selection submits bare /model, polls for the picker, walks
the cursor onto the target row, and presses 's' (session-only — proven
to leave the file byte-identical; Enter and digit keys both save the
default and are never sent), resolving exact catalog-id matches across
all rows before any alias match so a workspace serving two generations
of one tier lands the right row. auto_confirm's fixed 0.3s sleep is
now a dialog poll with a deadline.

The web path needed more than the executor's targets, caught live: the
picker dropdown sends tier ids, and this workspace serves two Opus
generations — 'opus' alias-matched the wrong row and the custom slot
(labelled by display name) was unreachable. Targets now come from the
session's resolved launch-config env (alias pins + custom slot + slot
name) merged under the bridge record; both cases verified live
('opus' -> Opus 4.8, the custom tier -> Opus 5, each session-only).

Live proof on the running stack, no restart (runners spawn per session
from disk): a routed opus-5 -> sonnet-5 switch and two web switches,
panes showing bare /model + 'for this session only', zero 'saved as
your default' lines in full scrollback, and ~/.claude/settings.json
md5-identical throughout. 640 tests pass across the seven touched
suites, including a tripwire that fails if the arg form ever returns.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: let a spec hand its brain harness to Smart Routing

A spec that pins executor.config.harness also pins the family its
sub-agents are routed within, so a two-headed agent loses the head that
lives in the other family: debby's `gpt` sub-agent, declared on codex,
was rerouted onto claude-sdk and both heads answered as Claude.

Add executor.config.smart_routing_harness: auto, which opts a spec out of
its own pin for a Smart Routing session and converges on the "auto"
sentinel path the brain-harness picker already offers by hand. Gated to
Smart Routing creates only, and never over a client's explicit harness or
model pick, so a spec carrying the key is inert with routing off.

Set it on debby and polly, whose sub-agents span harness families.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: two-state subagent routing, stamped at create — Inherit is gone

Per the user's ruling: a session that starts with Smart Routing routes
the subagents it spawns; everything else is Default, meaning whatever
the harness natively does. The tri-state inherit (unset resolving to
the session's own cost-control state) produced displays the user never
picked and a chip gate that disagreed with behavior.

- subagent_routing_enabled is now exactly override == "on"; the spawn
  gate reads one explicit switch instead of re-deriving parent state.
- The server create handler stamps "on" once, for every path that
  starts routed: top-level auto harness, bundle-agent auto brain, fixed
  native harness with routing on, CLI --smart-routing (including v4's
  bare in-harness creates, which send cost_control on), and children of
  a routed parent. Unrouted creates store nothing; an explicit caller
  value always wins; only "on" is ever stamped so ordinary creates
  cost no extra write.
- One-time data migration stamps "on" onto existing rows exactly
  where the old inherit rule resolved to routed (146 of 158 live rows),
  so sessions in flight keep routing their spawns across the deploy;
  downgrade is a documented no-op.
- The gear row offers exactly two options — Smart Routing / Default —
  reading through to the stored value; a legacy null displays Default
  and re-picking it writes nothing. PATCH keeps accepting explicit null
  as an API-level clear; the UI never sends it. The chip gate's logic
  is unchanged and is now an exact mirror of behavior.

181 python + 642 web tests pass across the touched suites (stamp
matrix, migration up/down, two-option UI, PATCH back-compat);
tsc/oxlint/prettier and ruff clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: the router always decides a requested-model spawn — honor only on match

A spawn naming a model bypassed routing entirely ('honored — it is a
routable arm'), so the parent model's habit of writing a model field
starved the delegate arms: a dry-run subtask that the router scores to
glm ran on sol because the router was never asked. Per the user's
ruling, the requested model never short-circuits: the router is always
called, 'honored' appears only when its pick matches the ask (bare-id
normalized, [1m] folded), and a mismatch applies the router's pick with
the ask recorded as attempted_override — struck through on the chip
next to the applied model — and named in the codex parent's notice so
it does not silently re-spawn.

Claude-side asks now resolve through the session's alias pins before
comparison (a bare 'opus' never matched its own pinned arm and logged a
spurious override on every named spawn); inherit/default sentinels
carry no ask. The sys_session_send path's raw string compare gets the
same normalizer (a servable-alias respelling is not an override). On
router outage the spawn still runs on the ask (fail-open unchanged)
and the record now says so.

Accepted cost, signed off: an explicit ask — including a user-authored
'use glm' — is honored only when the router independently lands the
same arm; task_v1 exposes no requested-model input (live-probed: config
hints ignored, narrowed menus rejected). Follow-ups if wanted: a
requested_model field in the routing proto, or a session-level pin.

197 python + 40 web tests across the touched suites; live-probed
against the real router with match, mismatch, and no-ask shapes.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: session Smart Routing is a create-time choice; the gear keeps one knob

Custom/SDK agents (Polly, Debby, and any non-native agent session)
lose the in-session Smart Routing toggle. It was already a near-no-op
for the session's own turns — the first routed turn pins
model_override, after which the toggle changed nothing — and its only
live effect was gating child spawns through a field the visible
Subagent routing row did not control. Per the user's ruling, Smart
Routing for a session's own turns happens once, at session start.

The Subagent routing row (identical copy, options, and testids to
native sessions) is now the single in-session routing control, and the
three server-side child-spawn gates (_force_auto_for_child, the SDK and
native parent-routing turn gates) plus the child create-stamp's parent
clause read the subagent-routing switch instead of parent cost-control.
Behavior-identical for every existing row via the create-stamp and the
e6f7a8b9c0d1 backfill (live DB verified: zero stranded cc-on/sr-unset
rows) — and picking Default now genuinely stops a bundle's spawns from
being routed, which the old pair of knobs never delivered.
isSubagentRoutingSession widens to all non-native top-level agent
sessions (their spawns go through the create path, which is
harness-independent), closing the pi-brain gap where the row vanished
mid-session. The gear tooltip drops its standalone Smart Routing line,
matching native.

189 python + 293 web tests across the touched suites, including
gate-flip cases proven to fail against the reverted server edits; full
web suite unchanged at 5005 passing.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* spike: codex UserPromptSubmit routing probe (S1/S2 scaffolding)

A marker-gated spike-userprompt subcommand on the codex policy hook:
logs every UserPromptSubmit payload to the bridge dir, and (behind a
one-shot marker file) fires thread/settings/update on the live thread
via the app-server websocket, optionally blocking the prompt. Inert
without the marker files. Kept as the working reference for the real
route-turn hook: the ws:// client framing, the second-command-per-event
wiring, and the trusted-module trick are all proven here.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: record the spike verdicts - Variant B disproven, Variant A verified

S1 FAIL, 3 runs with a bogus-model positive control: codex binds the
turn model at turn/start and writes turn_context before UserPromptSubmit
runs, so an in-window thread/settings/update only lands on the NEXT
turn. Variant A (block -> settings update -> replay) was then verified
end-to-end on codex: clean 1.08s abort, routed turn_context on the
replay, re-entrancy marker held, and the forwarder self-pins
model_override off thread_settings_applied.

S2 PASS: UserPromptSubmit fires for turn/start RPC turns with payloads
byte-identical to TUI-typed input; payload carries prompt + LIVE model
+ codex thread id (not the omnigent session id). S4 PASS: full hook
chain 0.37-0.78s; the settings call 26-77ms, wide margin under the 30s
budget. New trap recorded: never read the live model from config.toml
(stale on every read during the spike); take it from the hook payload.
S3 (claude block-and-replay UX) is the only spike still open.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* spike: claude UserPromptSubmit block-and-replay probe (S3 scaffolding)

Marker-gated spike-userprompt subcommand on the claude policy hook plus a
second UserPromptSubmit command in the bridge's settings generation. Inert
without the marker file. Kept as the working reference for the real
route-turn hook on claude: it is what proved the block leaves a clean
slate and the bracketed-paste replay is byte-exact.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: S3 passes - claude block-and-replay verified, all spikes closed

Block is cleaner than documented: input erased, reason shown, and nothing
persists (transcript logs only an informational preventContinuation row -
no user row, no model call; the omnigent conversation records nothing for
the blocked prompt). Replay is byte-exact including a real multi-line
prompt, submitted as one turn by the existing bracketed-paste injector.
The replay's fresh UserPromptSubmit no-ops on the consumed marker, and
/model does not fire UserPromptSubmit so the switch cannot self-trigger.
Three routed turns landed three different arms. Visible gap ~3-4s, the
/model settle dominating. No turn-2 fallback needed.

Records the actuator spec (poll for the Switch model? dialog, settle on
context.json - never fixed sleeps) and four claude-specific findings: the
hook payload has no model field, /model <arg> rewrites the user's GLOBAL
default (product blocker for the actuator, needs a decision), the /model
echo can make a weak model refuse the replayed prompt, and this
deployment's /model vocabulary is full catalog ids rather than bare
aliases. Also flags a pre-existing defect that bites the current branch
independently: inject_slash_command(auto_confirm=True) confirms the switch
dialog after a fixed 0.3s sleep, but the dialog took 1.861s with cached
history - the Enter is dropped and the next injection times out.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: in-harness first-message routing for codex (phase 1)

A bare 'omni codex' launch now routes on its first prompt, wherever that
prompt comes from (TUI-typed or RPC-delivered) — the spike-verified
block-and-replay variant, productionized:

- omnigent/runner/turn_routing.py: the decision seam (wire types, the
  route-once policy, loopback relay with advertisement + live-pid check,
  and the runner-side replay that waits on the hook's done-marker and the
  blocked turn clearing before redelivering through the normal events
  path, which re-checks the gate and records no second decision).
- codex hook 'route-turn' subcommand: fast-skip on the marker, POST to
  the loopback, thread/settings/update + config mirror, then block.
- POST /v1/sessions/{id}/hooks/route-turn mirroring route-subagent,
  reusing route_turn / catalog / decision-chip plumbing.
- Registered as a second UserPromptSubmit command in the trusted policy
  hook module; started/torn down beside the subagent router at launch.
- write_advertisement/read_router_endpoint gain a filename kwarg so the
  loopback plumbing is shared with subagent routing, not copied.

The route-once gate is the routing-decision label, not model_override:
the codex forwarder mirrors config.toml's stale model into
model_override at the first turn/started, beating the hook, so presence
can't distinguish a real pin from the mirror. Residual gap (documented
in already_routed): a manual pin with Smart Routing on gets hook-routed
once; closing it needs pin provenance, left for phase 2.

Live-verified on the :64688 stack: trivial->luna, sprawling->sol, one
decision row and one user turn each; second turn fast-skips with zero
network. Spike scaffolding (spike-userprompt) removed. 96+69 tests pass
under the sanitized env run.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: make the blocked first prompt durable across runner crashes

Between the hook's block and the replay delivery the prompt existed
only as an in-memory asyncio task — a runner crash in that window lost
it forever while the decision chip, model_override pin, and done-marker
all said routing succeeded (exactly the dead-session shape reported
from live testing, reproduced with a SIGKILL at the marker write).

The relay resolver now writes turn_replay_pending.json before handing
the verdict back (on disk before the hook can block), clears it on
delivery or when the hook is known to have fallen open, and keeps it on
a failed delivery. On the next launch schedule_pending_replay_recovery
drains a leftover record: it requires the marker (proof the hook
blocked), waits for the relaunched thread, and only delivers after
confirming via the item history that the prompt never ran — an
unreadable session leaves the record for a later launch rather than
risking a double-run. A session_id match guards forks sharing a bridge
dir. Adds a turn_routing.log hook trace for diagnosability.

Live-proven on the spike stack: four fresh sessions routed on their
first prompt with turn-2 fast-skips, plus a crash-recovery run
(SIGKILL at the marker; relaunch recovered and replayed the prompt on
the routed model, record cleared). Investigation of the reported dead
sessions showed no prompt ever reached them (no UserPromptSubmit, no
events, empty rollouts) — the durability gap was the adjacent real
defect. 101 tests pass across the turn-routing and codex hook suites.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: in-harness first-message routing for claude (phase 2)

A bare 'omni claude --smart-routing' launch now routes on its first
typed prompt, mirroring codex phase 1 through the same turn_routing
seam: claude-native joins _TURN_HOOK_HARNESSES, the claude hook gains a
route-turn subcommand (marker fast-skip, loopback POST with the live
model read from context.json, block), and the runner performs the model
switch inside the replay via _apply_routed_model — the composer gate
only forwards model_override in-band when it just routed, so a
hook-routed replay previously arrived with no model and ran on the
launch model.

The switch actuator drives the /model PICKER instead of '/model <arg>':
sandbox-proven that the arg form saves the pick as the user's GLOBAL
default in settings.json, while walking the picker with arrows and
pressing 's' switches 'for this session only' with the file
md5-identical across idle soak and clean exit (digit keys also save the
default and are never sent). inject_model_selection resolves exact
catalog-id matches across all rows before any alias match — a workspace
serving two opus generations otherwise lands the wrong row. The routed
composer path switches through the same picker, closing the global
default rewrite on every routed turn; auto_confirm's fixed sleep is
replaced by a dialog poll with a deadline.

CLI: --smart-routing without -p now creates the bare routed session
(cost_control on, no create-time route) and launches the TUI for
harnesses with in-harness routing; auto/no-harness still requires -p.
Spike scaffolding (spike-userprompt) deleted.

Live-proven on an isolated stack: five bare claude launches, trivial
prompts routing to sonnet-5 and a narrow task escalating to opus-4-8
(the pane held opus-4-8 AND opus-5 rows — the id-first matcher picked
right), one decision and one user message each, second prompts
fast-skipping with zero network, and ~/.claude/settings.json
md5-unchanged after every run. 364 tests pass across the touched
suites.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: drop the vestigial turn_router_dir kwarg that broke claude launches

A merge-resolution leftover passed turn_router_dir to
augment_claude_args, whose merged signature never gained the parameter
(the claude route-turn hook registers via bridge_dir and self-gates on
the advertisement at fire time) — every claude-native launch on this
branch died with a TypeError before the pane existed. Caught by the e2e
sweep's bare-launch row.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: apply the routed model to the codex thread in codex's own slug

The route-turn actuator sent thread/settings/update the raw catalog id
(databricks-gpt-5-6-luna). The turn ran — the gateway serves the id —
but codex has no catalog metadata for that spelling, so the pane warned
'Model metadata not found, defaulting to fallback' and /model kept
highlighting the launch slug, which reads as routing not working.

New codex_model_vocabulary (shaped like claude_model_vocabulary):
comparable_model_id folds catalog prefixes, the [1m] suffix, and
dot/dash spelling; codex_model_slug resolves the routed id against
codex's live model/list rows, so codex stays the vocabulary authority
with no hardcoded table. The actuator lists models on the client it
already holds, sends the matched slug, and mirrors the same spelling
into config.toml so the forwarder cannot flip-flop between spellings;
model/list failure or an unmatched id falls back to the id verbatim.
The decision row keeps the catalog id.

Live-proven: thread_settings_applied carries gpt-5.6-luna, zero
catalog-id spellings in the rollout, /model shows the routed row as
(current), no metadata warning for the routed model, one decision,
turn-2 fast-skip. 122 tests across the four touched suites.

Known siblings left for follow-up: thread/start still passes the
catalog id (the remaining launch-model metadata warning), and the
codex spawn path injects catalog ids verbatim.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* feat: gateway backing selects the router; the chip discloses the source

Gateway inference stops being a hide gate and becomes a source
selector. Every Smart Routing surface stays available; the AIGW
conditions decide which router answers each decision: the external
task_v1 client when it is configured and every family the decision
involves is AI-Gateway-backed, else the built-in judge
(LLMRoutingClient) when the server has one, else today's errors —
now reworded to name the real neither-source cause.

- New routing_backend seam: RoutingBackends holds both clients;
  select_router picks per decision; caps carry both (routing_client
  stays the primary for un-migrated readers). The CLI builds both, so
  a Databricks deployment keeps its judge as the fallback.
- Off-gateway decisions never see the static databricks-* tables:
  allow_static_fallback gates the infer_models fallback/top-up, and the
  route declines rather than offer an id the pane cannot run (the two
  hazard tests pin this seam-first).
- Decisions persist router_source ('databricks-aigw' | 'oss-llm');
  /v1/info exposes smart_routing_sources; older servers degrade to
  both-mirror-smart_routing_enabled in the CLI and web alike.
- The chip carries a small Databricks mark only when the AI Gateway
  router answered ('Routed by the Databricks AI Gateway'); OSS and
  legacy rows carry none; pickers are never branded.
- CLI preflight on an off-gateway family with a judge available prints
  one informational downgrade line and proceeds instead of erroring.
- Setup doc and routing overview updated to the source-table semantics.

696 python + 336 web tests across the touched suites (21-test selector
truth table, the create-refusal splits, the /v1/info matrix, badge
render cases); ruff/tsc/oxlint/prettier clean. The 9 wider-run
failures are pre-existing snapshot-cache pollution, reproduced
identically on the clean parent.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: apply the routing test-suite overhaul and refresh the CUJ registry

Registry (designs/CUJ_MASTER.md): 4 rows + 1 recipe cut as fixed or
contradicted; the spawn-audit/canary rows retired-with-reason (the
machinery went with 484f7300 — deliberately out of scope, named in the
PR); row 95 re-entered as a picker regression row; ~22 rows updated to
today's ground truth (codex slug comparisons via comparable_model_id,
strict adherence, the spec-declared auto brain, the deleted standalone
toggle, source-selector semantics); 19 new rows in area O covering the
create-stamp matrix through the off-gateway static-menu decline.

Suites: the turn-gate tests renamed test_turn_routing_enabled_* so they
stop reading as the two-state spawn gate; the matching-ask pair and
five integration duplicates folded into their parametrized seam tests
with per-item duplication proof (122 -> 119 cases, no coverage lost).

25 new targeted cases: an AST-based guard module pinning that no claude
routing path builds '/model <arg>', the switch path holds no fixed
sleeps, the picker reads only the user settings file, and cursor/kiro
remain the only (documented) arg-form senders; hook-settings cases
pinning both routing hooks' timeouts above their script budgets and
coexistence with the policy hooks; the turn-routing timeout ladder
strictly decreasing and the router client inside the hook budget; the
two-concurrent-first-prompts and manual-pin-routed-once gaps pinned as
recorded decisions; migration edge cases (unparseable blobs, dangling
parents, idempotent re-upgrade).

744 + 364 + 192 sanitized pytest passes across the routing slice; web
suites re-confirmed green as baseline; ruff and pre-commit clean.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: add the e2e routing CUJ suite behind a mocked router

Five end-to-end CUJs — claude and codex from session start (API) and
from a typed first message (TUI), plus the auto-harness cross-family
redirect — each asserting the routing artifacts (decision rows and
their router_source, the pinned model, marker files, thread settings in
codex's own slug, pane state, message counts) and never answer content.

Two properties make it CI-shaped. The routing API is mocked: a
deterministic routes:select service replays the live router's own rule
traces (trivial -> cheapest arm, delegate-class -> glm, crosscutting ->
default/escalate) and keeps the real contract honest by rejecting a
narrowed menu exactly as staging does — proven against the real
ExternalRoutingClient over HTTP, not a hand-written body. And subagent
spawns are asserted as issued-and-routed rather than awaited, so no
test waits on a child's output or an inbox return.

21 pass in ~5 minutes; the suite is opt-in (smart_routing marker plus
OMNIGENT_E2E_SMART_ROUTING=1) and skips with a named reason when the
CLIs, tmux, or a provider config are absent. Each test boots its own
ephemeral server, host, temp DB and temp config home; the developer's
settings files are left untouched, which CUJs 1/3/5 assert by digest.

The CLIs are launched with their trust-bypass flag through
terminal_launch_args (the pattern tests/e2e/test_comment_tools_claude_native.py
already uses) because a fresh temp workspace otherwise blocks the input
box on a trust dialog before any hook can fire.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* chore: remove development-session scaffolding from the PR

Working docs (CUJ registries, plan documents, session setup notes),
the personal dev scripts (dev-env/run-server/run-host/run-frontend and
the routing-API probe), and their allowlist rows were session tooling,
not product: several named internal staging workspaces and proxy
endpoints, and none of them belong in a public repo. A test fixture's
profile string is generified for the same reason. The user-facing
routing documentation moves to the omnigent-site docs (PR #446 there).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: settle the rebase against main's session-routes and model-picker work

Main split the session routes into explicit imports and grew a
host-resolved Codex launch-model catalog while this branch was out; the
replay needed both re-applied by hand.

- Import the names the routing paths use explicitly (`_logger`,
  `_get_runner_client`, `_spawn_gateway_backed`, the validators) now that
  `routes_hooks` / `routes_core` no longer star-import them.
- Keep the pre-existing `native_policy_not_enforced` banner: the trim
  commit dropped its server half, but the runner still reports the
  degrade reason, and main re-exports the helpers.
- Codex's Model row now carries the host's real catalog alongside the
  Smart Routing sentinel instead of replacing it, with the resolved
  default label back via a `defaultLabel` prop on `RoutingModelSelect`.
- Refresh the tests those two changes made stale, and re-apply the hook
  timeout the dropped merge commits had fixed in place.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: apply the external-review fixes and drop both new migrations

- Turn dedup compares decoded user-message text, not a JSON dump
- A no-op model pick is terminal: pinned and recorded without replay
- Child sessions route once; follow-ups cannot flip harness_override
- The turn marker is scoped to {session, decision}; the claude hook
  reads the live session id, so /clear cannot reuse a stale marker
- Hook relays require LEVEL_EDIT; rationales log at DEBUG
- The turn router registers only when routing is enabled; codex model
  catalog population runs off the event loop with a 60s failure TTL;
  hook timeouts sit 10s above the inner HTTP timeout
- gateway_inference moves off the hosts table onto the host connect
  handshake, held in server memory (unknown-is-backed until a host
  re-reports); both alembic migrations are deleted — the PR adds zero
  migrations
- Routing availability checks unified on the routing_backend helpers

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: gate the router's ambient-credential tests on the databricks extra

The new ambient workspace-credential tests patch
``databricks.sdk.config.Config``, but ``tests/server`` runs on a lean CI
lane that neither installs the ``databricks`` extra nor deselects marked
tests, so all eight failed collection with ``ModuleNotFoundError: No
module named 'databricks'``.

Mark them the way the repo already gates SDK-coupled tests, and list
``tests/server/test_smart_routing.py`` on the databricks lane — a marked
test in a path that lane does not cover would otherwise run nowhere.

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* revert: switch claude models with `/model <id>`, not the picker

Switching a live claude-native pane through Claude Code's interactive
`/model` picker took ~530 lines of tmux screen-scraping to avoid one side
effect: the argument form also saves the pick as the person's global
default in `~/.claude/settings.json`. The repo owner has accepted that
write, and an external review found the picker path fragile in ways the
argument form has no equivalent of — a 5s server forward budget against a
~35s worst-case automation whose result was discarded, an applied-check
that could return before the ~1.9s "Switch model?" dialog rendered, a
next-message-swallowed-by-dialog hazard, no busy-pane gate, no scroll
handling, and no concurrency lock.

So every claude model-switch call site goes back to injecting the text
`/model <id>` plus Enter through `inject_slash_command`, with
`auto_confirm=True` so the cache-invalidation dialog is still answered:

- the web/API `model_change` endpoint (`runner/app.py`),
- the first-message turn-routing switch (`runner/turn_routing.py`),
- the per-turn executor switch (`inner/claude_native_executor.py`).

Fail-open semantics are unchanged: a failed injection is logged and the
turn still runs on the pane's current model.

Deleted with their last caller: `inject_model_selection`, the picker's
open/apply poll ladders, the row regex and row scanner, the row-matching
and row-picking helpers, the session-only key, and the two runner-side
target-spelling resolvers. Kept: `inject_slash_command` and the polling
`_confirm_tui_dialog` (shared with `/effort`, and a real improvement over
the fixed 0.3s sleep it replaced), plus a single picker-footer string the
pane-readiness gate uses to notice a picker the person opened by hand.

The AST guards that forbade the argument form are gone; the "omnigent
never writes the user's settings file" and "no fixed sleeps on the switch
path" guards stay, since both still guard live code. The e2e settings
guard now compares everything in `~/.claude/settings.json` except the
`model` key Claude Code itself moves.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix(web): one routing chip per pick, hydrate the gear modal's Model row

A Smart Routing create routes twice: once at create time (recorded as a
`session`-scope chip) and again on the session's first turn (a `turn`-scope
chip). Both land before the user's message, both resolve to the same model and
harness, and both render the identical "Smart routing · applied · claude-native"
card — one above the message, one below. The transcript opened on a duplicate.

Collapse them in the block walker: a `session` chip whose next content block is
a `turn` chip with the same model, harness, applied flag, and agent renders
nothing, and the turn chip (the one that pairs below the message) stands for the
pair. Both rows stay persisted as the audit trail, and a create-time pick the
turn CHANGES — or a failed create-time route, recorded as an unapplied
`"unavailable"` row — still renders its own chip, because those two chips say
different things.

Also in the gear modal, the Model row rendered blank on a routed session.
Routing pins the router's fully-qualified pick (`databricks-claude-opus-4-8`),
which the harness catalog carries only under an alias (`opus`) — so no option
declared the Select's value and Radix fell back to its empty placeholder. The
live model now rides as its own option, labelled exactly as the status label
below the composer. An untouched row still submits nothing: `save` re-pins only
a draft that actually changed.

Three review findings:

- `useSession` asks for `refresh_state=true` on every fetch again. Narrowing it
  to the cache-cold fetch meant an invalidation refetch — how switching a
  session's agent reloads the snapshot — came back off the runner's process
  cache, leaving the PREVIOUS agent's model catalog on screen until a hard
  reload.
- Drop the 30s snapshot poll every open session ran. Its only consumer was the
  session warning banner, which the enforcement-stack trim removed; nothing
  reads a field the poll refreshes, so the poll and its opt-in options go with
  it. That also makes the unconditional refresh above safe — nothing re-asks
  often enough to thrash the runner's caches.
- `refreshSessionOverrides` no longer fetches through the query client. It reads
  two plain DB columns, but writing the reply into the shared `["session", id]`
  cache replaced every other surface's refreshed snapshot with an unrefreshed
  one, dropping the `model_options` the model picker renders from.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: scope the codex routing extras to the sessions that need them

Three session classes now decide what a codex home carries: a plain
session gets a byte-identical pre-routing home (bundled catalog,
symlinked hooks.json, no spawn gate, no extra tool approvals); a
pinned-harness Smart Routing session adds only the extended model
catalog; an auto-harness session that routes to codex adds the spawn
gate and the cross-session tool approvals. The subagent router
endpoint starts only where something consumes it. The catalog probe
validates its payload and holds a lock across concurrent boots.
Dispatch validation accepts gpt substrings again and localizes
glm/kimi ids mechanically. The codex env filter now lets the router
and catalog launch signals through — the SDK-codex hook path was
silently dead without them.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: keep pinned codex launches free of routed-spawn extras

The runner passed `developer_instructions` to `build_codex_native_server`
for every codex terminal (with a `None` value on pinned sessions), which
changed the launch call shape for sessions Smart Routing does not own.
Pass the kwarg only for auto-harness sessions.

The claude-native launch-args tests handed a raw `tmp_path` to
`augment_claude_args`, which validates the bridge dir against the real
bridge root; point the bridge root at the test temp dir the way the
bridge's own tests do so the tests pass under any TMPDIR.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: satisfy the type and hardcoded-model gates

pyrefly on the pre-commit gate rejected five shapes the routing work
introduced: an inferred `dict[str, int | str]` hook literal that could
not take the route-turn entry, two `Awaitable` resolver results handed to
`asyncio.run_coroutine_threadsafe` (which takes coroutines only), and two
locals — `_parent_conv`, `_auto_harness` — read on paths where only a
narrower branch had assigned them. It also flagged the create path
rebinding `conv` from `get_conversation` without a `None` check, which
made every later attribute read an error; it now raises the same
`INTERNAL_ERROR` its sibling label writes do.

The router's static model tables moved to `omnigent/model_fallbacks.py`
as owned `StaticModelFallback` records — the repo's only sanctioned home
for a static model id, per the `no-hardcoded-models` lint. Ids that are
composed from the gateway's model-route prefix (GLM's `system.ai.`
spelling) are now spelled that way instead of restated.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: cover the Smart Routing UI in the Playwright suite

The web changes add user-visible routing surfaces with no e2e_ui coverage,
which the E2E UI Required gate flags. Two specs, following the suite's
established stub patterns:

- `start_session/test_smart_routing.py` — the landing picker's Smart
  Routing row (create sends `harness_override: "auto"` +
  `smart_routing_message`, and none of the placeholder wrapper's knobs),
  Smart Routing as the gear modal's Model choice (create sends
  `cost_control_mode_override: "on"`, no pinned model), and the negative
  gate: a server with routing off offers neither.
- `chat/test_smart_routing_session.py` — a routed session's two audit
  rows (create-time `session` chip + first-turn `turn` chip) render as ONE
  chip with the Databricks mark, and the session gear modal's Model row
  names the router's fully-qualified pick instead of rendering blank.

Both run against the suite's spawned server with `/v1/info`, `/v1/hosts`
and `/v1/agents` stubbed, so neither needs gateway credentials.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: match the gateway's trusted parents on DNS labels

The AI Gateway trust check compared the parsed hostname against
dot-prefixed domain suffixes with `str.endswith`. Correct as written (the
leading dot is what rejects `evilcloud.databricks.com`), but the safety
rests on a spelling convention in a constant, and a string-suffix test on
a domain literal is exactly the shape static analysis flags as incomplete
URL sanitization.

Compare whole DNS labels from the right instead, requiring at least one
label of the host's own in front of the parent domain. Same verdicts,
with the boundary now structural, and tests pinning both look-alike
classes: a trusted domain that only appears mid-host, and a label that
merely ends in one (`evilcloud.databricks.com`,
`ai-gateway.notazuredatabricks.net`).

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: stop the routing hook's codex floor from blocking every launch

Raising `_CODEX_MIN_VERSION` to 0.145.0 for the routing PreToolUse hook
made `harness_cli_installed("openai")` report `version-too-low` on
0.137–0.144, which makes `harness_is_configured("codex")` false, which
makes the host refuse EVERY codex launch — plain sessions included — with
a misleading "run omni setup". CI pins codex 0.139.0, so the e2e lane
failed on it too.

Restore 0.137.0 as the launch floor and enforce 0.145.0 only where the
spawn gate is actually registered: both codex hook writers now probe
`codex --version` and, on an older CLI, log one line and drop the routing
bridge dir so no hooks are generated at all. Routing no-ops instead of
blocking, and the user's hooks.json stays symlinked.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: confirm the /effort dialog instead of hanging on its title

`inject_slash_command(auto_confirm=True)` polled `capture-pane` for the
hardcoded "Switch model?" and sent Enter only on a match. The web UI's
effort change injects `/effort <level>`, whose confirmation dialog is not
titled that — so it never matched, the dialog stayed open, the change never
committed and the pane was wedged for the next injection. The no-dialog
case also spent the whole 4s poll budget where the previous code spent
0.3s.

Make the hint a per-command parameter and keep an unconditional confirm
Enter as the floor, which is what the code did before the poll was
introduced: on the no-dialog case it lands on an empty prompt and is a
no-op. The three `/model` sites pass the title they know and keep their
fast path; `/effort` passes none, settles briefly and confirms blind.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: keep the spawn-routing apparatus off plain claude sessions

claude-native passed `auto_harness=True` hardcoded and the SDK path started
the router for every claude session, so a plain claude session carried a
loopback HTTP server, its thread, a bearer token on disk, and a `Task`
PreToolUse hook — a subprocess cold start on native, in-process on the SDK —
on every spawn, with a 30-40s worst case when the endpoint is wedged. All of
it for a verdict the server would never route.

Gate both starts on the session's routing class, the same one the codex
paths already read. A plain claude session now gets no router, no hook and
no token file, matching plain codex; a routed session (pinned or auto —
claude routes spawns in both) keeps everything, and the per-spawn
server-side gate stays as defense in depth.

Accepted consequence: the class is stamped at create, so flipping the gear's
Subagent-routing toggle on for a plain-created claude session is inert until
the session is recreated. That matches the stamped-at-create design the codex
paths already follow.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: stop plain launches from displacing the model picker slot

`claude_config_with_launch_model_pinned` ran on every claude-native launch.
Whenever the launch model is an exact id no family alias points at — a user
picking an older generation of a family the workspace still serves — it
overwrote `ANTHROPIC_CUSTOM_MODEL_OPTION`, taking the workspace's own picker
row with it.

The slot exists so a routed session can return to the model routing picked
for it. Nothing re-picks the launch model on a plain session, so gate the pin
to routed launches and leave a plain launch's env untouched.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: restore main's spawn-env secret-leak canary

The trim commit deleted this file by name collision with the routing
spawn-audit canary; it is main's own guard for clean_agent_env and was
never part of this PR's machinery.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: keep the router rendezvous out of logs

The subagent- and turn-router startup logs printed the handle's url, and
the hook's rejection diagnostics echoed the url read out of the
advertisement. Both values travel with the bearer token that authorizes
the loopback endpoint, so a log line was enough to point a reader at the
secret's neighbourhood; static analysis flagged the four sites as
clear-text logging of sensitive data.

Drop the url from all four: the session id and the bridge directory (or
the advertisement's file name) identify the rendezvous well enough, and
the advertisement itself is on disk for anyone debugging it.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: confirm an effort dialog that renders after the blind Enter

A command whose dialog text we cannot recognise — ``/effort`` — settled
0.3s and then Entered blind. On a warm session the confirmation renders
about 1.9s in, so that Enter landed on an idle prompt and the dialog that
arrived afterwards stayed open: the person's next message was typed into
the modal and swallowed.

Keep the blind Enter as the fast path, then keep watching the pane for a
dialog until the confirm timeout and Enter again if one turns up. With no
dialog text to match on, the watch uses a structural signal — a framed
menu of at least two numbered choices with one selected — which also
recognises the ``/model`` picker and steps around a composer draft that
merely starts with ``2. ``. A dialog already showing at the settle skips
the watch, so the common cases still cost one capture.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: derive claude launch routing state through the shared class

Both claude-native launch-metadata builders hand-derived
``routing_enabled`` from ``cost_control_mode_override`` alone, while
``routing_class_from_snapshot`` deliberately ORs in the auto-harness
signal. A sub-agent child of a routed parent is created with
``harness_override="auto"`` and the auto-harness label but no
cost-control stamp, so it launched ``routing_enabled=False`` with
``auto_harness=True``: no pinned arms, no launch-model pin, no turn
router and no subagent router — yet still carrying the routed-spawn
system-prompt note and the four pre-approved ``sys_*`` tools. Claude was
told to hand its spawns to a hook nothing answered.

Route both builders through ``routing_class_from_snapshot`` so the class
is derived in one place, and require the spawn router to have actually
started before the note and pre-approvals go onto the argv.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: stop offering subagent routing where it cannot work

The create path stamped ``subagent_routing_override="on"`` on every
session that started on Smart Routing, and the gear offered the
Subagent-routing select to every native Claude/Codex session. On a
session pinned to codex neither is real: spawn routing there needs the
generated ``hooks.json`` and the routed-spawn tool pre-approvals that
only an auto-harness launch installs, so the switch read "on" with
nothing consuming it. The same went for a plain native session of either
family, whose apparatus is fixed at create.

Leave the stamp off for a pinned codex create, and hide the row wherever
the session's class has no spawn-routing machinery — a claude-family
routed session and any auto-harness session keep both. Non-native
SDK/bundle sessions are untouched: their children go through the
session-create path, which re-reads the switch per spawn.

Subagent routing is now launch-time-fixed for codex.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: make the model switch land once, or say why it did not

Three faults left over from reverting the interactive ``/model`` picker.

The web/API model-change handler typed the resolved catalog id straight
into ``/model``, which takes only the pane's own picker vocabulary. An id
outside it left the pane on its old model while the handler reported
success. Translate through ``claude_model_command_arg`` like the routed
turn path and the executor already do, and fail with a clear 503 when the
picker has no spelling for the model.

A routed first message switched twice. The turn router blocks the prompt,
types the switch and replays the prompt with the same override, but the
executor seeded its baseline from ``launch_model`` — written once at
bridge prepare — so the replay compared against the pre-switch model and
typed a second, redundant ``/model``. Seed from the live statusLine model
instead, and compare normalized.

A dropped forward was invisible. The PATCH persisted ``model_override``
and discarded the forward's result, so on a native pane — where the
injection is the only thing that moves the model — the row and picker
claimed a model the terminal was never on. Publish a visible notice and
log the reason. The forward budget also went up: the ``/model`` and
``/effort`` injectors can legitimately spend ~5s waiting on the pane and
its confirm dialog, which the old 5s budget would have reported as a
failure.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: clear the routing punch list's small residuals

- The install and credential routes recorded ``gateway_inference`` straight
  off the host's RPC reply, so a host answering with anything other than a
  string→bool object 500'd them inside ``dict(...)``. Decode through the
  same tolerant reader the tunnel path uses, where a non-mapping is
  "unknown".
- Reworded the routing docstrings that cited design documents no longer in
  the repo; the behaviour they described is stated inline, and the e2e
  suite in tests/e2e/routing/ is the executable reference.
- ``routing_enabled(caps=)`` read the routing backends directly, which
  misses the managed arm where only a policy-LLM factory is registered and
  the routing client arrives later. It goes through ``routing_available``
  now, the same gate the rest of the server uses.
- The codex model-catalog cache was keyed on binary path plus codex home,
  so an in-place upgrade (same path, new bytes) served the previous
  codex's catalog for the life of the host process. The binary's mtime and
  size are part of the key now.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* docs: match the gear's comments to the narrowed subagent gate

The two comments still described the old "every native Claude/Codex
session" rule. Say which classes carry the apparatus and which the row is
hidden for.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: pin that the late-dialog Enter only answers our own dialog

The extra Enter is scoped to a dialog that appeared after the settle, so a
menu already open when the command was injected — a live permission
prompt, say — still takes only the single blind Enter this seam always
sent. That property is what makes widening the confirm window safe, so it
gets a test and a note rather than living in the reviewer's head.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: answer the effort dialog by name, not by shape

The effort confirm watch Entered on any dialog that turned up during its
4s poll, so a ``/model`` picker the person opened by hand — or a tool
permission prompt that rendered mid-turn — took the Enter too: the first
silently rewrites their global default model, the second silently
approves the tool.

Claude Code titles both cache-invalidation confirmations from one
component, so ``/effort`` has a title to poll for just like ``/model``:
"Change effort level?". Pass it as the effort call's ``confirm_hint`` and
drop the shape-matching watch — ``auto_confirm`` now requires a hint. The
timeout Enter stays, so a title that drifts in a future release does not
wedge the pane, but is withheld when the pane shows a picker or a
permission prompt. The readiness gate learns the effort title too, so an
open effort dialog no longer reads as "an injection may land".

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: suppress the codex subagent stamp only where it is inert

The create-time subagent_routing_override stamp was skipped for anything
whose harness family is "gpt". That also caught an SDK/bundle agent whose
brain is codex or openai-agents — and those spawn their children through
the session-create path, which re-reads the switch per spawn, so the
stamp is exactly what gives them default child routing. Skipping it took
that away, and disagreed with the gear, which offers the row on every
non-native session.

Suppress only where the switch really has nothing behind it: a NATIVE
codex terminal, whose spawn routing comes from the hooks.json and
tool pre-approvals an auto-harness launch installs. The server and the
gear now agree class by class: native pinned-codex hides the row and
writes no stamp; a codex-brained bundle keeps both.

The old fixture had no spec harness, so it never reached the family
check; the new case pins a codex-brained bundle on both sides.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: clear the routing punch list's last three residuals

- The "terminal was not switched" banner fired on stopped and detached
  native sessions too, where nothing was running to diverge from: the
  relaunch reads model_override off the row. Surface it only when a runner
  actually answered and refused, which is the reachability the /health
  liveness field reports.
- Add the credential route the tolerance test the install route got: a
  host reply whose gateway_inference is a list must read as "unknown", not
  500 with the credential already written. The install test never proved
  that — its garbled value was dropped by the fixture before it reached
  the frame — so both now inject at the proxy's return, past the decoder
  that would otherwise normalise it away.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* test: drive the gateway-flip repush through the readiness loop

Upstream moved readiness refresh into its own task; the flip test now
exercises that loop directly instead of the removed tunnel helper.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: log nothing that addresses the router rendezvous

The redaction kept the session id and bridge path, which still name the
loopback endpoint whose advertisement carries the bearer token. The
start-up lines and the marker-failure notice now carry no values at all.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: make routing fail open in seconds, not in half a minute

Routing was already advisory everywhere it mattered, but the budgets meant
a wedged router still stalled the work it was supposed to get out of the
way of: a subagent spawn sat behind a 30s request inside a 40s hook kill,
and a first typed prompt sat behind 25s inside 45s. A fail-open that takes
that long is blocking in practice — the user cannot tell it apart from a
hang, and the turn they were promised runs no sooner for the wait.

Retune every routing ladder around one number: the routing call itself gets
5s, sized from the observed round trip (healthy routes:select answers in
~1.4-3s; the slowest sample on record was a gateway 500, not a verdict).
Each hop above it takes one more second, out to the harness-registered kill
at 15s (spawn gate 12s), which is now the only budget above single digits.
One attempt, no retry: a second try on an interactive path only doubles the
stall.

Two budgets on these paths were unbounded rather than merely long. The
built-in judge inherited the server `llm:` block's 300s request timeout,
multiplied by every configured fallback model, so picking the OSS router as
the source turned a fail-open into a multi-minute hang; it now shares the
external router's 5s. And the stale native model-options refresh, awaited
only to sharpen a routing candidate list, retries a booting runner for
~30s; routing now waits 3s for it and lets the single-flight finish filling
the cache on its own.

The CLI's preflight reads move off the create's 60s read budget too. They
answer in milliseconds and every failure already degrades to "unknown",
which does not gate, so there was nothing to win by waiting. The create's
own budget is left alone: that one is a session create, not a routing call.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: stop a routing outage from 500ing the turn it was routing

`route_turn` was the one routing seam that let its failure out. Its two
callers on the message path did not guard it, so a client that raised
instead of declining — a gateway 500 surfacing as HTTPStatusError, a read
timeout, a garbled body, a 401 — propagated to `POST /v1/sessions/{id}/
events` as a 500. By then the user's message had already been persisted, so
the turn was not merely unrouted: it was persisted and abandoned. Its
sibling `route_session_harness` has always returned an `error` string for
exactly this, which is what made the asymmetry easy to miss.

Add `route_turn_or_decline` as the turn path's fail-open boundary, in the
same `(model, verdict, error)` shape, and take the visible half of failing
open with it: the declined `routing_decision` card the auto-harness path
already emitted ("unavailable", applied=False) now covers the turn and the
native-pane paths too, so a session does not quietly ignore the toggle the
user turned on.

A failure deliberately does NOT stamp the routing-decision label. That label
is the route-once gate, so claiming it would turn one outage into the reason
the session never routes again — the failure is a card, not a decision.

Everything else audited on the routing paths was already fail-open and stays
untouched: the CLI's routed create and its auto-harness fallback, the
create-time server paths, the spawn-gate relay, both first-message hooks,
the loopback relays, both clients, and the model-switch application step.
The precondition gates that decline before anything starts are also left
alone — those are config rejections the owner asked for, not call failures.

Regression coverage for both properties (work proceeds, budget respected)
across gateway 500 / timeout / malformed body / 401 / unreachable relay, at
every call site: the SDK turn path, the native pane path, the spawn relay,
the first-message relay, both create paths, both hook scripts, both clients,
and the CLI's non-routing-400 fallback notice. Timing assertions are against
the ladder constants, never a wall clock.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: give a child spawn's failed route the same visible decline

`route_session_harness` returns its reason as an `error` string, and the
child-spawn branch of the message path unpacked it into `_route_err` and
then never read it. So the last routing path that could not route left no
card at all: the spawn ran on whatever the orchestrator had asked for, which
is right, but from the transcript "the router was down" and "the router had
no opinion" were the same thing.

Emit the same "unavailable" card the auto-harness and turn paths emit. Set
last, after the branch's own pin and publish, so nothing upstream can pin or
announce the placeholder — and leave the route-once label unclaimed, because
a child routes per spawn and `_child_routed_before` reads that label, so
stamping it on a failure would stop the child from ever being routed again.

The flag is renamed `_route_failed` now that both branches set it.

Also covers the bounded catalog wait: a stale-catalog refetch that never
finishes serves the stale vocabulary within `_ROUTING_CATALOG_WAIT_S` and
leaves the single-flight running to fill the cache for the next turn.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: let a pinned Smart Routing codex session actually spawn

Suppressing the create-time subagent-routing stamp for a native pinned-codex
session was justified on the theory that the switch would be inert there. It
was worse than inert: the pinned class was also withheld the spawn-routing
advertisement, and on codex that advertisement is what turns on the generated
``hooks.json`` ``spawn_agent`` gate AND the four routed-spawn tool
pre-approvals. A pinned Smart Routing codex session therefore had no spawn gate
and no pre-approved cross-session spawn tools, so its spawns did not merely go
unrouted — they stalled on an approval prompt nobody was watching.

Stamp every routed create again, and start the endpoint for a routed
codex-native launch whether or not the harness was auto-picked, which brings
the gate and the approvals with it. The codex SDK arm keeps the auto-harness
requirement: its spawns go through the session-create path, which already
routes off the stamped switch, so an in-harness gate would only add a round
trip. Plain sessions still get none of it.

What separates pinned from auto-harness is not whether spawns route but where
they may land: ``cross_harness`` stays ``auto_harness_session``, so a pinned
codex spawn is offered codex arms only and a claude pick is denied. The web
predicate now shows the gear's Subagent-routing row for exactly the classes the
server stamps.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: collapse a repeated routing verdict into one chip again

A Smart Routing create records its pick as a session-scope chip and the first
turn records the identical pick as a turn-scope chip; only the turn chip should
render. The pairing test asked whether the two decisions were ADJACENT, using
the same neighbour walk that decides where a chip sits relative to the message
it routes. That walk steps over exactly the blocks allowed between a chip and
its message, so anything else a booting session emitted between the two
decisions — narration, an earlier message, a whole finished response — read as
"unrelated" and both chips rendered.

Pair them by decision order instead: the next routing decision anywhere later,
across intervening blocks and turn-group boundaries. A turn chip that CHANGED
the pick, a declined create-time route followed by an applied one, and a spawn's
deny-then-honor pair all still render as two — the first two because the
verdicts differ, the last because a subagent-scope decision is never the
supersessor.

The incremental path had its own hole: the create chip is finalized into the
cached prefix frames before the turn chip exists, and the drop was computed only
from the walk's resume point, so a chip already in the prefix could never be
removed. The verdict set is now resolved over the whole transcript and
remembered on the cache, and a disagreement over the prefix forces the single
rebuild that removes the stale chip.

For the record, the resource_event in the reported transcript is not the
mechanism: an unknown item type yields no block from itemsToBlocks and
session_resource_created adds none on the live path, so it never separated the
two. The wire rows are kept as a funnel regression test regardless.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: keep a pinned session's spawns in its own harness family

A pinned Smart Routing codex session spawned a claude child and the router
pinned it to claude-sonnet-5: the in-harness spawn gate holds the in-family
line (candidate_models(cross_harness=False)) but the child-session route on
the native-terminal dispatch path had no such rule. It routed whatever
family the child's own pane ran, so an orchestrator that named another
family's wrapper agent got a cross-family spawn blessed by routing —
against the standing ruling that only an auto-harness session may cross.

The native child path now asks the same predicate the spawn gate does
(auto_harness_session(conv, parent)) and, for a pinned parent whose child
runs another family's CLI, routes nothing: no pin, no in-band /model, and a
declined chip naming the rule. The spawn itself still runs, on its CLI's
own model.

Also resolve a native pane's family from the terminal it is actually
running rather than an unresolved "auto" sentinel. The sentinel carries no
family, so a forced-auto child was offered every model its gateway serves
and could be pinned to one its running CLI cannot speak.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: render one routing chip per spawn, not two

One spawn produces two decisions — the in-harness gate sizes the task, then
the child session it created routes its own first message — and the
transcript showed both: one chip labelled "Session" (the gate row carries no
agent name) and one naming the spawned agent, with the same rationale. To
the owner that is one decision about one spawn.

The pair now collapses onto the child-session row, which is the informative
one: it names the spawned agent and the arm that actually ran, keeping the
gate's own pick visible as the router's raw verdict when a tier
substitution moved it (opus-4-8 -> opus-5). The two rows share no spawn id
— different decision ids, no agent on the gate row, minutes apart — so the
pairing key is the verdict: the same non-empty rationale AND the child
running the arm the gate picked. A deny-then-honor pair, two independent
spawns, and two genuinely different verdicts all still render as two chips.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

* fix: name the cause on a routing decline that had none

A live decline read "Routing unavailable (router request failed: )" — a
dangling colon with the reason missing. httpx's timeouts stringify to the
empty string, so the exception the fail-open budget produces most often was
also the one that said nothing. Every routing failure string now falls back
to the exception class ("router request failed: ReadTimeout"), which is what
a 5s budget firing looks like.

The subagent gate had a second way to lose the cause: a client that raises
before it can record its own last_error left the chip saying only "router
returned no verdict", with the real failure in the server log alone. It now
carries the raised cause when the client reported none.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>

---------

Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-08-05 15:34:54 -07:00
Corey Zumar 590b2b6376 fix(sandbox): supervise the in-sandbox host so a crash can't strand the box (#4155)
* fix(host): keep the tunnel receive loop responsive during readiness refresh

The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.

Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.

Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.

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

* fix(sandbox): supervise the in-sandbox host so a crash can't strand the box

A sandbox container outlives the host process: PID 1 is `sleep infinity` or
the provider's own init, never `omnigent host`. So when the host dies the
container stays healthy and still billing, with nothing running in it.
Nothing notices until the next message, and the only recovery is
`relaunch_managed_host` re-provisioning a fresh sandbox — which discards the
workspace: the clone, the installed dependencies, the harness state.

Wrap every exec-model host launch in a restart loop at the one seam all
providers funnel through (`run_background`), so a crashed host restarts in
place and the workspace survives. No image changes, no init system, no new
privileges — replacing PID 1 across seven provider images would mean booting
systemd with cgroup mounts, which the Kubernetes Pod's "restricted" security
posture forbids outright.

To make restarting safe, give a permanent startup failure its own exit code
instead of sharing 1 with generic crashes: without it, a revoked or expired
launch token inside a remote sandbox becomes an invisible hot restart loop
with nobody watching a terminal. The supervisor stands down on that code, on
a clean exit, and on SIGTERM; anything else is a crash, retried with a
doubling delay capped at 30s.

OpenShell keeps its held exec stream — it reaps an exec's processes when the
RPC returns, so `setsid nohup` genuinely cannot work there — but gains the
same supervisor inside that stream. Kubernetes is untouched: it is
entrypoint-as-host with a deliberate `restartPolicy: Never`, recovering by
provisioning a replacement Pod rather than restarting in place.

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

* fix(sandbox): make the supervisor's stop contract and backoff cap explicit

Review follow-ups on the in-sandbox host supervisor.

A signal-kill of the host alone (SIGKILL -> 137) stays classified as a crash on
purpose: that is what an OOM kill looks like, and restarting is the wanted
response. The consequence is that a path meaning to STOP the host must signal
the supervisor too, or the loop faithfully restarts it. Both in-sandbox stop
paths already do — `foreground_kill_command` signals the pidfile's recorded pid
(the supervisor, which the host `exec`s under), and islo's preserved-daemon stop
matches "omnigent host" against full argv, which the supervisor's own `sh -c`
argv contains. Documented so a future narrowing of either match doesn't silently
turn a stop into a restart loop.

The loop deliberately has no attempt ceiling — giving up would restore the
stranded-empty-box failure it exists to prevent — so add an attempt counter to
the restart log, making a persistently crashing host observable instead of an
indistinguishable repeat.

Cover the backoff clamp with a test asserting the full delay sequence
(1, 2, 4, 8, 16, 30, 30, 30), and point the `_harness_cli_version_string`
timeout example at READINESS_CLI_PROBE_TIMEOUT_S instead of a stale literal
that disagreed with it.

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

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-05 12:31:00 -07:00
Corey Zumar 046ee1bc59 fix(host): keep the tunnel receive loop responsive during readiness refresh (#4092)
* fix(host): keep the tunnel receive loop responsive during readiness refresh

The host->server tunnel disconnected with `4003 ping timeout`: the periodic
harness-readiness refresh ran inline on the receive loop and could block it for
~60s — two 30s CLI probe subprocesses (`--version` and `auth status`) hanging on
a wedged harness CLI. While blocked, the host never answered the server's
application-level pings, so the server watchdog declared the host dead and
closed the tunnel.

Fix A (host/connect.py): move the readiness refresh into its own task,
`_harness_readiness_loop`, so the receive loop only ever reads frames and
answers pings — a slow probe can no longer stall the keepalive.

Fix B (harness_install.py, harness_readiness.py, codex_native.py): bound
readiness CLI probes to READINESS_CLI_PROBE_TIMEOUT_S (10s, matching goose's
status-probe budget) instead of 30s, so a hung harness CLI fails fast on the
refresh, the startup hello, and Ctrl-C. Setup and launch gating keep the lenient
30s default via behavior-preserving timeout parameters.

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

* test: accept the readiness probe timeout kwarg in harness CLI stubs

Readiness now calls harness_cli_installed / harness_cli_logged_in with a timeout= kwarg (READINESS_CLI_PROBE_TIMEOUT_S); update the monkeypatch stubs in the affected suites to accept it so they exercise the same paths.

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

* test(host): cover off-loop readiness refresh and bounded CLI probe

Fix A moved the harness-readiness refresh off the tunnel receive loop into
_harness_readiness_loop. Rewrite the three live-host readiness tests to drive
that loop directly: the old versions drove _serve_frames with a fake tunnel
that blocks on recv, which under the pure recv loop never exits and hangs to
the pytest timeout. Add a harness_install test asserting the readiness caller
shortens the CLI probe subprocess timeout while setup/launch keep the 30s
default.

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

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
2026-08-05 11:43:21 -07:00
Tomu Hirata 566fc5bb5a perf(runner): bound per-runner memory via glibc arenas + threadpool cap (#3901)
* perf(runner): bound per-runner memory via glibc arenas + threadpool cap

Each session spawns its own runner process, and each grows to ~200MB in
prod, over-using host resources. Profiling shows ~123MB is the irreducible
import floor; the growth on top is runtime bloat from threaded Python on
glibc: the runner offloads heavily via asyncio.to_thread, the default
executor sizes to min(32, cpu+4) threads, and glibc opens up to 8*ncpu
malloc arenas that never return to the OS. Nothing tuned any of this.

Three low-risk, env-gated levers (all no-ops or benign off Linux):

- MALLOC_ARENA_MAX=2 + a 128 MiB trim threshold, injected into the runner
  child env at both spawn sites via a shared _proc.malloc_tuning_env()
  helper. Empty off Linux; OMNIGENT_RUNNER_MALLOC_ARENA_MAX=0 reverts.
- Cap the asyncio default executor at 8 workers (runner.threadpool_max_workers
  config key, OMNIGENT_RUNNER_THREADPOOL_MAX_WORKERS env override), set before
  any to_thread use so the 20-thread default pool is never created.
- gc.freeze() after app construction to drop the static import graph from
  GC's tracked set.

This targets the runtime growth, not the import floor; collapsing the floor
itself (a copy-on-write zygote) is tracked separately.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): apply the glibc arena cap at the zygote exec

The zygote forkserver landed and is now the default runner spawn path, which
silently defeated this branch's MALLOC_ARENA_MAX injection. glibc reads that
variable once, when its allocator initializes at exec; a zygote-forked runner
never execs, it just replaces os.environ, so the value arrived far too late to
configure an allocator and the cap stopped applying to every runner.

Move the injection to the zygote's own Popen -- the single real exec on this
path -- so all forked runners and harnesses inherit an already-capped
allocator. Two tests pin the contract at that boundary, including that an
operator's explicit export still wins.

The other two levers on this branch (the 8-worker threadpool cap and
gc.freeze()) live inside _run_tunnel_from_env, which every runner reaches
regardless of how it was started, so they were unaffected. Note in
malloc_tuning_env why the arena cap is glibc-only: macOS libmalloc uses
per-CPU magazines with madvise reclaim and ignores MALLOC_ARENA_MAX, so macOS
hosts get their reduction from the threadpool cap (measured: 21 threads -> 9).

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-04 13:49:59 +00:00
Tomu Hirata 4d4ddb2617 feat(runner): copy-on-write zygote forkserver for runner processes (#3921)
* spike(runner): measure copy-on-write savings from a warm-fork zygote

Each session spawns its own runner process, and each pays a ~123MB import
floor for omnigent's own graph plus pydantic/fastapi/httpx. Runtime tuning
trims the growth on top but can't touch that floor; the only way to collapse
it is to import the graph once in a warm parent and os.fork() a child per
session, sharing the read-only import pages copy-on-write.

This standalone script measures whether that COW sharing actually
materializes before we commit to the full zygote architecture. It imports the
runner graph once, forks N idle children, and reports aggregate memory against
an N-process Popen baseline, optionally with gc.freeze().

Not wired into the daemon — this is a measurement gate, not a feature. On this
macOS box (N=8) the fork path showed ~82% lower aggregate footprint than the
Popen baseline, but macOS phys_footprint is only an indicative analog to Linux
Pss and the children idle (no COW erosion from refcount page-dirtying), so a
Linux-under-load measurement is still required before productionizing.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* feat(runner): add copy-on-write zygote forkserver for runner processes

Every session spawns its own runner, and each pays the full ~120MB import
floor (omnigent's graph + pydantic/fastapi/httpx). On a host running N
sessions that floor is duplicated N times. This adds a zygote: a single
long-lived process that imports the runner graph once and os.fork()s a child
per session, so on Linux the read-only import pages are shared copy-on-write
and each extra runner costs only the pages it dirties.

Design (grounded in the daemon/runner lifecycle, not the naive sketch):

- omnigent/runner/_zygote.py — the forkserver. Single-threaded, no event loop
  or network; imports the graph once, gc.freeze()s it, then blocks on an
  AF_UNIX control socket forking a child per request. The child reopens its
  log, replaces os.environ with the request env, and calls the unchanged
  _entry.main() — so it behaves exactly like `python -m omnigent.runner._entry`.
  It is Popen-exec'd by the daemon (never forked from it), so it inherits none
  of the daemon's asyncio loop / websocket / worker threads — the classic
  fork-in-multithreaded-async deadlock is avoided by construction.

- omnigent/host/runner_zygote.py — the daemon-side client. ZygoteManager owns
  the control socket; ZygoteRunnerProc is a Popen-shaped shim so the existing
  _RunnerHandle / _watch_runner / _handle_stop paths are unchanged. The daemon
  is NOT the forked runner's parent, so poll()/returncode/wait() round-trip to
  the zygote (the real parent) for exit status while terminate()/kill() signal
  the pid directly.

- connect.py — _handle_launch forks via the zygote when enabled, else the
  original Popen. RUNNER_PARENT_PID is set to the ZYGOTE's pid (not the
  daemon's) because the runner's orphan watchdog compares os.getppid(); daemon
  death -> control-socket EOF -> zygote exit -> runners reparent -> each tears
  itself down, preserving today's parent-death semantics through one hop.

Gated behind OMNIGENT_RUNNER_ZYGOTE=1 and Linux-only; any zygote failure
disables it for the daemon's life and falls back to a direct Popen, so it is
never a hard dependency. Also removes the Phase-1 measurement spike script,
which this supersedes.

Verified on macOS: a real zygote subprocess forks children, reports pids and
exit codes, isolates per-fork env, reaps cleanly, and tears down on stop
(fork works on macOS even though the COW savings are Linux-only). The
production memory win and a full session-through-the-tunnel run are unverified
here — they need a Linux host under load, which this change is written to be
turned on for.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): address zygote review feedback

- connect.py: a failed fork no longer stops the running zygote. Stopping it
  would kill healthy runners already forked from it (their orphan watchdog
  sees the parent die), so one bad fork could take down unrelated live
  sessions. Latch a `_zygote_disabled` flag for future launches instead and
  retain the manager so the zygote is still reaped on daemon shutdown.
- runner_zygote.py: wait() after kill() in stop() so a zygote that ignored
  SIGTERM is reaped rather than lingering as a zombie.
- _zygote.py: unify the _entry/app/native import to a single `from ... import`
  (CodeQL flagged mixed import styles).
- test: build the fresh-interpreter probe via an explicit newline join instead
  of implicit adjacent-string concatenation (CodeQL).

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): forward --log-to-stderr TTY fd through the zygote

The direct-Popen launch path forwards OMNIGENT_LOG_TTY_FD via
child_logging_popen_kwargs so a detached runner can still mirror logs to the
daemon's terminal. The zygote path dropped it, so --log-to-stderr mirroring
was lost for zygote-forked runners.

Forward it across both hops:
- daemon -> zygote: reuse child_logging_popen_kwargs to dup the TTY fd and add
  it to the zygote's pass_fds (the helper also rewrites env[LOG_TTY_FD] to the
  duped number).
- zygote -> forked runner: the valid fd number inside the child is the one the
  zygote inherited, not the daemon-side number the payload carries, so the
  child restores LOG_TTY_FD from the zygote's own value (and clears a stale
  payload value when the zygote has no terminal mirror).

Adds a test asserting a bogus payload LOG_TTY_FD is cleared in the child.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): address second round of zygote review feedback

- _zygote.py: create the forked child's log file 0o600, not 0o644. Runner
  logs can carry secrets (tokens, prompts); matches create_process_log_path.
- _zygote.py: the child guard now preserves SystemExit's code instead of
  flattening it to a traceback + exit 1, so a zygote-forked runner exits with
  the same code as `python -m omnigent.runner._entry` (main() raises
  SystemExit on a tunnel rejection). New test covers it via a raise seam.
- runner_zygote.py: stop the partially-started zygote if the initial ping
  raises (timeout / EOF), so a failed start never leaks a process + socket.
- runner_zygote.py: signal via signal.SIGTERM / signal.SIGKILL instead of the
  raw 15 / 9.
- test: mark the suite posix_only (it uses os.fork / pass_fds) so cross-
  platform sweeps skip it on Windows.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* feat(runner): enable the zygote on all POSIX hosts, not just Linux

The host daemon runs on the user's own machine — most often macOS — so a
Linux-only gate denied the copy-on-write import-floor savings to the majority
of hosts. Gate on IS_POSIX instead (the zygote needs os.fork + AF_UNIX
fd-passing, both POSIX; Windows still takes the direct Popen path).

macOS is the platform where fork-without-exec is riskiest (CoreFoundation/GCD
abort a forked child that touches them), so this was verified rather than
assumed. The abort is triggered by forking from a MULTI-threaded process, which
the zygote already designs against: it forks from a single-threaded parent
(asserted active_count()==1) and does create_app + all network work in the
child. Evidence on this macOS box:

- A faithful fork probe (fork from the single-threaded import state, child runs
  create_app + getaddrinfo + TLS ctx + asyncio + httpx) survived 5/5. The same
  work forked from a multi-threaded parent SIGSEGV'd 2/3 — confirming the
  single-threaded fork is what makes it safe.
- test_host_launch_runner_and_session_round_trip passes with
  OMNIGENT_RUNNER_ZYGOTE=1: a real host daemon forks a runner through the
  zygote, the runner connects its tunnel, and a full mock-LLM session round-trip
  completes. The daemon log confirms the zygote path (distinct zygote/runner
  pids), not a Popen fallback.

Also adds an info log on the successful zygote-fork path so operators can see
the zygote is active and which pids are involved.

Still opt-in behind OMNIGENT_RUNNER_ZYGOTE=1 with the full Popen fallback; the
steady-state Pss win under load remains best measured on a Linux host.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* feat(runner): fork harness subprocesses from the runner zygote

The harness subprocess (`python -m omnigent.runtime.harnesses._runner`) is a
separate exec per conversation, so it re-pays its import floor — and that floor
is ~54MB of the same common graph (fastapi/pydantic/omnigent-core) the runner
zygote already holds resident. This extends the zygote to fork harness children
too, sharing that graph copy-on-write instead of exec'ing a fresh interpreter.

- _zygote.py: the serve loop becomes a single-threaded `selectors` multiplexer
  over the daemon socket PLUS one inherited control socket per forked runner.
  A new `fork_harness` command forks a child that reproduces `_runner.main(argv)`
  in-process. The runner-fork request/response bytes are unchanged; the new
  multiplexer wraps them rather than rewriting them. A forked child closes every
  inherited zygote-side socket (it never speaks the fork protocol).
- _harness_zygote_client.py (new): the runner-side client. `HarnessZygoteClient`
  reads the inherited control-socket fd from OMNIGENT_RUNNER_ZYGOTE_HARNESS_FD;
  `ZygoteHarnessProc` is an asyncio.subprocess.Process-shaped shim (pid /
  returncode / wait / send_signal / kill) with a background poll task keeping
  returncode fresh for _wait_for_bind's synchronous reads.
- process_manager.py: `_spawn_harness_process` forks via the zygote when the
  runner was itself zygote-forked, else the original create_subprocess_exec;
  disabled on first failure so it falls back for the process's life.
- _runner.py: a zygote-forked harness has the zygote (not the runner) as OS
  parent, so its watchdog probes the runner pid explicitly instead of trusting
  os.getppid(), and skips PR_SET_PDEATHSIG (which would bind death to the
  zygote). Gated by OMNIGENT_HARNESS_ZYGOTE_FORKED.

Present only when the runner itself was zygote-forked; any failure falls back to
a direct exec, so the harness fork is never a hard dependency. The win is
bounded to the ~54MB Python wrapper (the external claude/codex CLI is a separate
exec no Python zygote can share) and materializes under multi-conversation
fan-out. Verified on macOS: fork_harness forks, reports pid + exit code,
round-trips argv, reaps, and leaves the daemon socket serving; existing
process_manager tests unchanged. Linux Pss savings still unverified.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): clear pyrefly type errors in the zygote

- ZygoteRunnerProc.wait: narrow on `timeout` (not just `deadline`) so
  TimeoutExpired(timeout=...) gets a `float`, not `float | None`.
- _spawn_zygote_process: pass stdin/stdout/stderr explicitly with a typed
  `BinaryIO | None` log handle instead of a `dict[str, object]` splat that
  matched no Popen overload.
- _ZygoteServer.serve: cast selector key.fileobj (HasFileno | int) to socket
  — only sockets are ever registered.
- _ZygoteServer._on_readable: wrap the bytearray partition result in bytes()
  before dispatch, which expects bytes.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): harden zygote failure paths (crash recovery, exit-code leak)

Review flagged three correctness bugs in the unhappy lifecycle paths; none are
security issues but each is reachable in prod.

1. Unexpected zygote crash stranded the daemon's view of every child. The
   daemon isn't the runner's OS parent, so once the zygote died it had no
   channel to learn a runner exited — ZygoteManager.poll returned None
   ("still live") forever, so _watch_runner looped, _handle_runner_status
   reported gone sessions as alive, and _handle_stop's final wait() could hang.
   Now poll() probes the runner pid directly when the zygote is gone: a dead
   pid surfaces a non-zero sentinel (254) so the runner reads as dead-and-
   failed, not eternal alive. _handle_stop's post-kill wait() is now bounded.

2. _exit_codes leaked for a dropped runner's harness children. Exit codes were
   only popped via poll, but a dropped runner's harnesses have no remaining
   client to poll them — the entries accumulated (unbounded map growth +
   pid-reuse misattribution). _drop_runner now discards those descendants'
   codes and marks still-live ones orphaned: _reap waitpid's them (no zombies)
   but discards the code instead of storing it.

3. ZygoteHarnessProc.wait() masked a crashed harness as exit 0. If the zygote
   went away, wait() returned 0, so a harness that crashed on boot (bind
   failure, import error) read as a clean exit and the process manager could
   hang waiting for a bind that never comes. Now probes the harness pid and
   returns a non-zero sentinel when the code is unrecoverable.

Also: tighten "Linux-only" docstrings to "POSIX; COW savings on Linux" (the
gate is IS_POSIX and the path runs on macOS), and add a sleep test-seam so the
new failure-path tests can hold a child genuinely alive.

Tests: kill the zygote under a live runner and assert the daemon eventually
sees it dead (not hanging); a dropped runner's harness code is not retained; a
crashed harness with an unrecoverable code surfaces as failure, not 0.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): keep zygote poll/wait off the daemon event loop

Review flagged a liveness regression on the enabled path: for a zygote-forked
runner, poll()/wait() are blocking control-socket round-trips (with lock
contention against a booting zygote that holds the lock across its ~120MB
import), not the lock-free waitpid the direct-Popen path used. Calling them on
the loop thread could freeze the whole daemon — all sessions, websocket
traffic, heartbeats — until the import finishes or the 30s control timeout
elapses.

- _watch_runner: poll() now runs via asyncio.to_thread.
- _handle_stop: now async; the poll/terminate/wait sequence runs off-loop in a
  _stop_runner_proc helper. Its dispatch site and three tests updated to await.
- _tracked_runner_pids: include the zygote pid so the orphan reaper never
  waitpid's the zygote out from under ZygoteManager._proc on an unexpected
  crash (which would confuse is_running()/stop()).

Also updates test_poll_after_stop to use a live child, since the crash-recovery
sentinel (254) now correctly fires for an already-exited pid after stop().

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): status query off-loop + enable zygote by default

- _handle_runner_status did its poll() on the event loop, the one place the
  PR hadn't moved off it. For a zygote-forked runner poll() is a blocking
  control-socket round-trip (bounded only by the 30s control timeout, and
  contended against a booting zygote), so a slow zygote could stall the whole
  daemon for a single status query. Made it async and run the poll via
  asyncio.to_thread, matching _watch_runner / _handle_stop. Dispatch site and
  the three status tests updated to await.

- Enable the zygote by default: OMNIGENT_RUNNER_ZYGOTE is now opt-OUT
  (=0/false/no/off), not opt-in. The host daemon runs on the user's own
  machine (most often macOS), so defaulting on lets most users share the
  ~120MB import floor. Still POSIX-gated with a full Popen fallback, so an
  unsupported platform or any zygote failure is transparent.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix: prevent mid-spawn launch leaks and harden zygote request handling

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-04 22:16:01 +09:00
Serena Ruan 3cc3777413 fix(host): forward OMNIGENT_RUNNER_ENV_PASSTHROUGH through the remote daemon (#4050)
OMNIGENT_RUNNER_ENV_PASSTHROUGH lets an operator name extra env vars for the
host to forward on to spawned runners (provider gateway wiring, config env: refs,
etc.). It worked locally but was a silent no-op in --server mode: the remote
daemon env is allowlisted by a prefix set of DATABRICKS_ + LC_/MLFLOW_/OTEL_/
OMNIGENT_OTEL_ — NOT plain OMNIGENT_ — so the control var itself was stripped at
the CLI->daemon hop, and _build_runner_env never saw the names it listed. Any var
forwarded through the passthrough (e.g. a Linear API key for the repro-agent)
reached the runner locally but never remotely.

Add OMNIGENT_RUNNER_ENV_PASSTHROUGH to _RUNNER_ENV_ALLOWLIST so it survives both
hops. It carries only env var NAMES, not secrets, so allowlisting it leaks
nothing on its own — each named var must still independently reach the daemon
(here via the DATABRICKS_ prefix).

Tests: a daemon-hop test (remote env keeps the control var) and an end-to-end
two-hop test (a named var survives CLI->daemon->runner, an unnamed one doesn't).
Both fail without the one-line allowlist change.

Co-authored-by: Isaac
2026-08-04 19:59:38 +08:00
Tomu Hirata cd5bcd2d04 fix(host): recover from workspace-missing runner launch failures (#4023)
When a session's workspace directory no longer exists on the host
(e.g. a worktree was deleted), the host was returning a generic
failed status with no error_code, causing the server to silently
wait out the full connect timeout and then surface a generic
'runner_failed_to_start' banner.

Changes:
- Add WORKSPACE_MISSING_ERROR_CODE ('workspace_missing') to host/frames.py
- Host returns this code when workspace.is_dir() fails, alongside the
  existing descriptive error message
- Server (routes_events.py post_event) handles workspace_missing the same
  way as harness_not_configured: immediately consumes the user message and
  persists an actionable runner_failed_to_start error item with the host's
  'workspace path does not exist: ...' message instead of timing out into
  a generic RUNNER_UNAVAILABLE
- orchestration.py _ensure_runner_relay_ready skips the connect-timeout
  wait for workspace_missing (same as harness_not_configured), and records
  the refusal in runner_exit_reports so snapshot-based renders also show
  the actionable cause

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-04 17:09:57 +09:00
Tomu Hirata 21febb6cc8 fix(host): retry 401/403 on an already-connected host (#4025)
When the VPN drops, a corporate proxy answers the host tunnel's
WebSocket upgrade with 401/403 before the request reaches the Omnigent
server. `_classify_http_status` treated those as permanently fatal, so a
live, already-registered host exited with code 1 and the user had to
re-run `omnigent host` after reconnecting.

A host that already completed an upgrade proved its credentials and
authorization are valid, so a later 401/403 is almost always a transient
network-path artifact. For a connected host, 401/403 now retries forever
via the normal reconnect path (mirroring the existing login-redirect
design), with a once-per-outage stderr notice so a foreground
`omnigent host` isn't silent. A fresh, never-connected host still fails
loud on the first 401/403.

Fixes OMNI-2367.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-04 07:37:19 +00:00
Pat Sukprasert 1262652a03 chore(lint): enforce pyrefly type checking (#3972)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-08-03 23:32:26 +07:00
Pat Sukprasert 0e46accde4 narrow lazy import boundaries (#3941)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-08-03 20:04:29 +07:00
Pat Sukprasert bc12d9a881 fix typing for subprocess handles (#3935)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-08-03 18:59:26 +07:00
Tomu Hirata c74faadc1e fix(runner): forward provider api_key_ref env vars into runner subprocess (#3915)
* fix(pi): surface credential resolution error when gateway provider's env var is unset

When a `kind: gateway` provider is configured as the pi harness default
via `default: pi` and its `api_key_ref: env:VAR` cannot resolve (because
VAR is not exported in the runner's environment), `_optional_provider_family`
previously caught the OmnigentError from `resolve_secret` and returned None
silently. The outer `_apply_provider_to_pi` then raised a generic
"no family whose credentials resolve — set the api_key env var for its
'anthropic' or 'openai' family" message with no mention of which specific
variable to export, making the error hard to act on.

Change `_optional_provider_family` to return the captured error alongside
None (as a tuple), and surface that error in the "no family resolves"
message so the user sees exactly which env var (e.g. `$MY_TOKEN` from
`api_key_ref: env:MY_TOKEN`) needs to be set.

The design intent of the silent catch is preserved: a family whose key is
unset is still treated as absent so pi can fall back to the other family
when only one key is exported. The only change is that the fallback-failure
error now carries the root cause.

Closes #3788

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* address review: fix return type annotation, correct keychain docstring, remove issue refs from tests

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(runner): forward provider api_key_ref env vars into runner subprocess

_build_runner_env filters the host environment before spawning the runner
subprocess, passing only an allowlist of known credential vars
(ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.). A user who configures a gateway
provider with a custom env var via api_key_ref: env:MY_TOKEN would find that
MY_TOKEN is present in their shell and daemon process but stripped before
reaching the runner — resolve_secret then fails, _optional_provider_family
returns None for the family, and _apply_provider_to_pi raises the no-family-
resolves error.

Add provider_credential_env_vars(config) to provider_config.py, which scans
all inline-family providers for api_key_ref: env:VAR and api_key: $VAR
references and returns the set of env var names (plus OMNIGENT_-prefixed
aliases). Wire this into _build_runner_env so those vars are automatically
forwarded alongside the standard HARNESS_CREDENTIAL_ENV_VARS, without
requiring users to list them in OMNIGENT_RUNNER_ENV_PASSTHROUGH by hand.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(pi): add authHeader to generic openai provider entries in models.json

Generic (non-Databricks) OpenAI-compatible gateways expect
Authorization: Bearer <token>. The 'databricks' and 'databricks-completions'
provider entries in the generated models.json were missing authHeader: True
on the generic provider path, so Pi used the Databricks-native auth scheme
instead — causing a 401 Missing Authentication header from the gateway.

Add authHeader: True to both entries when is_generic_provider is true,
matching the pattern already used by databricks-openai, databricks-anthropic,
and databricks-mlflow.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(pi-native): qualify namespaced model ids in --model arg to prevent builtin routing

When a gateway provider's model id contains a slash (e.g. an OpenRouter
namespaced id like 'moonshotai/kimi-k2.5'), Pi's arg parser treats
'provider/model' in --model as a provider override, routing to the builtin
'moonshotai' provider instead of our custom 'omnigent' provider. The builtin
has no API key, producing 'No API key for provider: openai-codex'.

Pass the fully-qualified 'provider/model' form (e.g.
'omnigent/moonshotai/kimi-k2.5') when the model id contains a slash, so
Pi's findExactModelReferenceMatch matches the canonical form under our
provider first.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-03 11:39:39 +00:00
Pat Sukprasert e711e907a8 refactor: tighten host process typing (#3773)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-31 17:14:10 +00:00
Pat Sukprasert c6cd36cad2 Filter Codex picker to compatible OpenAI models (#3668)
* fix codex launch model compatibility filtering

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(codex): tolerate model discovery failures

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-31 08:06:39 +00:00
Pat Sukprasert e662555092 feat(web): select Codex model before launch (#3556)
* feat(web): select Codex model before launch

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix codex databricks default model label

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-31 06:21:56 +00:00
Pat Sukprasert f3d28c8e71 fix(host): guard orphan reaper against missing os.WNOHANG on Windows (#3627)
The host orphan reaper's waitpid fallback uses os.WNOHANG and
os.waitpid(-1, ...), neither of which exists/works on native Windows.
Windows also has no child reparenting to a subreaper, so there is
nothing to reap. The periodic sweep swallowed the resulting
AttributeError, but the final drain in run()'s finally block runs
unguarded and would crash shutdown.

Return early with 0 when os.WNOHANG is absent, matching the reaper's
own "non-Linux is a no-op" contract.

Co-authored-by: Isaac
2026-07-31 02:11:38 +00:00
Manfred Calvo cc39c4eac1 fix(tunnel): give websocket clients a verifying SSL context (certifi/OS-trust fallback) (#1731)
* fix(tunnel): give host/runner websocket tunnels a verifying SSL context

On interpreters whose OpenSSL default cert path is uninitialized (python.org
macOS framework builds before Install Certificates.command, and
python-build-standalone interpreters used by uv), ssl.create_default_context()
loads zero trust roots, so the host and runner wss:// tunnels failed with
CERTIFICATE_VERIFY_FAILED and looped on reconnect.

Add omnigent/tls.py (resolve_ca_file + cached client_ssl_context) that resolves
a CA bundle OS-trust-store-first with a certifi fallback, and pass that context
to both tunnel websockets.connect calls for wss:// (ws:// stays ssl=None).
egress/ca.py:_system_ca_bundle now shares resolve_ca_file; certifi is promoted
to an explicit dependency.

Closes #1730

Co-authored-by: Isaac
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>

* fix(claude-native): pass a verifying SSL context to wss:// terminal-attach

_websocket_connect opened wss:// terminal-attach connections (the scheme
terminal_attach_url produces from an https workspace base_url) with a bare
default SSL context, so claude-native attach to a remote workspace hit the same
empty-trust-store failure fixed for the tunnels. Route it through
client_ssl_context() for wss:// (ws:// stays ssl=None). Also realign a
ws_tunnel test with the databricks_request_headers rename from main.

Closes #1730

Co-authored-by: Isaac
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>

* chore(deps): record certifi in uv.lock

pyproject.toml promoted certifi to an explicit dependency; add it to the
omnigent package's dependencies and requires-dist in uv.lock so
"uv sync --locked" passes in CI. certifi was already resolved transitively,
so its package entry (with hashes) is unchanged — this only records the
direct dependency edge.

Co-authored-by: Isaac
Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>

---------

Signed-off-by: manffred-calvosanchez_data <manffred.calvosanchez@databricks.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-30 12:32:37 +09:00
Zeyi (Rice) Fan f2b2f80948 refactor(server)!: remove deprecated OMNIGENT_ACCOUNTS_ENABLED env alias (#3322)
## Related issue

N/A

## Summary

- Remove the long-deprecated `OMNIGENT_ACCOUNTS_ENABLED` environment-variable alias for the multi-user auth enable switch. The canonical name `OMNIGENT_AUTH_ENABLED` has existed since the repository was open-sourced.
- Strip the alias logic from `omnigent/server/auth.py::_auth_enabled()`, the explicit-auth check in `omnigent/cli.py::_apply_bind_auth_defaults()`, and the runner env-propagation allowlist in `omnigent/host/connect.py`.
- Delete the tests that exercised the alias and the obsolete comment in `tests/conftest.py`.

## Test Plan

- `uv run ruff check omnigent/server/auth.py omnigent/cli.py omnigent/host/connect.py tests/conftest.py tests/cli/test_bind_auth_defaults.py tests/server/test_accounts.py tests/e2e/test_local_server_lifecycle_e2e.py` passed.
- `uv run pytest tests/cli/test_bind_auth_defaults.py tests/server/test_accounts.py -q --no-header` passed (95 items).
- `uv run pytest tests/server/test_accounts.py::test_resolve_auth_source_defaults_to_header tests/server/test_accounts.py::test_resolve_auth_source_opt_in_selects_accounts tests/server/test_accounts.py::test_factory_defaults_to_header_when_env_unset tests/cli/test_bind_auth_defaults.py -q --no-header` passed (15 items).
- Verified no remaining references with `grep -R "OMNIGENT_ACCOUNTS_ENABLED" . --exclude-dir=.git --exclude-dir=.venv`.

## Demo

N/A

## Type of change

- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [x] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Removed the tests that specifically covered the deprecated alias; remaining tests continue to validate `OMNIGENT_AUTH_ENABLED` behavior. The refactor does not change the `OMNIGENT_AUTH_ENABLED=1 | =0` semantics.

## Changelog

[Breaking] The deprecated `OMNIGENT_ACCOUNTS_ENABLED` environment variable has been removed; use `OMNIGENT_AUTH_ENABLED` instead.

BREAKING CHANGE: Users and deploys still setting `OMNIGENT_ACCOUNTS_ENABLED` must rename the variable to `OMNIGENT_AUTH_ENABLED` before upgrading; the old name is no longer read or propagated.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 03:35:35 +00:00
Cathy Yin 76281b9438 feat(onboarding): write a harness provider credential from the UI (M3 backend) (#3088)
CI / gate (push) Failing after 6s
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
* feat(onboarding): report the installed-but-unconfigured harness state

Add the credential axis to the picker-facing readiness map so Claude, Codex,
and Pi report the yellow "installed but no credential" state — the signal the
web setup dialog needs to know when to offer an "Add credential" action
(the M2 keystone of Setup-From-the-UI). Purely additive: the values
("needs-auth" / "binary-missing") already exist in HarnessAvailability, and
the launch gate (harness_is_configured) stays binary-only, so a not-yet-authed
harness is never blocked from launching.

- New _family_provider_configured(): whether an omnigent-managed provider
  (API key / gateway) serves the harness's family, reading the same config
  omni setup's overview does. Subscription-kind is excluded (that lives in the
  CLI's own login, judged by harness_cli_logged_in). Local, side-effect free,
  never raises (fails to "no credential").
- Claude: ready when a provider is configured OR the CLI subscription login is
  present (was CLI-login only — an API-key-only user wrongly showed yellow).
  Checks config first to avoid the status subprocess on the common path.
- Pi: gains the axis it lacked entirely (was binary-only → always green once
  installed). No CLI login, so binary + provider: installed-but-no-provider is
  now "needs-auth".

Codex already had this (unchanged, the template). Qwen/OpenCode env-auth
unchanged.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(onboarding): write a harness provider credential from the UI

Second PR of Setup-From-the-UI (M3, security-sensitive). Adds the path that
turns a yellow "installed but not configured" harness green from the browser
for Claude / Codex / Pi, host-agnostic (local or remote), reusing the
credential-write logic omni setup already uses.

Design: the server is an authz'd pass-through. It validates ownership + the
UI-auth allowlist and forwards the secret over the (TLS) tunnel; the host DAEMON
does the write on the runner. The server never persists the secret, and the
frame's secret_value field is redaction-named so it never lands on a telemetry
span. Gated behind OMNIGENT_HARNESS_INSTALL_ENABLED (default off) exactly like
the install route (404 when disabled).

- New non-interactive core omnigent/onboarding/harness_auth.py: store a key /
  gateway (secret → keychain, else ~/.omnigent/secrets.json; a providers: entry
  referencing keychain:<name>, never the raw key), adopt an existing host env
  var by reference (env:<VAR>, value never read), and detect adoptable env
  credentials (non-secret descriptors only). First provider on a family becomes
  the default; unsupported families/kinds are refused.
- New host.store_secret / _result frame pair; host daemon handler resolves the
  harness→family, calls the core, and re-reports readiness so the badge flips
  without a reconnect. Pi maps to its preferred anthropic family.
- New route POST /v1/hosts/{id}/harnesses/{harness}/credential (owner-scoped,
  allowlisted, flag-gated) + registry pending_secret_writes plumbing + tunnel
  result resolution.
- Regenerated openapi.json.

Tests: core unit tests (incl. the no-raw-secret-in-config invariant), frame
round-trip + telemetry-redaction, host-handler unit tests, and a full route
integration test over a fake tunnel (ownership, flag-off, allowlist, failure
mapping). 243 pass across the affected suites.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(onboarding): detect adoptable credentials on the host (adopt flow)

Adds the read side of the adopt flow: a host.detect_credentials frame pair +
GET /v1/hosts/{id}/credentials/detected that reports the credentials already
present on the host as NON-secret descriptors (family + source label + env var
name), so the UI can offer a one-click "adopt" instead of asking the user to
paste a key they already have. The value is never read or sent — adopt writes
an env:<VAR> reference via the existing store_secret path.

Owner-scoped + flag-gated like the credential-write route. Decode drops
malformed entries so a garbled payload can't inject a non-string field the UI
would trust. Adds frame round-trip (+ malformed-drop), host-handler, and route
integration (+ flag-off) tests; regenerated openapi.json.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(onboarding): tighten the credential route + adopt guard (Polly review)

Two review fixes on the credential-write path:

- The route gated on ui_installable_harnesses(), which includes the env-auth
  opencode/qwen — the host handler then rejected them, turning a client/allowlist
  problem into a confusing 502. Add ui_credential_configurable_harnesses() (the
  Claude/Codex/Pi families the host can actually write) and gate on it, so
  opencode/qwen get a clean 400 with no frame forwarded.
- adopt_env_credential now refuses an env var that isn't set on the host —
  adopting an unset var would persist a provider entry that resolves to nothing
  at the first turn. (Runs on the runner, so os.environ is the host's env.)

Tests: opencode/qwen added to the 400-rejection parametrize; an unset-env-var
adopt rejection case.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(server): serialize concurrent credential writes to one host (Polly review)

Polly non-blocking note: unlike the install route (which coalesces via
inflight_installs), the credential route had no guard against overlapping
writes. The daemon's write is a non-atomic load→merge→save of config.yaml
(twice — entry, then default), so two writes to one host in quick succession
(a double-click, or key + gateway) could interleave and clobber a sibling
providers: entry.

Add a per-connection credential_write_lock held around the store-secret
round-trip so writes to one host serialize. A gateway/local host still
processes different hosts concurrently (the lock is per HostConnection).
Adds an integration test that holds the first reply and asserts the second
frame only reaches the host after the first completes.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(onboarding): make Pi's auth step UI-authable and trackable

Pi's setup_steps auth descriptor was still the M1 shape (action="setup",
command="omnigent setup", status_key=None). Two consequences surfaced in
manual testing: (1) status_key=None made the step "unknown", so the setup
dialog dropped it and wrongly showed "Pi is ready" with no action even though
readiness reported needs-auth; (2) even rendered it was a CLI signpost, not
the credential form.

Pi is UI-authable now (PR A gave it the needs-auth readiness axis; the UI has
the credential form), so its auth step becomes action="auth" (opens the inline
form, keyed on kind=="auth"), command=None (Pi has no subscription CLI login),
status_key="authed" (trackable, so it's not dropped and the dialog reflects
the real state). Qwen stays the untracked env-auth signpost (not UI-authable).
Updates the pi test and adds a qwen-stays-signpost test.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* chore: use the `omni` CLI alias (omni setup) in setup guidance

Rename user-facing "omnigent setup" → "omni setup" in the harness setup-step
descriptors, the setup hint, and their doc-comments. `omni` is the installed
console entry point (pyproject: omni = omnigent.cli:main) and is already used
elsewhere in the codebase, so the shorter alias is correct and consistent.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* test: fix CI drift on the M3 backend branch (omni setup + auth action)

Two "Pytest (misc)" failures on this branch were stale test expectations, not
product bugs:

- tests/host/test_connect.py asserted the unconfigured-launch error names
  "omnigent setup", but the earlier `omni` CLI-alias rename made the runtime
  message say "omni setup". Update the positive assertion and the cursor
  test's negative assertion (which guards that Cursor points at its own
  installer, not the generic setup command) to the new spelling.
- tests/test_harness_capabilities.py restricted setup-step actions to
  ("install", "command", "setup"), but Pi's UI-authable step uses action
  "auth" (added when Pi's credential step became a form). Add "auth" to the
  allowed set; codex's own two-step assertion is unchanged.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* test: harden the install-flow e2e against a slow picker render

test_install_button_installs_missing_harness opened the agent picker and
immediately clicked the Codex row, but the picker mounts its rows only after
the /v1/agents fetch resolves. Under CI load that render lags, and a menu
opened before the data lands can render empty or re-close on the update — so
the bare open-then-click flaked with a 30s click timeout, the Codex row never
becoming actionable (seen across two different shards). Open the picker, wait
for the Codex row and re-open if the menu flapped, then click. No product
change; passes locally unchanged (the retry is a no-op on the fast path).

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* test: settle agent data before opening the picker in the install e2e

The install-flow e2e flaked (30s click timeout on the Codex row, then on the
picker trigger via an overlay pointer-interception when reopened). Root cause:
the picker opened before the /v1/agents fetch settled, racing the menu-open
against a re-render. Wait for the composer's "Set up Codex" notice (rendered
only once the Codex agent + its unconfigured host state load) BEFORE opening the
picker, then open once and click. Passes locally repeatedly.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* test: stop driving the agent picker in the install e2e (kill the flake)

The picker interaction was redundant — the single seeded Codex agent is already
auto-selected, so the composer's "Set up Codex" notice is present without
opening the dropdown. Driving the picker only added a menu-open-vs-async-render
race that flaked under CI load. Wait for the notice directly (generous 60s) and
click it to open the setup dialog.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* test: wait for network idle before asserting the setup notice (install e2e)

The "Set up Codex" notice depends on two async fetches re-rendering the
composer (/v1/agents auto-selecting the agent, /v1/hosts marking its harness
unconfigured). On loaded CI runners that chain lagged past the timeout and the
assertion raced the still-loading landing screen. Wait for network idle and the
host chip (readiness present) before asserting the notice.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* test: drop networkidle wait in install e2e (WS keeps network busy)

wait_for_load_state("networkidle") never fires in this app — the shell holds a
long-lived sessions/updates WebSocket, so the network is never idle. That wait
just burned its timeout and then raced the still-loading landing screen (the
"Set up Codex" notice was intermittently absent on CI). Replace it with plain
element waits (host chip, then the notice) at a generous 60s, matching every
other e2e_ui test. Passes locally repeatedly.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(host): adopt an env credential under its own family, not the harness's

Review (isaac, MAJOR): pi consumes both the anthropic and openai families, so
the UI can offer an OpenAI env var (e.g. $OPENAI_API_KEY) as adoptable for pi.
`_handle_store_secret` derived the family solely from the harness (pi→anthropic)
and passed that to `adopt_env_credential`, so adopting that var wrote an
anthropic-family provider whose api_key_ref is env:OPENAI_API_KEY — mis-routed
to the anthropic endpoint, failing at run time. For the adopt kind, look the env
var up in the host's detected credentials and use its OWN detected family
(falling back to the harness family if absent). Adds a pi-adopts-OpenAI
regression test.

Also carry the install-flow e2e fix onto this branch: explicitly select Codex
in the picker and stub the /v1/sessions?kind=any agent scan so the seeded-DB
agents don't leak in and leave Claude Code selected (a CI-only flake).

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(host): harden the UI credential-write path (review feedback)

Addresses Polly's blocking finding + hardening notes and Pat's nits on the
store_secret/adopt path:

- BLOCKING (adopt boundary): the daemon's adopt handler fell back to the
  harness-derived family when an env var wasn't detected, and adopt_env_credential
  only checked the var was *set*. An owner hitting the raw API could name any set
  env var (a DB password, an unrelated secret) and have it persisted as a provider
  credential sent to the vendor endpoint. Now the handler refuses an env_var that
  isn't in detect_adoptable_credentials() (no fallback) — enforcing server-side the
  same "only adopt what was detected" restriction the UI presents.
- secrets.py: create the file-backend secrets.json 0600 atomically via
  os.open(O_CREAT, 0o600) instead of open()+chmod-after, which briefly left a
  freshly-created file group/world-readable. Now network-triggerable, so worth
  closing. Fixes the stale "0600 from the start" comment.
- adopt_env_credential: presence-only env check (`in os.environ`, not `.get`) so
  the "never reads the value" contract stays literally true.
- gateway base_url: reject a non-http(s) scheme at write time rather than writing
  a malformed provider entry that fails opaquely at the first turn.
- connect.py: hoist the harness_auth / provider_config imports to module top
  (no circular import) to match the sibling onboarding imports.

Adds regression tests: adopt refuses an undetected env var, gateway rejects a
non-http base_url, and secrets.json is 0600 even under a permissive umask.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-24 17:48:43 +07:00
Sabhya Chhabria 82c25ffbec [claude] Load Databricks models dynamically (#2831)
*  feat(claude): Load Databricks models live

- Refresh the gateway catalog once per new native session and share the launch snapshot with the UI.
- Keep provider-neutral aliases, cached fallback behavior, and authoritative model removals.

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* 🐛 fix(claude): Handle delayed model catalogs

- Retry sticky model handoff after live options arrive, including bind races
- Map provider model ids and defaults to friendly active picker rows
- Tighten model option contracts and cover backend/UI edge cases

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* fix(api): regenerate OpenAPI schema

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* 🐛 fix(claude): Mirror managed model catalog

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* 🐛 fix(ui): Resolve launch models from host

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* test: fix model discovery CI coverage

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* test: stub host model discovery in e2e

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* fix(claude): preserve live catalog routing

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* fix(claude): don't treat a failed-primary empty catalog as authoritative

Addresses the outstanding review round:

- discover_databricks_claude_models: when the UC listing fails and the
  legacy gateway answers with no Claude routes, re-raise the primary
  error instead of returning {} — callers now fall back to cached ucode
  models rather than hard-failing the launch on a transient UC outage.
- Warn when model-services pagination is truncated at the page budget.
- Runner claude-model-options: answer ClickException config failures
  with 424 instead of the retryable 503, so the picker path stops
  conflating "no models configured" with "still booting".
- chatStore bind race: a preserved raced-catalog selection must still
  exist in that catalog — a removed sticky alias no longer lingers
  visually selected.
- Document that the pre-launch host catalog is an ambient-default
  preview; launch re-resolves with the session's agent spec.

Co-authored-by: Isaac

* test(e2e): pick the live catalog label in the model/effort scenario

The config modal's Model rows now carry the host catalog's display
names ("Opus 4.8"), not the static alias labels, so the exact-match
click must use the mocked catalog's label.

Co-authored-by: Isaac

* chore: revert accidental uv.lock churn from the merge

Co-authored-by: Isaac

* fix(api): sync openapi.json with the host model-options docstring

Co-authored-by: Isaac

* fix(api): tolerate provider model rows without displayName

Polly review: the shared NativeModelOption schema made displayName
required and _model_options_from_wire validated all-or-nothing, so one
Codex model/list or OpenCode /api/model row lacking displayName blanked
the whole picker for the session. Restore displayName as optional (the
UI already falls back to the id) and skip malformed rows individually
instead of discarding the catalog.

Co-authored-by: Isaac

---------

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-22 17:50:37 -07:00
Daniel Lok 23e498521f fix(runner): quiet idle-reaper shutdown instead of a scary error banner (#3060)
* fix(runner): quiet idle-reaper shutdown instead of a scary error banner

When the runner idle monitor reaps an inactive runner after
`runner.idle_timeout_s` (default 1h), the runner exits cleanly (code 0),
but the UI rendered the same loud red `ErrorBanner` a genuine crash would
— even though the session is fully reactivatable (host-bound sessions
relaunch the runner on the next message). A clean idle shutdown tripped
two banner-producing server paths:

1. Relay path (durable / reload banner): the runner's `GET /stream`
   dropped abruptly, so the SSE relay published `failed` +
   `runner_disconnected` and persisted it as a `last_task_error` label.
2. Host exit-report path (live): the host's `_watch_runner` reported
   `host.runner_exited`, which became `failed` + `runner_failed_to_start`.

This treats a clean idle exit as benign (a genuine crash still shows the
banner):

- Runner drains its session streams before the idle shutdown: enqueues the
  `[DONE]` sentinel to each `GET /stream` so the relay returns cleanly
  (no `runner_disconnected`, no durable label). `serve_tunnel` now takes a
  `shutdown_event` + `on_graceful_shutdown` hook; on signal it waits for
  in-flight dispatch tasks to emit their end frames, then closes the socket
  with a normal close handshake (the handshake completing is the delivery
  confirmation — robust over a remote connection, not a timing nudge), and
  stops reconnecting.
- Host suppresses the exit report for a clean (code-0) exit; a non-zero
  exit still reports its cause.

Co-authored-by: Isaac

* refactor(runner): address PR review nits on graceful-shutdown loop

- Use asyncio.create_task instead of ensure_future in the graceful-shutdown
  read loop, matching the module convention (Copilot).
- Make the graceful-shutdown serve test deterministic: pre-arm the shutdown
  event so the first recv() race resolves to it, dropping the real-time
  sleep(0.01) that could flake under load (Copilot).
- Give the flagged bare `await task` an explicit effect via
  `assert task.result() is None` (CodeQL "statement has no effect").

Co-authored-by: Isaac

* docs(runner): note the same-tick frame drop in graceful shutdown

Polly/Copilot review flagged that if a frame and the shutdown signal
complete in the same asyncio.wait tick, the shutdown branch wins and the
frame is dropped. That is acceptable on the idle-reaper teardown path (a
host-bound session replays/relaunches on the next message); document it so
the trade-off is explicit for future readers.

Co-authored-by: Isaac

* refactor(runner): snapshot drain queues; create_task in tests

Follow-up PR review nits (Copilot):

- `_drain_session_streams` now iterates `list(_session_event_queues.values())`.
  The loop is synchronous (no await, so nothing interleaves on the event loop
  today), but snapshotting keeps the drain robust if a queue mutation ever
  moves off this atomic path — matching the `list(...)` idiom already used by
  the timer-cleanup / pane-reaper paths.
- Switched the two remaining `asyncio.ensure_future(...)` test helpers to
  `asyncio.create_task(...)` for consistency with the module convention.

Co-authored-by: Isaac

* fix(runner): log recv failure while settling cancelled read on shutdown

PR review (Copilot): the graceful-shutdown branch swallowed
WebSocketException while awaiting the cancelled recv_task. If recv() had
already failed with an abnormal close on the same tick the shutdown fired,
the socket may be dead — so the drain's [DONE] frames won't reach the
server and it will see a disconnect — yet there was no trace of why.

Keep suppressing the exception (letting it propagate would skip
_graceful_drain and reintroduce the abrupt drop this PR removes), but split
the handling: silent on CancelledError (normal cancellation), debug-log on
WebSocketException so the rare same-tick failure is diagnosable without
disturbing the quiet UX.

Co-authored-by: Isaac
2026-07-22 22:39:16 +08:00
Cathy Yin 9b9d331964 feat(server): install a missing harness onto a connected host from the UI (backend, flag-gated) (#2912)
* feat(host): add install-harness tunnel frame pair + registry plumbing

Adds the HostInstallHarnessFrame / HostInstallHarnessResultFrame pair to
the host tunnel protocol, mirroring the existing HostCreateDirFrame
request/result pattern, plus the pending_installs future map on
HostConnection. This is the vocabulary the server and a connected host
use to negotiate a UI-driven harness install (later PRs add the host
handler, the route, and the frontend button).

Additive only: no frame is sent or received yet, so behavior is
unchanged. The result frame carries a freshly-recomputed readiness map
(configured_harnesses, reusing _optional_str_availability_map) so the UI
can flip the harness badge without waiting for a reconnect.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* refactor(onboarding): surface install failure reason from install_harness_cli

Extracts install_harness_cli_with_reason(key) -> tuple[bool, str | None]
alongside the existing install_harness_cli(key) -> bool, which becomes a
thin wrapper that discards the reason. Single implementation, no caller
churn: the four setup-wizard call sites keep their boolean contract
unchanged.

The reason is derived from the existing failure branches (manual-only
spec, missing installer, timeout, OS error, non-zero exit, post-install
binary-not-found) without capturing installer output — so omni setup's
live npm output UX is preserved. A later PR's UI-driven install returns
this reason to the user instead of a bare failure.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(host): install harness on request + resolve the install result

Adds the host daemon side of UI-driven install:
- _handle_install_harness in host/connect.py runs
  install_harness_cli_with_reason off the event loop, recomputes
  configured_harness_map(), and returns a HostInstallHarnessResultFrame
  carrying either the fresh readiness map or a failure reason.
- host_tunnel.py's receive loop resolves the pending_installs future.
- A shared allowlist/resolver (ui_installable_harnesses / ui_install_key)
  in onboarding/harness_install.py is the single source of truth for
  which harnesses are UI-installable (claude, codex, pi, opencode, qwen)
  and their install-spec keys.

Defence in depth: the handler re-checks ui_install_key, so a stray or
spoofed frame can never drive the installer for a non-allowlisted
harness (e.g. hermes, whose installer is a curl | bash). Inert until PR4
wires a sender: nothing emits HostInstallHarnessFrame yet.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(server): add UI harness-install route behind a default-off flag

Adds POST /v1/hosts/{host_id}/harnesses/{harness}/install: the server
endpoint the web UI's Install action calls. It validates in order —
feature flag (404 when off) -> allowlist (400) -> auth/require_user ->
owner (403) -> liveness (409) — then forwards a HostInstallHarnessFrame
over the tunnel via _proxy_install_harness and returns the host's
refreshed configured_harnesses map.

- Reuses the _proxy_create_dir request/future/wait_for template; the
  install timeout (330s) sits above install_harness_cli's 300s subprocess
  ceiling so the result is received before the server gives up.
- Concurrent installs of the same (host, harness) coalesce onto one
  in-flight task (conn.inflight_installs) so a double-click can't fire two
  non-race-safe global npm installs.
- Gated by OMNIGENT_HARNESS_INSTALL_ENABLED, surfaced to the SPA via
  GET /v1/info (harness_install_enabled), mirroring smart_routing_enabled.

Allowlist ordering (400 before 403) avoids leaking host ownership through
error codes. Ships dark: with the flag off the route is 404, so merging
this changes nothing in production.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(host): make UI install idempotent + widen the server wait

End-to-end testing against a real host surfaced two issues the stubbed
unit tests masked:

- The host ran `npm install -g` even when the harness CLI was already on
  PATH; npm re-resolves over the network and took >60s for an
  already-present binary, so a repeat Install click hung. _handle_install_harness
  now short-circuits on harness_cli_installed(key) and just returns fresh
  readiness (reusing the existing check) — sub-second on the happy path.
- The server's per-call wait (330s) sat only 30s above install_harness_cli's
  own 300s subprocess cap, so a genuine cold npm install could finish right
  as the server gave up — a "504 but actually installed" outcome. Widened
  to 420s (300s + 2min headroom for readiness recompute + tunnel latency).

Verified end-to-end: happy path 200 in 0.8s (already-installed fast-path),
a real cold opencode install completes route->tunnel->daemon->npm->readiness,
hermes rejected 400, codex reports needs-auth post-install.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* chore(openapi): regenerate spec for the harness-install route

CI's openapi-drift guard flagged openapi.json as out of sync after the
new POST /v1/hosts/{host_id}/harnesses/{harness}/install route. Regenerated
via scripts/dump_openapi.py so the committed spec matches the app.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* refactor(server): share the harness-install flag env-var name

Extract OMNIGENT_HARNESS_INSTALL_ENABLED into a single
HARNESS_INSTALL_ENABLED_ENV constant in hosts.py, read by both the
install route and the /v1/info flag in app.py, so the flag the UI sees
and the flag the route enforces can never drift on a typo. Also switch
the install-task scheduling from asyncio.ensure_future to the more
idiomatic asyncio.create_task.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(server): describe per-harness setup steps for the UI setup flow

Extends the harness-install backend so the web UI can render a "set up this
agent" checklist that mirrors omnigent setup, instead of a single Install
button.

- /v1/harnesses now carries an ordered setup_steps list per harness (install,
  then auth), derived from the existing HarnessInstallSpec so it can't drift
  from the real install/login commands. Claude/Codex/Pi/OpenCode/Qwen get a
  first-class two-step flow; other harnesses get a generic "run omnigent setup"
  step.
- The host readiness map now reports a two-step signal (binary-missing /
  needs-auth) for Claude and OpenCode too, matching Codex, so the UI can show
  install-done vs sign-in-done. Pi/Qwen stay binary-only (their credential
  isn't locally determinable).
- The launch gate (harness_is_configured) is unchanged and stays binary-only,
  so a not-signed-in harness is never blocked from launching.
- /v1/info advertises installable_harnesses (bare + native spellings) so the
  UI offers setup only where the install route will accept it.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* feat(server): key harness setup steps by every spelling for the UI

The setup dialog looks up steps by the harness a session declares — often a
native wrapper (codex-native) or an installable id that isn't a picker row
(opencode/qwen), none of which appear in the harness catalog. Add
harness_setup_steps_by_spelling() and return it from GET /v1/harnesses as a
top-level setup_steps map so the dialog can resolve steps for whatever id it
holds, without adding non-pickable rows to the catalog.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* fix(server): use host.user_id in the install route's owner check

The install route still compared host.owner, but the Host model's owner field
was renamed to user_id (identity-columns unification on main). An authenticated
install therefore 500'd with AttributeError. Switch to host.user_id (matching
every other host route) and add an owner-mismatch test that exercises the
ownership branch with a real user_id — the existing tests run unauthenticated,
so the comparison was never hit.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* docs(server): correct the setup-step "can't drift" comment

The auth-step commands (codex login, etc.) are display-only literals, not
derived from HarnessInstallSpec.login_args — only the install step's label is
derived. Reword the comment/docstring so they don't overstate the guarantee.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

* Address review: family-keyed install coalescing + clearer naming

- Coalesce concurrent UI installs on the resolved install *family* key
  (ui_install_key) rather than the raw spelling, so codex + codex-native
  (both the openai npm package) share one in-flight install. Cleanup is
  tied to task completion via add_done_callback and every caller awaits
  under asyncio.shield, so a cancelled request can't clear the map out
  from under a follow-up and start a second concurrent `npm install -g`.
- Add an integration test that fires two overlapping same-family installs
  and asserts exactly one frame reaches the host.
- Rename install_harness_cli_with_reason -> try_install_harness_cli and
  return a HarnessInstallResult NamedTuple instead of a bare tuple.
- Trim the over-long install-handler docstring and UI-installable map
  comment to the essentials.

Co-authored-by: Isaac
Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-22 10:34:13 +07:00
Daniel Lok f70085da2f [auth] Reuse delegated credentials in host runners (#2762)
*  perf(auth): Reuse delegated runner credentials

- Exchange host launch binding tokens for short-lived owner bearers before resolving user credentials.\n- Share runner auth with Claude and refresh hook snapshots without exposing the binding token.

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

* ♻️ refactor(auth): Address review feedback

- Avoid logging bridge paths and collect cancelled refresh tasks explicitly.\n- Inject the refresh interval so tests use the existing direct import style.

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

* fix runner auth fallback behind Apps proxy

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

* perf(auth): bootstrap runners with host bearer

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

* docs(api): regenerate OpenAPI schema

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>

---------

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
2026-07-21 11:14:01 +08:00
Sabhya Chhabria 01f1db3df4 [host] Refresh harness readiness without reconnect (#2828)
* 🐛 fix(host): Refresh harness readiness live

- Publish change-only readiness updates from connected hosts
- Persist updates and notify web and desktop host queries

* 🐛 fix(host): Harden readiness refresh contract

- Cover full-refresh and unchanged-map timer paths
- Centralize readiness states and reject partial or empty live maps

---------

Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-20 06:12:18 -07:00
Daniel Lok ae8878ad70 fix(server): ask the host if a runner is coming before the connect grace (#2699)
* fix(server): ask the host if a runner is coming before the connect grace

A host-bound session's first message waits up to _HOST_BOUND_RUNNER_CONNECT_GRACE_S
for the pinned runner's tunnel to register before relaunching. That wait is
correct for a booting new-session runner but pure latency for one that will
never connect — a non-sticky Stop dropped it, or the host restarted and lost
it. Neither case writes a host.runner_exited report (Stop pops the runner
before terminating; a dead host never sends the frame), so the old blind wait
burned the full grace every time on restart.

The host is the authoritative owner of runner-process liveness — it holds the
Popen. Add a host.runner_status frame pair so the server can ask: alive
(booting/serving → wait), dead (tracked but exited → relaunch now), or unknown
(stopped, crashed, or lost to a host restart → relaunch now). The dispatch
path races this query against the connect grace: the runner connecting (or a
crash report) always wins if it lands first, and a dead/unknown verdict cuts
the wait short so the relaunch runs immediately. Running the query alongside
the wait — not before it — keeps it strictly a speed-up: a host that is
offline, too old to answer, or slow yields no verdict and the grace runs its
normal course with no added latency.

Host-absent at dispatch skips the grace entirely (there is no one to query)
and falls through to the existing relaunch/503, unchanged.

Co-authored-by: Isaac

* Address code-quality review on the runner-status query

- Drain cancelled tasks via asyncio.gather(..., return_exceptions=True)
  instead of `await task` inside contextlib.suppress, in both the race
  helper and the integration test. Functionally identical, but avoids the
  bare-expression-statement the static analyzer flagged as "no effect"
  (it doesn't model `await` as side-effecting).
- Harden _query_host_runner_status: map any unexpected exception (e.g. a
  future resolved with an error) to None so the query can only ever speed
  up the connect grace, never break the message POST. CancelledError stays
  a BaseException and still propagates, so the race helper's cancel/drain
  is unaffected. Covered by a new test that resolves the pending future
  with an exception.

Co-authored-by: Isaac

* test(e2e): stub /health so the host-badge push test isolates useHosts status

test_hosts_changed_frame_updates_host_badge failed at its first
assertion (before any hosts_changed frame): the badge read "status
unknown" instead of the stubbed "online". The test intercepts the
WS /v1/sessions/updates stream to keep liveOnline undefined, but the
open-session GET /health poll is a second, independent source of
host_online — and the real endpoint emits host_online: null for a
session it finds without a host binding. That null reaches
useSessionHostOnline as a live signal, which HostBadge treats as
authoritative "unknown", overriding the useHosts status the test drives.

Patch /health to drop the seeded session from the batch sessions map so
useSessionHostOnline stays undefined ("not observed yet") and the badge
falls back to the useHosts status field — matching the test's stated
intent and the existing snapshot/list route patches. HostBadge behavior
is unchanged; this only repairs the test's mock world, which had the
snapshot claiming host-bound while /health said otherwise.

Co-authored-by: Isaac
2026-07-16 12:38:44 +00:00
Serena Ruan 57ec0e1db6 feat(files): serve session filesystem from host when runner is offline (#2676)
* feat(files): serve session filesystem from host when runner is offline

When a session's runner process dies but its host is still connected,
the file panel (browse / changed files / diffs / search / file content)
used to go dark — every request 502/503'd and the user had to send a
message to wake a new runner just to look at files.

The server now falls back to reading the workspace over the existing
host tunnel when the pinned runner is offline. A shared, read-only
WorkspaceReader (confined to the workspace root) runs on the host and
returns the same JSON shapes the runner's filesystem endpoints do, so
the resolver (live runner -> host tunnel -> 503) and the frontend can't
tell which side answered. The panel stays live with a passive "Asleep —
files shown live from host" badge; no LLM, no wake-up.

Built as a resolver chain so a future host-death snapshot source drops
in as an additive third link without touching endpoints or the frontend.

- omnigent/workspace_fs.py: read-only WorkspaceReader (list/read/search/
  changes/diff), reusing the runner's path-validation, glob, pagination,
  and git change-registry helpers.
- host tunnel: host.fs_request / host.fs_result frames + host handler +
  server-side proxy and pending-future routing.
- server: _fs_get_with_host_fallback wraps the 5 FS GET endpoints;
  offline env-metadata is synthesized from the bound workspace.
- web: useWorkspaceServeable gate (runner-online OR host-online, tri-state
  aware) replaces the runner-only gate across the FS hooks; host-served
  badge in FilesPanel.

Test Plan: backend unit + integration (real host tunnel, offline runner,
real git workspace), frontend hook unit tests, and e2e_ui (real browser)
covering the file list + content viewer while the runner reads offline.

Co-authored-by: Isaac

* fix(files): address host-served FS review notes (bounded read, parity)

Follow-up to the PR review on the host-served filesystem path:

- WorkspaceReader now reads at most _MAX_READ_BYTES from disk (via a
  bounded open().read) in both _read_file and diff's `after`, instead of
  slurping the whole file — a multi-GB file opened while the runner is
  asleep can no longer OOM the host process. Matches the runner's cap.
- _list_dir falls back to lstat for a broken symlink and lists it as
  type="file"/bytes=None instead of silently dropping it — restores the
  parity the docstring claims with the runner's list_dir.
- Host FS failures now mirror the runner proxy's status mapping: a
  non-404/400 host error (e.g. git_status_failed) surfaces as 502 like
  _proxy_get_to_runner, and a 400 stays a 400.
- Log a warning when a host fs op times out (the module's _logger was
  previously unused); drop a dead `text = ""` assignment.

Adds tests for the oversize-read cap and the broken-symlink listing.

Co-authored-by: Isaac

* fix(files): keep oversize text as UTF-8 when truncation splits a codepoint

Follow-up to the PR review: WorkspaceReader._file_content_payload sliced
the read at _MAX_READ_BYTES on a raw byte boundary, so a text file larger
than the cap whose cut fell inside a multi-byte UTF-8 codepoint raised
UnicodeDecodeError and was served base64 — diverging from the runner,
which truncates on a valid boundary and keeps encoding="utf-8".

Now, when we truncated and the only invalid bytes are a partial trailing
codepoint (error within the last 3 bytes), drop them and re-decode as
text. A genuinely binary file has invalid bytes earlier in the buffer, so
it still falls through to base64. Adds tests for both.

Co-authored-by: Isaac
2026-07-16 17:46:13 +08:00
Tomu Hirata 13da60cf32 feat(telemetry): propagate host installation ID to SessionCreatedEvent (#2667)
* feat(telemetry): propagate host installation ID to SessionCreatedEvent

Adds `installation_id` to `HostHelloFrame` so the host daemon advertises
its local installation ID on connect. The server stores it in the
`HostRegistry` via a new `get_host_installation_id` helper, then passes
it as `host_installation_id` on `SessionCreatedEvent` so hosted sessions
can be correlated back to a specific host machine in telemetry.

* test(telemetry): add tests for host_installation_id telemetry feature

Cover HostHelloFrame encode/decode roundtrip with and without
installation_id, HostRegistry.get_host_installation_id with and
without a registered host, and _build_record promoting
host_installation_id to top-level data rather than params.
2026-07-16 02:54:34 +00:00
Tomu Hirata 2b3b54a48e feat(telemetry): add usage telemetry system for session lifecycle events (#2457)
* feat(telemetry): add usage telemetry system for session lifecycle events

Adds a new omnigent/telemetry package with fire-and-forget product
analytics for session created, stopped, and deleted events.  Telemetry
is completely opt-out (OMNIGENT_TELEMETRY=0, DO_NOT_TRACK=1, or any CI
env var suppresses all instrumentation) and never raises exceptions into
application code.

Key pieces:
- omnigent/telemetry/: new package with installation_id, client,
  events, and surface modules
- HelloFrame.installation_id: runner propagates its installation ID
  through the WS tunnel handshake so the server can correlate
  runner-side and server-side identities
- TunnelRegistry.get_runner_installation_id(): convenience accessor
- sessions.py: stamps omnigent.client surface label at create time,
  emits SessionStoppedEvent and SessionDeletedEvent at the right hooks
- app.py: initialises the telemetry client at lifespan startup and
  emits SessionCreatedEvent inside _on_runner_connect

* fix(telemetry): emit session.created at create time, not on runner reconnect

Move SessionCreatedEvent emission from _on_runner_connect (which fires on
every reconnect for all bound sessions) to create_session, so the event
fires exactly once per session at creation time. Remove runner_installation_id
from the event schema since it is no longer available at emit time. Prime
the installation-id cache in init_client() to avoid synchronous file I/O
on the event loop in stop/delete handlers. Add unit tests for classify_surface,
is_disabled, and get_installation_id.

* fix(telemetry): address Copilot review comments

- Replace bare except pass blocks with _logger.debug() calls or
  explanatory comments so intent is explicit
- Rename _INSTALLATION_ID_CACHE/_CACHE_INITIALIZED to _cache/_cache_initialized
  to resolve unused-global-variable warnings

* fix(telemetry): consolidate imports, defense-in-depth opt-out, hash only user_id

- Move all telemetry imports to top-level in sessions.py; alias the three
  event classes (_TelSession*Event) to avoid name clash with the existing
  SessionCreatedEvent SSE schema class
- Add is_disabled() check inside TelemetryClient.emit() so opt-out is
  enforced even if a call site skips the module-level guard
- Hash only user_id (not installation_id:user_id) since user_id is the
  only PII; installation_id is already a random UUID with no PII value
- Add omnigent/telemetry/*.py to BLE001/SIM105 ruff ignore list — broad
  exception catches are intentional at every telemetry boundary

* fix(telemetry): remove unused surface label stamp and _tel_disabled import

The omnigent.client label was written but never read anywhere. Surface
is already captured directly in SessionCreatedEvent from the User-Agent
header, so the extra label write was redundant. _tel_disabled is now
handled internally by emit().

* fix(telemetry): align wire format with API Gateway / Kinesis schema

- Wrap batches in {"records": [{"data": {...}, "partition-key": "..."}]}
  instead of {"events": [...]}
- Add required envelope fields to each record: event_name, session_id
  (per-process UUID), omnigent_version, schema_version, python_version,
  operating_system, timestamp_ns, status, duration_ms, environment
- Serialize event-specific fields into data.params as a JSON string to
  satisfy additionalProperties: false on the gateway schema
- installation_id remains a top-level data field (explicitly in schema)
- Add _detect_environment() for docker/cloud environment tagging
- Reorder events.py fields to put installation_id first (top-level field)

* feat(telemetry): support DISABLE_TELEMETRY env var and config.yaml opt-out

- Add DISABLE_TELEMETRY as an alias for OMNIGENT_DISABLE_TELEMETRY
- Read telemetry: false / telemetry:\n  enabled: false from
  ~/.omnigent/config.yaml (honouring OMNIGENT_CONFIG_HOME)
- Config check is last in precedence so env vars always win

* fix(telemetry): only support telemetry: false in config.yaml

* feat(telemetry): hardcode staging/prod endpoints based on version

- Dev/pre-release versions (*.dev*, *a*, *b*, *rc*) route to staging
- Final releases route to production
- OMNIGENT_TELEMETRY_ENDPOINT env var still overrides for local testing
- Remove the 'no endpoint = silent no-op' behaviour; endpoint is always set

* feat(telemetry): add explicit runner-side opt-out via HelloFrame.telemetry_opt_out

- Replace installation_id in HelloFrame with telemetry_opt_out bool
- Runner sets telemetry_opt_out=True when its local is_disabled() is True
  (honours OMNIGENT_TELEMETRY=0, DISABLE_TELEMETRY, DO_NOT_TRACK, CI vars,
  and telemetry: false in config.yaml on the host machine)
- Replace get_runner_installation_id() with is_runner_telemetry_opted_out()
  on TunnelRegistry
- Server skips session.created emit (best-effort) when runner signals opt-out

* feat(telemetry): link opt-out to host instead of runner

- Add telemetry_opt_out to HostHelloFrame (encode/decode in host/frames.py)
- Host sets telemetry_opt_out=True in connect.py when its is_disabled() is True
- Add HostRegistry.is_host_telemetry_opted_out(host_id)
- sessions.py checks host_id opt-out instead of runner_id — host is stable
  and persistent; runner is ephemeral (one per session)
- Runner-side telemetry_opt_out in HelloFrame retained for CLI sessions
  (omnigent claude/pi) which have no host

* fix(telemetry): address remaining Copilot empty-except comments

- _resolve_endpoint: log debug on version parse failure
- init_client: log debug on TelemetryClient init failure

* feat(telemetry): add remote config fetch (MLflow pattern)

- Fetch {config_url}/{version}.json at startup in a daemon thread
- Config fields: ingestion_url (required), disable_telemetry (kill-switch),
  disable_events (per-event list), disable_os, rollout_percentage
- Consumer waits for config before sending; discards buffered events if
  config fetch fails or kill-switch is set
- Per-event disable_events checked at emit time AND at send time
- OMNIGENT_TELEMETRY_CONFIG_URL env var overrides config URL for testing
- Staging config URL for dev/pre-release; production for final releases
- Remove hardcoded _ENDPOINT_PROD/_ENDPOINT_STAGING — ingestion_url comes
  from config now

* style(telemetry): fix test formatting (pre-commit ruff format)

* fix(telemetry): update tests to use renamed cache vars (_cache/_cache_initialized)

* fix(telemetry): update config URLs to omnigent-telemetry.io domain

* fix(telemetry): use actual Omnigent session_id instead of per-process UUID

Pop session_id from event fields to the top-level data.session_id so
the gateway receives the real conversation ID. The per-process UUID was
confusing and didn't match the schema description 'Omnigent session
identifier'.

* fix(telemetry): start threads eagerly and reduce batch interval to 10s

- Start config fetch + consumer threads in init_client() rather than
  lazily on first emit(), so config is pre-fetched before the first event
- Reduce _BATCH_INTERVAL_S from 30s to 10s so events are flushed promptly
  in low-volume usage (waiting 30s explains why endpoint wasn't being hit)

* fix(telemetry): format anon_user_id as installation_id_hash(user_id)

* fix(telemetry): promote anon_user_id to top-level data field; revert to sha256(user_id)

- Pop anon_user_id from event fields into data envelope alongside
  installation_id (requires infra schema update to allow the field)
- Revert anon_user_id format back to plain sha256(user_id)[:16]

* fix(telemetry): salt anon_user_id with installation_id to prevent rainbow table attacks

* fix(telemetry): remove params truncation that produced invalid JSON

* fix(telemetry): respect telemetry: false in -c config.yaml for server

- Add server_config param to init_client() — checks config.get('telemetry') is False
- Thread cfg from CLI server command into create_app(server_config=cfg)
- create_app passes it into the lifespan which calls init_client(config=server_config)

* fix(telemetry): remove OMNIGENT_TELEMETRY_DISABLE env var

* fix(telemetry): fix config.yaml opt-out and add missing tests

- Replace yaml.safe_load with regex match in _config_telemetry_disabled
  to avoid spec/parser.py corrupting SafeLoader.yaml_implicit_resolvers
  which caused 'false' to parse as a string instead of a boolean
- Add tests: DISABLE_TELEMETRY, OMNIGENT_DISABLE_TELEMETRY, config.yaml
  telemetry:false, config.yaml telemetry:true, init_client server_config
2026-07-14 00:23:45 +09:00
Zeyi (Rice) Fan 4face30b9d feat(logging): Add process log routing (#2468)
*  feat(logging): Add process log routing

Related issue: N/A

Summary:
- Route server, host, runner, and CLI logs through shared process logging under $OMNIGENT_DATA_DIR/logs/<destination>/.
- Add global --debug and --log-to-stderr controls, including fd-based terminal mirroring for omnidev.
- Update omnidev to pass --log-to-stderr to Omnigent server and host processes.

Test Plan:
- cargo fmt --check
- cargo test (dev/omnidev)
- .venv/bin/python -m pytest tests/test_process_logging.py tests/cli/test_cli_diagnostics.py tests/cli/test_cli.py tests/cli/test_server_lifecycle.py tests/host/test_local_server.py tests/host/test_connect.py tests/runner/test_runner_entry.py
- .venv/bin/pre-commit run --all-files

Demo:
N/A

Type of change:
- [x] Feature
- [x] Refactor / chore
- [x] Test / CI

Test coverage:
- [x] Unit tests added / updated
- [x] Existing tests cover this change

Coverage notes:
Automated tests cover process logging helpers, CLI flags/log discovery, server lifecycle, host-spawned runner logging, runner entrypoint logging, and omnidev command construction.

Changelog:
Omnigent writes process logs to per-destination files and can mirror them to the terminal with --log-to-stderr.

* Fix process log routing checks
2026-07-13 06:32:18 +00:00
Pat Sukprasert 3864413eb1 fix(harnesses): flow Anthropic gateway creds host→runner→Claude Code launch (#2371)
* fix(harnesses): flow Anthropic gateway creds host→runner→Claude Code launch

A browser-created managed sandbox running claude-native against an
Anthropic-compatible gateway (e.g. LiteLLM) needs ANTHROPIC_API_KEY,
ANTHROPIC_BASE_URL, and ANTHROPIC_MODEL to survive three hops. Each hop
dropped or ignored the model / gateway wiring, so sessions failed with
invalid-model or auth errors, or hung on Claude Code's custom-key menu.

- Host→runner env: forward ANTHROPIC_MODEL through the harness credential
  allowlist next to ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL, so the runner
  no longer resolves model=None.
- Ambient provider synthesis: an ambient ANTHROPIC_API_KEY now honors
  companion ANTHROPIC_BASE_URL and ANTHROPIC_MODEL, mirroring the OpenAI
  branch, so a gateway key routes to the gateway with the served model
  pinned instead of api.anthropic.com with no model.
- Native launch + tmux delivery: when an apiKeyHelper delivers the
  credential, strip the raw ANTHROPIC_API_KEY (and CLAUDECODE) from the
  Claude terminal child so Claude Code doesn't open its custom-API-key
  menu, and teach the prompt-readiness scan to ignore selected numbered
  menu rows so the first web message isn't typed into that menu.

Co-authored-by: omnigent <noreply@omnigent.ai>

* test(harnesses): pin apiKeyHelper no-raw-key invariant, fail loud

The helper-path key strip in the Claude terminal env relies on
build_native_claude_terminal_env never emitting a raw ANTHROPIC_API_KEY
when an apiKeyHelper is configured. If a future change starts injecting
the raw key on that path, it would silently reintroduce Claude Code's
custom-API-key menu hang. Raise at the env-build seam when the invariant
breaks, and pin it with a focused unit test.

Co-authored-by: omnigent <noreply@omnigent.ai>

* test(harnesses): pin Databricks-gateway helper-path env shape

Existing helper-path coverage is generic gateway-shaped; add a test for
the Databricks ucode/profile case real users run. Through
_claude_terminal_env_unset and the terminal-env build, assert the child
drops DATABRICKS_CONFIG_PROFILE and the raw key / nested-session marker
while apiKeyHelper, ANTHROPIC_BASE_URL, and the gateway model survive, so
Claude Code still authenticates against Databricks.

Co-authored-by: omnigent <noreply@omnigent.ai>

* docs(harnesses): trim comments on the Anthropic gateway cred path

Tighten the comments and docstrings introduced by this branch to match
the repo's comment guidance: keep them short and focused on the scenario,
drop redundant restatement, and remove paragraphs that duplicate a nearby
docstring. Preserve the load-bearing "why" — the Databricks profile drop
at the terminal-child hop, the apiKeyHelper raw-key guard, and the
readiness-scan menu-glyph rationale.

Comment-only; no executable code changed.

Co-authored-by: Isaac

* 🐛 fix(harnesses): Strip nested Claude marker

* 🐛 fix(harnesses): Recognize numbered Claude drafts

---------

Co-authored-by: omnigent <noreply@omnigent.ai>
2026-07-10 18:50:29 +00:00
Edwin He 76bb9002d9 Route out-of-process native posters through databricks_request_headers (#2328)
The pi JS extension and the opencode policy plugin run OUT of the runner
process and POST to the omnigent server with a hand-rolled `Authorization:
Bearer` header, bypassing databricks_request_headers -- the single chokepoint
that folds in the server-routing selectors (X-Databricks-Org-Id and the opaque
OMNIGENT_DATABRICKS_EXTRA_HEADERS map that some Databricks deployments use to pin
a request to a specific server instance). Without those selectors their POSTs can
land on a different server instance than the one the runner and the web UI are
bound to, so on a multi-instance deployment pi's streamed items never reach the
browser's in-process event stream (they only appear on reload) and opencode's
policy evaluation hits a different instance.

- cli_auth: fold OMNIGENT_DATABRICKS_EXTRA_HEADERS into
  databricks_request_headers (opaque JSON header map; no-op when unset).
- pi: build the extension config.authHeaders (launch + per-turn refresh) via
  databricks_request_headers.
- opencode: bake the full routing header map as OMNIGENT_POLICY_HEADERS and merge
  it in the policy plugin, replacing the bearer-only OMNIGENT_POLICY_AUTH.
- host: allowlist OMNIGENT_DATABRICKS_EXTRA_HEADERS in the host->runner env
  builder so a host forwards the routing selectors to the runners it spawns.
  Without it the host tunnel lands on the selected instance while its runners
  fall back to the default one (their tunnel + callbacks register elsewhere), so
  the session's runner is unreachable from the instance serving the UI and the
  session reports runner_failed_to_start.

In-runner Python clients already route via _RunnerDatabricksAuth / _remote_headers;
the gaps were the two out-of-process posters and the host->runner env handoff.

Co-authored-by: Isaac

Signed-off-by: Edwinhe03 <41037314+Edwinhe03@users.noreply.github.com>
2026-07-10 00:32:37 -07:00
Tomu Hirata 91d6746b44 feat(cli): add omnigent debug logs command (#2273)
* feat(cli): add `omnigent debug logs` command

Exposes runner, server, and CLI diagnostic log files via the debug
subgroup so operators can inspect them without navigating the
~/.omnigent/logs/ directory manually.

  --type [runner|server|cli]  which log category (default: runner)
  --list                      list files with sizes and timestamps
  -n / --lines N              tail last N lines (0 = whole file)
  -f / --follow               stream in real-time (tail -f)

* feat(cli): filter runner logs by session id

Embeds the session id in each runner log filename
(runner-conv_abc123-<random>.log) so all relaunches for a session are
discoverable. Adds --session SESSION_ID to `omnigent debug logs` to
show all log files for a session oldest-first.

* fix(cli): address Polly review on debug logs command

- Separate runner into two types: runner (logs/runner/, local CLI) and
  host-runner (logs/host-runner/, host daemon) — fixes the blocking bug
  where the default type pointed at the wrong directory
- Broaden server glob to *server*.log to cover both server-*.log
  (omnigent run) and local-server-*.log (background daemon)
- Scope --session to --type host-runner only (where session ids are
  embedded in filenames)
- Guard --follow on Windows with IS_WINDOWS check
- Add min=0 bound to --lines to reject negative values
2026-07-09 07:38:13 +00:00