Compare commits

...

32 Commits

Author SHA1 Message Date
harry-yao_data d33863faeb docs(benchmarks): stop native_hook_spawn claiming to be the per-chunk path
The journey's comment, docstring, README row and CLI description all say
it spawns "the per-chunk MessageDisplay hook exactly as Claude Code
does". That stopped being true when MessageDisplay moved to a /bin/sh
appender and evaluate-policy moved to a curl against the runner's relay.
Both are pinned by tests — test_message_display_shell_command_round_trips
asserts "python" is absent from the installed command — so the number the
journey reports (~40ms here) is not on any per-chunk or per-tool-call
path.

Left as it was, the number reads as ~40ms of blocked TUI per streamed
chunk, which would make it the largest single cost in the system and the
obvious thing to go fix. It isn't, and I went and measured a replacement
for an optimization the repo already has.

Say what it measures instead: the lifetime of a hook that is Python,
which is what the per-turn hooks (SessionStart / Stop / UserPromptSubmit
/ PreCompact / Task*), the PostToolUse TodoWrite+TaskUpdate matchers, and
the policy hook's pre-relay fallback still pay — and which is the
standing argument for keeping the hot paths off the interpreter. Naming
the tests that pin it points the next reader at the evidence rather than
at a stale comment.

No behaviour change; comments, docstring, description and README only.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: harry-yao_data <harry.yao@databricks.com>
2026-08-22 08:50:20 +00:00
Aravind Segu 5d7aa85132 fix(cli): forward non-uuid session ids on remote resume (#5218)
`omnigent resume <id>` canonicalized every id through the local sqlite store's uuid rule (uuid_to_bytes), even when --server points at a remote server that owns its own id space. A deployment that keys sessions on non-uuid ids (e.g. numeric node ids) had every id rejected client-side with "Invalid session id." before any request was sent.

Only the local path binds the id to the Uuid16 column, so keep the strict uuid guard there; on the remote path forward the id untouched and let the server resolve it, matching how the runner and SDK already pass the id straight through.

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
Signed-off-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-21 15:54:45 -07:00
Danny Sievers 243b13abb3 fix(ios): delegate OIDC login to system browser (#2556)
* fix(ios): delegate OIDC login to system browser

Signed-off-by: dannysievers <danjsievers@gmail.com>

* test(e2e_ui): cover iOS OIDC browser handoff

Signed-off-by: dannysievers <danjsievers@gmail.com>

* test(e2e_ui): avoid exfil scan false positive

Signed-off-by: dannysievers <danjsievers@gmail.com>

---------

Signed-off-by: dannysievers <danjsievers@gmail.com>
2026-08-21 15:38:03 -07:00
Tomu Hirata d20c465457 fix(codex): serialize stdin writes to prevent parallel tool-call interleaving (#5229)
When Codex emits parallel tool calls, the harness processes them
sequentially but _send_message's write+drain sequence is not atomic:
a concurrent caller can write() between another caller's write() and
drain(), interleaving bytes on the subprocess stdin pipe.  The Codex
app-server then reads a corrupted JSON-RPC line, drops the response,
and the remaining tool outputs never arrive — causing the turn to stall
for minutes before timing out.

Fix: guard _send_message with an asyncio.Lock (_stdin_lock) so that
the write/drain pair is always atomic.  The lock is initialized in
__init__ alongside the other per-session state.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-21 22:24:09 +00:00
Tomu Hirata f443086bd6 fix(cli): close daemon stdin to release terminal on shell exit (#5228)
The host daemon inherited stdin from the spawning shell, holding an open
fd to the pseudo-terminal (/dev/pts/N). Even with start_new_session=True,
the shell blocks on exit until all processes with that fd close it — so
users running 'isaac omni codex' in arca were forced to manually kill the
daemon before they could exit.

Passing stdin=subprocess.DEVNULL redirects the daemon's stdin to /dev/null
at spawn time, releasing the terminal fd immediately.

Fixes OMNI-3274.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-08-22 07:23:32 +09:00
Pat Sukprasert 203eb3e589 fix(e2e): de-flake test_repl_approve_always_caches_for_later_turns PTY timing race (#5224)
Wait for deterministic LLM response content instead of the racy toolbar marker.

The test was using _wait_for_turn_complete() which waits for the toolbar's
· ready marker. Under CI load, this marker can appear before Turn 2's actual
output (the "auto-approved" audit line and LLM response) renders, causing
child.before to be captured prematurely with only the input echo. This is a
classic PTY timing race.

The fix: wait for the scripted LLM response "Following up as requested." which
only appears after the full turn has rendered, including the required
"auto-approved" audit line. This synchronization pattern is already used
successfully in test_repl_two_turns_fires_one_approval_per_turn (line 602).

Verified: 10 consecutive runs all passed (was flaky ~1-3% on CI).

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Isaac <no-reply@databricks.com>
2026-08-21 21:21:46 +00:00
Pat Sukprasert 981c4fff3e fix(ci): relay fork review hygiene (#5063)
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-22 04:08:25 +07:00
Pat Sukprasert 8103829ef8 fix(codex-native): honor Max/Ultra reasoning levels instead of coercing to xhigh (#5217)
* fix(codex-native): honor Max/Ultra reasoning levels instead of coercing to xhigh

Codex advertises a per-model reasoning ladder via model/list: Sol reaches
`ultra`, Luna reaches `max`, and a turn at those levels completes (Sol's
`ultra` runs subagents). Omnigent's picker surfaced them, but the codex-native
effort override validated against the xhigh-capped CODEX_EFFORTS ladder, so a
web-picked Max/Ultra was silently coerced to xhigh before the wire — and the
TUI->web effort mirror stored `ultra` as `xhigh`, so the UI showed the wrong
level.

Validate codex-native efforts against the full codex ladder
(CODEX_NATIVE_EFFORTS) and add `ultra` to the session-metadata vocabulary, so a
picked level rides through unchanged and the UI shows the real level. The
SDK/Responses codex path keeps the xhigh cap + ultra/max alias, preserving
OMNI-1694's defensive fold on that backend.

OMNI-4255

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* fix(web): follow the drafted model's effort ladder in the Configure Codex modal

The effort dropdown mapped a static effortLevels prop computed from the
committed model, so switching the model inside the modal (Sol → Luna) still
listed Sol's `ultra`, with the stale level left selected. Recompute the ladder
from the drafted model and drop a picked level the new model doesn't offer, so
the dropdown never shows a rung the model rejects and Save can't submit one.

OMNI-4255

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

* chore(codex): ultra parity in REPL /effort, schema doc, and GLM effort cap

Address Polly's non-blocking notes on #5217, now that `ultra` is a
first-class effort value:
- REPL `/effort` accepts and lists `ultra` (matched EFFORT_VALUES).
- schemas.py reasoning_effort docstring enumerates `ultra`.
- GLM effort cap treats `ultra` as unsupported — every rung above `high` —
  so a pinned `ultra` clamps to medium like `xhigh`/`max`.

OMNI-4255

Co-authored-by: Isaac
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-08-21 20:52:14 +00:00
Yuan Tang 03b6ee51f5 feat(scheduled): auto-attach cost_budget policy on fire (#4791)
* feat(scheduled): auto-attach cost_budget policy on fire

Add an optional `max_cost_usd` field to scheduled tasks. When set, the
fire path attaches a `cost_budget` policy to each spawned session,
capping cumulative LLM spend at the configured limit. This prevents
runaway token spend from unattended scheduled runs.

The attachment is non-fatal: if the policy store is unavailable or the
create fails, the session proceeds uncapped (an uncapped session is
better than a dead run).

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix: chain migration after current alembic head

Point the max_cost_usd migration's down_revision to the actual current
head (d5e9f1a2b3c4) instead of z9a2b3c4d5e6, which already had another
child — creating a branch that broke alembic's single-head requirement.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix: SQLite-safe downgrade and update column set test

Use batch_alter_table in migration downgrade for SQLite compatibility.
Add max_cost_usd to the expected column set in migration tests.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

* fix: chain migration after merged main head za2b3c4d5e6f

Repoint down_revision from d5e9f1a2b3c4 to za2b3c4d5e6f so the
migration chains after the task_summary migration that landed on
main, avoiding a dual-head conflict.

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>

---------

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-08-21 16:22:23 -04:00
Lee moon soo a8b030b786 fix(runner): keep native harness alive during terminal activity (#5212)
Signed-off-by: Lee moon soo <moonsoo.lee@databricks.com>
2026-08-21 12:35:22 -07:00
Hubert 0a4a8114ad Add icon when creating project (#5209)
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
2026-08-21 18:09:45 +00:00
Hubert f7861ec494 Improve icon picking (#5170)
* Improve icon picking

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* Bugfix

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* Bugfix

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

* Bugfix only [no add icon option]

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>

---------

Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
Co-authored-by: Hubert Zub <hubert.zub@databricks.com>
2026-08-21 17:17:28 +00:00
Harry Yao eeec1cedf0 fix(runner): dedupe repeat sub-agent inbox re-wake notices (#4803)
* fix(runner): dedupe repeat sub-agent inbox re-wake notices

A parent that idles holding undrained sub-agent results gets a recovery
wake notice ("[System: sub-agent X/Y finished (completed) — N results
waiting in inbox. Call sys_read_inbox to collect.]"). In a large
homogeneous fan-out the latest child's label and the pending count
plateau, so that nudge repeats the identical line at every turn boundary
— visible spam in the parent's stream.

Record the last re-wake notice delivered per parent and skip a follow-up
re-wake that matches it verbatim. The first recovery nudge for any state
still fires (a stranded inbox is never left silent), and distinct
new-completion notices always post (their count differs), so no results
are lost — only redundant repeats are dropped.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* chore(runner): drop explanatory comments on re-wake dedupe

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(runner): let a drained inbox clear the re-wake dedupe record

The re-wake dedupe records the last delivered recovery notice per parent
so a verbatim repeat is skipped. That record described outstanding work,
but was only dropped on session teardown, so it outlived the stranding
episode it belonged to. After the parent drained, a later fan-out round
that produced the same label and pending count had its recovery wake
suppressed as a duplicate — leaving the parent holding undelivered
results with the wake flag cleared. Once every child has finished
nothing re-arms that flag, so the results are never collected: the exact
strand the recovery wake exists to break, and reachable in the
homogeneous fan-out the dedupe targets.

Treat a drained inbox as ending the episode and forget the record, so a
new episode's notice is judged on its own. Suppression across an
intervening completion wake is unchanged.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

---------

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-21 07:11:20 -07:00
Corey Zumar 3d537bba59 fix(claude-native): cold-resume a session on its persisted canonical model (#5167)
A pane's /model persists the exact id it runs (claude-opus-4-8), but the launch gate only accepted an exact catalog row, and a direct-login catalog spells that family as alias rows (opus -> claude-opus-5, the appended claude-opus-4-8[1m] default). Every live switch to a non-default model therefore armed a resume failure: "not in this host's current model list".

Accept a canonical Anthropic id when the endpoint serves canonical spellings and the catalog lists the id's family — the same fold /model already applies to an unpinned canonical id — and keep refusing gateways, Bedrock, and unlisted families so a stale pick still fails fast.

Covers the cold resume of a persisted pick for claude and codex in the live model-flows suite (red on claude before the fix, green after; codex has no alias layer and passes both ways), plus unit coverage of the fold and the launch gate.

Closes #5158
2026-08-21 00:47:49 -07:00
Harry Yao 67f9db8bc8 Implement Claude Code permission mode switching (#4018)
* feat(web): switch claude-native permission mode mid-session

Claude Code's permission mode could only be chosen when starting a
session: `--permission-mode` is a launch flag, so a running session was
stuck in whatever mode it booted with. Switching to auto mode meant
either attaching to the tmux pane and pressing shift+tab, or ending the
session and starting a new one.

Claude Code exposes no non-interactive mode command (`/permissions`
opens an interactive rules dialog, and settings files are read only at
startup), so the switch drives the TUI's own shift+tab cycle:

- `claude_native_bridge.set_permission_mode` presses `BTab` and reads
  the mode footer Claude renders below its input box after each press,
  until the target mode appears. The cycle is walked rather than
  computed from a press count because its width varies — `auto` is only
  in it for accounts that have the mode, and `bypassPermissions` only
  when the session launched into it. `dontAsk`/`bypassPermissions` are
  rejected up front as unreachable.
- The runner dispatches a `permission_mode_change` event and echoes back
  the mode the pane actually landed on.
- PATCH /v1/sessions accepts `permission_mode` and persists it as a
  label only after the runner confirms the switch, so the UI can never
  claim auto mode while Claude is still prompting on every edit. The
  label (not `terminal_launch_args`, which records only launch flags) is
  what the UI reads back after a reload.
- The composer grows a mode picker for claude-native sessions, mirroring
  the existing Codex plan-mode toggle.

The prompting mode is relabelled "Manual" to match what Claude Code
calls it in its own UI and renders in the pane footer; the wire value
stays `default`.

Test Plan
- New bridge tests cover the walk, the no-op when already in the target
  mode, an unreachable mode, non-cycleable modes, a pane with no footer,
  and a stale footer mid-repaint (this last case was a real bug found
  against a live TUI: reading the pre-keystroke mode made the cycler lag
  a mode behind and report `auto` unreachable).
- New runner tests cover the dispatch, the 503 on a failed switch, and
  the non-native no-op; new server tests cover the forward, the
  label-only-after-confirmation rule, and both rejection paths.
- Verified end-to-end against a real `claude` 2.1.220 TUI in tmux:
  every mode reached and confirmed in the pane, including a repeated
  request for the mode already active.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* feat(web): mirror in-terminal permission-mode switches to the UI

A shift+tab pressed inside the Claude Code TUI never reached the web UI:
only UI-driven switches stamped the mode label, so the composer's picker
showed a stale mode until the next switch from the web side.

Claude Code emits no event on a mode change and hook payloads only arrive
on tool use, so the rendered mode footer is the only available signal. The
forwarder polls it (throttled to 2s — each read spawns a tmux capture-pane
subprocess) and POSTs `external_permission_mode_change` when it differs
from the last reported mode. The server persists the label and publishes
`session.permission_mode` so the live picker follows the pane and a
reloading client restores the same state.

Anchor the footer scan on the input box's closing rule instead of a fixed
tail offset: the footer's height scales with concurrent subagents, and the
anchor also excludes transcript text structurally, so a mode name Claude
echoed while discussing modes can't be misread as live.

The first observation after spawn is the launch mode, not a switch, so it
seeds the baseline without posting — otherwise a passive spawn default
could clobber a mode the web UI just set. An unreadable pane reads as
"unknown" rather than a guess, and the picker hides instead of displaying
a mode the session may not be in (a `permissions.defaultMode` in a
settings file never reaches the launch args).

Also move the picker into the session-config gear modal alongside model
and effort, keeping the composer row uncluttered.

Fix: `external_permission_mode_change` was missing from the events route's
payload-validation passthrough, so every forwarder POST was rejected with
a 400 before reaching its handler — and the forwarder swallows HTTP errors
at debug level, so the mirror failed silently. Added an end-to-end test
that drives the real forwarder against a live server and asserts the event
crosses the actual SSE wire, since each layer's own mocked tests passed
while the seam between them was broken.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* test: drop redundant permission-mode cases and tighten comments

The `sse.test.ts` block duplicated `sessionEvents.test.ts`, which pins the
same envelope through `parseEventLines` and is the file whose stated job is
catching silent lift bugs. The composer case asserting the absence of the
old standalone picker guarded a selector that no longer exists anywhere.

Rewrite the comments that described the change instead of the code: the
e2e docstring recounted how the 400 was found, and a composer comment
narrated what the gear "used to" do.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(server): don't 500 on a silent permission-mode PATCH

`silent` suppresses the live runner forward, so no mode is confirmed and no
label is written — but the publish read `labels_to_set[...]` unconditionally
and raised KeyError, turning a request that changed nothing into a 500.

Gate the publish on the label actually being set. That also keeps an
unconfirmed mode off the picker, matching the rule the forward path already
follows: the label is written only once the pane really moved.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(ci): address all 4 failing CI checks on PR #4018

- Regenerate openapi.json to include the new SessionPermissionModeEvent
  type and permission_mode field on UpdateSessionRequest; fixes Pytest
  (server-rest) openapi drift check.

- Update E2E landing-page permission-mode test: the default mode label
  was renamed from "Default" to "Manual" in claudePermissionMode.ts;
  fixes E2E UI Tests (shard 2/3).

- Add test_claude_native_permission_mode_switch_persists to cover the
  new in-chat permission-mode picker in the gear modal; extends
  _patch_session_as_claude_native with a permission_mode param so the
  label stub is set; resolves the E2E UI Required gate.

- Raise the asyncio yield deadline in the quiescence-recheck test from
  10 000 to 50 000 to eliminate flaky timing failures on slower CI
  runners; fixes Pytest (misc).

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* fix(claude-native): show the permission picker for manual-mode sessions

A session launched in manual mode had no way to leave it. Manual is the
default and writes no `--permission-mode` arg, and the forwarder discarded
its first pane observation as "just the launch mode", so nothing ever
recorded the session's mode. With no mode to render the web picker hid
itself, and the switch could never be started — not because cycling
failed, but because there was no control to press.

Post the first observation instead of seeding silently, so every live
claude-native session publishes its mode within one poll whatever it
launched in, CLI `omnigent claude` included. The overwrite this guarded
against cannot happen: the server ignores a mode equal to the stored
label, and the PATCH path only persists after the runner confirms the
pane repainted, so a stored label already matches the pane.

Verified against a real Claude Code 2.1.237 pane: manual renders
`⏸ manual mode on`, and shift+tab cycles manual → accept edits → plan →
auto → manual, matching the bridge's footer table exactly.

Three tests had encoded seed-silently as intended behavior and now assert
the launch mode reaching the wire.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

* test(e2e): locate permission-mode options by data attribute

The picker's options render their label and description in nested spans, so
each option's accessible name is "Auto Auto-runs; a classifier blocks risky
actions" — never the bare label. `get_by_role("option", name="Auto",
exact=True)` could therefore never match, and the click timed out after 30s.

Tag each option with `data-permission-mode` and select on that, matching how
the sibling model and effort rows already expose `data-model-id` and
`data-effort-level` for the same reason.

Co-authored-by: Isaac
Signed-off-by: harry-yao_data <harry.yao@databricks.com>

---------

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-20 23:14:53 -07:00
Zeyi (Rice) Fan 2c2afac192 feat(electron): clarify desktop update prompts (#5160)
## Related issue

N/A

## Summary

- Show the installed and available Omnigent Desktop versions in the shell-owned update prompt, with session-safety copy on its own line and unclipped card chrome.
- Carry the effective desktop version through updater status events and keep the native up-to-date dialog consistent.
- Add a development-only semantic-version override that checks the production update feed, making the real update flow reproducible without changing packaged behavior.

ELI5: development can pretend the installed desktop app is older, compare it with the production feed, and pass that same version into the update card.

```text
OMNIGENT_DESKTOP_VERSION_OVERRIDE
                |
                v
      electron-updater baseline ---> production update feed
                |
                v
      update status + current version ---> shell overlay
```

## Test Plan

- `node --test web/electron/test/desktop_updater.test.js`
- `node --test web/electron/test/update-main.test.js`
- `pnpm --filter web exec vitest run src/components/UpdateBanner.test.tsx`
- `pnpm --filter web type-check`
- Targeted `oxlint` over the changed Electron and web sources/tests.
- `pnpm --filter web build:overlay`
- Development flow: `OMNIGENT_DESKTOP_VERSION_OVERRIDE=0.9.0 pnpm start`, then **Server → Check for Updates…** against the production feed.

## Demo

N/A — the UI is rendered in an Electron-owned transparent child window; the focused component assertions and overlay production build cover the final copy and layout contract.

## Type of change

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

## Test coverage

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

## Coverage notes

The updater unit harness covers version propagation, development override isolation, and production-feed configuration. The UpdateBanner test covers available, downloading, and downloaded copy.

## Changelog

Desktop update prompts now show the installed and available versions with clearer copy and polished overlay spacing.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-21 05:57:33 +00:00
Zeyi (Rice) Fan 733234c303 fix(electron): restore version lockstep (#5150)
## Related issue

N/A

## Summary

- Restore the Electron package version to the semver translation of the repository's Python package version.
- Make the Version lockstep check pass after the desktop version drifted to `0.10.0` on `main`.

## Test Plan

- `uv run --frozen --project ../.. python ../../scripts/update_versions.py check`
- `git diff --check`

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

The existing version-update tests cover Electron version stamping and desktop-version drift detection.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 20:28:07 -07:00
Zeyi (Rice) Fan 039661185d chore(electron): set desktop version to 0.10.0 (#5145)
## Related issue

N/A

## Summary

- Stamp the Omnigent Electron desktop package as version 0.10.0 for the desktop release.

## Test Plan

- `node -e "const p=require('./web/electron/package.json'); if (p.version !== '0.10.0') throw new Error(p.version); console.log(p.name + '@' + p.version)"`
- `git diff --check`

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

This is a package metadata-only version stamp; the package JSON was loaded directly to verify the resulting version.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 19:50:37 -07: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
Zeyi (Rice) Fan f914095c1d fix(cli): preserve Databricks profile shorthand (#5143)
## Related issue

[OMNI-4272](https://linear.app/omnigent/issue/OMNI-4272/add-a-global-profile-option-to-omni-cli-for-profiling)

## Summary

- Rename the global CPU profiler flag from `--profile` to `--profiling` so it no longer collides with the existing Databricks `--profile NAME` option.
- Preserve the historical bare-run shorthand `omni --profile my-sp ...` while keeping CPU profiling available for every explicit command.
- Add regression coverage for both options used independently and together.

**ELI5:** `--profiling` measures performance; `--profile NAME` continues to choose Databricks credentials.

```text
omni --profiling COMMAND       -> CPU profile
omni --profile my-sp -p "..."  -> run with Databricks profile my-sp
```

## Test Plan

- `uv run ruff format omnigent/cli.py tests/cli/test_cli.py`
- `uv run ruff check omnigent/cli.py tests/cli/test_cli.py`
- `uv run pytest -q tests/cli/test_cli.py::test_global_profiling_writes_summary_and_timestamped_stats tests/cli/test_cli.py::test_removed_ad_hoc_detection tests/cli/test_cli.py::test_help_groups_harnesses_and_other_commands tests/cli/test_cli.py::test_run_profile_sets_databricks_config_profile_env tests/cli/test_cli.py::test_bare_run_profile_shorthand_still_selects_databricks_profile tests/cli/test_cli.py::test_global_profiling_coexists_with_run_databricks_profile`

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

The tests exercise the real module entry point for CPU profiling and `main()` argv rewriting for the historical Databricks profile shorthand.

## Changelog

Use `omni --profiling COMMAND` for CPU profiling without conflicting with Databricks `--profile NAME` selection.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 18:57:13 -07:00
Zeyi (Rice) Fan 269dffacb6 feat(cli): add global CPU profiling (#5141)
## Related issue

[OMNI-4272](https://linear.app/omnigent/issue/OMNI-4272/add-a-global-profile-option-to-omni-cli-for-profiling)

## Summary

- Add a global `omni --profile COMMAND` flag so developers can profile any CLI command with Python's built-in `cProfile`.
- Print actionable Omnigent-only cumulative and self-time summaries while preserving the complete timestamped `.prof` data under `~/.omnigent/profiles/`.
- Keep the existing `omni run --profile NAME` Databricks credential option compatible, including when both profile options are used together.

**ELI5:** Put `--profile` before a command to see which Omnigent functions made it slow; open the saved `.prof` file when deeper analysis is needed.

```text
omni --profile COMMAND
        |
        v
     cProfile
      /    \
focused stderr  full timestamped .prof
```

## Test Plan

- `uv run ruff format --check omnigent/cli.py tests/cli/test_cli.py`
- `uv run ruff check omnigent/cli.py tests/cli/test_cli.py`
- `uv run --group test pytest -q tests/cli/test_cli.py::test_global_profile_writes_summary_and_timestamped_stats tests/cli/test_cli.py::test_removed_ad_hoc_detection tests/cli/test_cli.py::test_help_groups_harnesses_and_other_commands tests/cli/test_cli.py::test_run_profile_sets_databricks_config_profile_env tests/cli/test_cli.py::test_global_cpu_profile_coexists_with_run_databricks_profile tests/cli/test_cli.py::test_run_profile_wins_over_preset_env tests/cli/test_cli.py::test_run_without_profile_leaves_preset_env_untouched`
- Manually ran `OMNIGENT_DATA_DIR=<temp-dir> uv run python -m omnigent --profile version` and inspected the focused summary and generated `.prof` file.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Verified the real `python -m omnigent` entry point creates a loadable timestamped profile, renders only actionable Omnigent functions in the summary, and preserves the separate Databricks `run --profile NAME` behavior.

## Changelog

Use `omni --profile COMMAND` to print focused CPU profiling results and save the full profile for deeper analysis.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-21 01:32:32 +00:00
Zeyi (Rice) Fan 55459d5df9 chore(packaging): remove deprecated memory extra (#5139)
## Related issue

https://linear.app/omnigent/issue/OMNI-4270/remove-memory-extra

## Summary

- Remove the deprecated `memory` compatibility alias now that `hindsight` is the canonical extra.
- Regenerate the normalized lockfile so published package metadata no longer advertises `memory`.

## Test Plan

- `uv run --no-sync pytest -q tests/onboarding/test_extra_install.py`
- Build the wheel and inspect its `METADATA` to confirm `hindsight` remains in `Provides-Extra` and `memory` does not.
- `git diff --check`

## Demo

N/A — packaging-only change.

## Type of change

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

## Test coverage

- [ ] 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

Verified the built wheel's optional dependency metadata directly; the existing onboarding extra tests continue to pass.

## Changelog

The deprecated `omnigent[memory]` extra has been removed; use `omnigent[hindsight]` instead.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-21 01:19:28 +00:00
Zeyi (Rice) Fan 2fbd660f55 fix(web): copy tmux selections to the system clipboard (#5136)
## Related issue

[OMNI-4259](https://linear.app/omnigent/issue/OMNI-4259/cannot-copy-out-of-tui-in-web-desktop-app)

## Summary

- Forward tmux paste-buffer changes from control-mode terminals to the active owner’s browser clipboard, with payload limits, recent-input gating, and read-only isolation.
- Support safe tmux-generated OSC 52 writes for PTY terminals while disabling them when passthrough could let pane output bypass tmux’s clipboard policy.
- Add browser permission fallback UI, preserve terminal focus, and cover the bridge, routing, parser, and clipboard behavior with focused tests.

**ELI5:** When a terminal app says “copy this,” the runner now carries that text back to the browser, which places it on the computer’s real clipboard instead of leaving it only inside tmux.

```text
tmux copy buffer ── control notification ──> WebSocket JSON ──┐
tmux PTY copy   ── safe OSC 52 ───────────────────────────────┤
                                                              v
                                                     browser clipboard
```

## Test Plan

- `uv run pytest tests/terminals/test_control_bridge.py -q`
- `uv run pytest tests/terminals/test_ws_bridge.py -k 'osc52_capability or forward_pty_to_ws' -q`
- `TMPDIR=/tmp uv run pytest tests/inner/test_terminal.py::test_server_survives_inner_process_exit_real_tmux tests/inner/test_terminal.py::test_launch_enables_csi_u_extended_keys_quietly -q`
- `uv run pytest tests/runner/test_terminal_resource_attach_ws.py -k 'selects_control_bridge_on_transport_query' -q`
- `cd web && npm test -- --run src/components/blocks/TerminalSession.test.ts src/components/blocks/TerminalView.test.tsx`
- `cd web && npx tsc -p tsconfig.app.json --noEmit`
- Targeted Ruff, Oxlint, Prettier, and `git diff --check`.

## Demo

N/A — no screenshot or recording was captured for the terminal protocol integration.

## Type of change

- [x] Bug fix
- [ ] Feature
- [x] 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
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Focused tests cover tmux buffer notifications, bounded reads, cancellation cleanup, read-only and recent-input behavior, PTY OSC 52 capability negotiation, passthrough hardening, browser parsing, permission fallback, focus restoration, and transport routing.

## Changelog

Text copied by terminal apps now reaches your system clipboard in the web terminal.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 17:30:55 -07:00
Zeyi (Rice) Fan 275465d862 feat(electron): notarize macOS DMG releases (#5132)
## Related issue

N/A

## Summary

- Sign, notarize, staple, and validate each final macOS DMG from the existing one-command release build.
- Reuse the app notarization credentials and fail the release if Apple rejects a DMG or stapling fails.
- ELI5: the app was already sealed and checked by Apple; this also seals and checks the downloadable disk image around it.

```mermaid
flowchart LR
  A[Sign and notarize app] --> B[Build and sign DMG]
  B --> C[Notarize DMG]
  C --> D[Staple and validate]
```

## Test Plan

- `cd web/electron && node --test test/notarizeDmg.test.js`
- `uv run pre-commit run --files web/electron/package.json web/electron/build/afterAllArtifactBuild.js web/electron/test/notarizeDmg.test.js web/electron/README.md`
- Live Apple submission was not run locally because it requires release credentials.

## Demo

N/A — non-visual release packaging change.

## Type of change

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

## Test coverage

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

## Coverage notes

Unit tests cover credential argument construction, release command sequencing, successful notarization, and rejection handling. The release hook also validates the real codesign and stapling commands when run with Apple credentials.

## Changelog

macOS DMG releases are now signed, notarized, and stapled for stronger download verification.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 16:52:00 -07:00
Zeyi (Rice) Fan 93ce9a957e feat(electron): allow opt-in production DevTools (#5130)
N/A — maintainer-requested Electron debugging support.

- Keep the production Electron shell locked down by default while allowing packaged macOS builds to opt into DevTools through `defaults write ai.omnigent.desktop DeveloperMode -bool true`.
- Use the same developer-mode decision for the Debug menu and the main window's DevTools capability, without relaxing update security gates.
- Document the opt-in and cover the preference decision and main-process wiring.

ELI5: development builds keep their debugging tools; a production build only unlocks them when the local macOS user explicitly sets the app preference.

```text
app launch
  ├─ unpackaged build ───────────────────────> DevTools enabled
  └─ packaged build + macOS DeveloperMode ──> DevTools enabled
                   otherwise ────────────────> DevTools disabled
```

- `node --test web/electron/test/developer_mode.test.js web/electron/test/main.test.js web/electron/test/update-main.test.js`
- `web/node_modules/.bin/oxlint --deny-warnings --report-unused-disable-directives web/electron/src/developer_mode.js web/electron/src/main.js web/electron/test/developer_mode.test.js web/electron/test/main.test.js web/electron/test/update-main.test.js`
- `(cd web && node_modules/.bin/tsc -b)`
- `git diff --check`

N/A — this is an opt-in main-process configuration change with no default UI change.

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

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

The unit matrix covers development, packaged macOS opt-in/out, unsupported platforms, and read failures. The main-process harness verifies Debug-menu gating, and a wiring guard verifies the shell window uses the same decision.

Packaged macOS builds can opt into Electron Developer Tools with a local `defaults` setting.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 23:43:14 +00:00
Harry Yao a101840bbc perf(native): overlap session create with the host-online poll (#5091)
On a fresh launch the CLI created the session (`POST /v1/sessions`,
~2.1s) and then waited for the host daemon to register its tunnel
(`GET /v1/hosts/{id}`, ~0.6s), one after the other. Neither depends on
the other: the create needs no host, and the host poll needs no session.
`claude_native` already ran the two concurrently; every other daemon
launch path paid the sum.

Run them under one `asyncio.gather` in `omnigent run` and in the ten
remaining native harnesses (codex, pi, cursor, goose, kimi, qwen,
opencode, hermes, kiro, antigravity), so startup costs the longer of the
two rather than both. The post-create host wait is now guarded by
`fresh_session`, since the fresh path has already done it; a resume still
waits for the host on its own.

For `omnigent run` this also meant lifting the session create/fork into a
`resolve_session()` coroutine so it can run underneath the daemon
client's host poll, instead of opening and closing the SDK client first.

Co-authored-by: Isaac

Signed-off-by: harry-yao_data <harry.yao@databricks.com>
Co-authored-by: harry-yao_data <harry.yao@databricks.com>
2026-08-20 16:40:52 -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
Zeyi (Rice) Fan 9a0e24213b fix(electron): preserve Databricks organization in server URLs (#5133)
## Related issue

N/A

## Summary

- Preserve the Databricks `o` organization selector while normalizing workspace URLs, but strip query parameters from non-workspace and Databricks Apps URLs.
- Keep `?o` through workspace expansion and normalize persisted recent targets before returning them to the setup page.
- Render recent servers as `host` or `host/?o=…`, hiding schemes and the internal `/omnigent` mount without changing the reconnect target.

ELI5: keep the workspace identifier users need, while hiding internal URL details and unrelated query parameters.

```text
stored target -> normalized reconnect URL -> compact recent-server label
```

## Test Plan

- `node --test web/electron/test/url.test.js web/electron/test/main.test.js`
- `uv run --no-sync pytest tests/e2e_ui/desktop/test_setup_connect.py::test_recent_server_connect_and_copy_actions_are_independent tests/e2e_ui/desktop/test_setup_connect.py::test_shared_url_module_defaults_scheme_in_browser`
- `pre-commit run --files tests/e2e_ui/desktop/test_setup_connect.py web/electron/setup/index.html web/electron/src/main.js web/electron/src/url.js web/electron/test/main.test.js web/electron/test/url.test.js`

## Demo

N/A — this changes recent-server text and URL normalization without changing layout.

## Type of change

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

## Test coverage

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

## Coverage notes

Unit and browser E2E coverage verify Databricks-only `o` preservation, workspace expansion, recent-target normalization, and compact labels.

## Changelog

Electron recent servers now retain Databricks organization links while showing compact server names.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-20 23:26:15 +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
Mark Tai bb5de03e50 feat(cli): pin databricks workspace login to a per-host profile (#5112)
`omnigent login` to a Databricks-fronted server ran `databricks auth login --host <ws>` with no profile, so each workspace overwrote the shared DEFAULT entry in ~/.databrickscfg. Pass `--profile <first DNS label>` so distinct workspaces keep distinct profiles and DEFAULT is left alone. The OAuth grant stays host-keyed, so token resolution by host is unchanged.

Signed-off-by: Mark Tai <marktai@users.noreply.github.com>
Co-authored-by: Mark Tai <marktai@users.noreply.github.com>
2026-08-20 16:15:31 -07:00
Edwin He fd9a7d94ff fix(web): keep the queued-message strip docked when the background task pill shows (#5127)
The queued-message strip and sub-agent tray dock onto the composer card with a
-mb-4 negative margin that tucks their translucent bottom behind the opaque
card. BackgroundTaskPill was rendered between those trays and the card, so the
tuck landed on the pill's transparent wrapper instead: the pill's height lifted
the "Steer" strip well off the composer and left it visually detached. Render
the pill above the trays so they dock onto the card again and the pill floats
above as its own chip.

Co-authored-by: Isaac

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-08-20 16:05:24 -07:00
Randy 🌞 8e02d27bc3 feat(web): add new-session keyboard shortcut (#4840)
Route Cmd/Ctrl+N through the same new-session action as the command palette, and make Electron keep that navigation in the focused window instead of opening another app window.

Signed-off-by: Randy 🌞 <randypitcherii@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 15:52:21 -07:00
192 changed files with 16316 additions and 2379 deletions
+62 -5
View File
@@ -21,6 +21,7 @@ REVIEW_LABEL = "waiting-for-review"
WAITING_DAYS = 7
CANONICAL_REPO = "omnigent-ai/omnigent"
MAX_CLOSURES_PER_RUN = 30
REVIEW_EVENTS = {"pull_request_review", "pull_request_review_comment"}
def label_names(item: dict[str, Any]) -> list[str]:
@@ -114,6 +115,16 @@ class GitHubAPI:
pull, _ = self.request("GET", f"/repos/{self.repo}/pulls/{pull_number}")
return pull
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
review, _ = self.request(
"GET", f"/repos/{self.repo}/pulls/{pull_number}/reviews/{review_id}"
)
return review
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
comment, _ = self.request("GET", f"/repos/{self.repo}/pulls/comments/{comment_id}")
return comment
def remove_label(self, issue_number: int, label: str) -> bool:
quoted = urllib.parse.quote(label, safe="")
try:
@@ -355,10 +366,14 @@ def apply_waiting_on_maintainer_activity(
pull_number = payload["pull_request"]["number"]
review = payload.get("review") or {}
actor = (review.get("user") or {}).get("login")
review_state = (review.get("state") or "").lower()
# An approval asks nothing of the author; it means the PR is ready.
if (review.get("state") or "").lower() == "approved":
if review_state == "approved":
print(f"#{pull_number}: approving review, leaving the label alone.")
return False
if review_state not in {"commented", "changes_requested"}:
print(f"#{pull_number}: {review_state or 'unknown'} review, leaving the label alone.")
return False
if is_slash_command(review.get("body")):
return False
reason = "a maintainer reviewed"
@@ -473,6 +488,42 @@ def close_stale_waiting_prs(api: GitHubAPI, now: datetime | None = None) -> int:
return closed
def relay_integer(record: dict[str, Any], field: str) -> int:
value = record.get(field)
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError(f"Relay field {field!r} must be a positive integer")
return value
def hydrate_relay_event(
record: dict[str, Any], api: GitHubAPI, repo: str, expected_event: str
) -> tuple[str, dict[str, Any]]:
event_name = record.get("event_name")
if event_name not in REVIEW_EVENTS:
raise ValueError(f"Unsupported relayed event: {event_name!r}")
if event_name != expected_event:
raise ValueError(
f"Relayed event {event_name!r} does not match workflow event {expected_event!r}"
)
pull_number = relay_integer(record, "pull_number")
activity_id = relay_integer(record, "activity_id")
pull = api.get_pull(pull_number)
base_repo = ((pull.get("base") or {}).get("repo") or {}).get("full_name") or ""
if base_repo.lower() != repo.lower():
raise ValueError(f"Relayed PR #{pull_number} targets {base_repo!r}, not {repo!r}")
if event_name == "pull_request_review":
review = api.get_review(pull_number, activity_id)
return event_name, {"pull_request": pull, "review": review}
comment = api.get_review_comment(activity_id)
expected_url = f"https://api.github.com/repos/{repo}/pulls/{pull_number}"
if comment.get("pull_request_url") != expected_url:
raise ValueError(f"Review comment {activity_id} does not belong to PR #{pull_number}")
return event_name, {"pull_request": pull, "comment": comment}
def run(
event_name: str,
payload: dict[str, Any],
@@ -495,8 +546,7 @@ def run(
apply_waiting_on_maintainer_activity(event_name, payload, api)
def load_event_payload() -> dict[str, Any]:
path = os.environ.get("GITHUB_EVENT_PATH")
def load_json(path: str | None) -> dict[str, Any]:
if not path:
return {}
with open(path, encoding="utf-8") as handle:
@@ -509,8 +559,15 @@ def main() -> int:
if not token:
print("GITHUB_TOKEN is required", file=sys.stderr)
return 1
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
run(event_name, load_event_payload(), GitHubAPI(token, repo), repo)
api = GitHubAPI(token, repo)
relay_path = os.environ.get("WAITING_ON_AUTHOR_RELAY_PATH")
if relay_path:
expected_event = os.environ.get("WAITING_ON_AUTHOR_RELAY_EVENT", "")
event_name, payload = hydrate_relay_event(load_json(relay_path), api, repo, expected_event)
else:
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
payload = load_json(os.environ.get("GITHUB_EVENT_PATH"))
run(event_name, payload, api, repo)
return 0
+118
View File
@@ -63,6 +63,8 @@ class FakeAPI:
issue_comments: dict[int, list[dict[str, Any]]] | None = None,
review_comments: dict[int, list[dict[str, Any]]] | None = None,
reviews: dict[int, list[dict[str, Any]]] | None = None,
review_by_id: dict[tuple[int, int], dict[str, Any]] | None = None,
review_comment_by_id: dict[int, dict[str, Any]] | None = None,
commits: dict[int, list[dict[str, Any]]] | None = None,
writers: list[str] | None = None,
):
@@ -73,6 +75,8 @@ class FakeAPI:
self.issue_comments = issue_comments or {}
self.review_comments = review_comments or {}
self.reviews = reviews or {}
self.review_by_id = review_by_id or {}
self.review_comment_by_id = review_comment_by_id or {}
self.commits = commits or {}
self.removed: list[tuple[int, str]] = []
self.closed: list[int] = []
@@ -83,6 +87,12 @@ class FakeAPI:
def get_pull(self, pull_number: int) -> dict[str, Any]:
return self.pull | {"number": pull_number}
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
return self.review_by_id[(pull_number, review_id)]
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
return self.review_comment_by_id[comment_id]
def remove_label(self, issue_number: int, label: str) -> bool:
self.removed.append((issue_number, label))
return True
@@ -442,6 +452,21 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
)
self.assertEqual(api.added, [])
def test_dismissed_review_leaves_the_label_alone(self) -> None:
api = self.dispatch(
"pull_request_review",
{
"pull_request": {"number": 12},
"review": {
"user": {"login": "maintainer1"},
"state": "dismissed",
"body": "stale feedback",
},
},
pull=pr(labels=[]),
)
self.assertEqual(api.added, [])
def test_commenting_review_applies_the_label(self) -> None:
api = self.dispatch(
"pull_request_review",
@@ -483,6 +508,99 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
)
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
def test_relayed_review_rehydrates_trusted_api_data(self) -> None:
pull = pr(labels=[]) | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
api = FakeAPI(
pull=pull,
review_by_id={
(12, 41): {
"id": 41,
"user": {"login": "maintainer1"},
"state": "changes_requested",
"body": "please fix",
}
},
)
event, payload = waiting_on_author.hydrate_relay_event(
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review",
)
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
def test_relayed_review_comment_rehydrates_author_reply(self) -> None:
pull = pr(author="alice") | {
"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}
}
api = FakeAPI(
pull=pull,
review_comment_by_id={
73: {
"id": 73,
"user": {"login": "alice"},
"body": "fixed",
"pull_request_url": (
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/12"
),
}
},
)
event, payload = waiting_on_author.hydrate_relay_event(
{
"event_name": "pull_request_review_comment",
"pull_number": 12,
"activity_id": 73,
},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_relayed_review_comment_must_match_pull(self) -> None:
pull = pr() | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
api = FakeAPI(
pull=pull,
review_comment_by_id={
73: {
"id": 73,
"pull_request_url": (
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/99"
),
}
},
)
with self.assertRaisesRegex(ValueError, "does not belong"):
waiting_on_author.hydrate_relay_event(
{
"event_name": "pull_request_review_comment",
"pull_number": 12,
"activity_id": 73,
},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
def test_relayed_event_must_match_workflow_event(self) -> None:
api = FakeAPI()
with self.assertRaisesRegex(ValueError, "does not match workflow event"):
waiting_on_author.hydrate_relay_event(
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
api,
waiting_on_author.CANONICAL_REPO,
"pull_request_review_comment",
)
def test_applying_clears_waiting_for_review(self) -> None:
api = self.dispatch(
"issue_comment",
@@ -0,0 +1,78 @@
name: Waiting on Author Review Run
# Privileged half of the fork-review relay. The artifact contains numeric IDs
# only; the script re-fetches the PR and review/comment from GitHub before using
# actor identity, review state, or comment content. No PR code is checked out.
on:
workflow_run:
workflows: [Waiting on Author Review]
types: [completed]
permissions:
contents: read
concurrency:
group: waiting-on-author-review-run-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
jobs:
hygiene:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Download recorded review event IDs
id: download
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
const { owner, repo } = context.repo;
const arts = await github.rest.actions.listWorkflowRunArtifacts({
owner, repo, run_id: context.payload.workflow_run.id,
});
const art = arts.data.artifacts.find(
a => a.name === 'waiting-on-author-review-event'
);
if (!art) {
core.info('No fork-review artifact; the direct review job handled this event.');
core.setOutput('found', 'false');
return;
}
const dl = await github.rest.actions.downloadArtifact({
owner, repo, artifact_id: art.id, archive_format: 'zip',
});
fs.writeFileSync(
`${process.env.RUNNER_TEMP}/waiting-on-author-review.zip`,
Buffer.from(dl.data)
);
core.setOutput('found', 'true');
- name: Check out trusted .github
if: steps.download.outputs.found == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github
persist-credentials: false
- name: Apply relayed waiting-on-author state
if: steps.download.outputs.found == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
RELAY_ZIP: ${{ runner.temp }}/waiting-on-author-review.zip
RELAY_DIR: ${{ runner.temp }}/waiting-on-author-review
WAITING_ON_AUTHOR_RELAY_EVENT: ${{ github.event.workflow_run.event }}
WAITING_ON_AUTHOR_RELAY_PATH: ${{ runner.temp }}/waiting-on-author-review/event.json
run: |
mkdir -p "$RELAY_DIR"
unzip -q "$RELAY_ZIP" -d "$RELAY_DIR"
python3 .github/scripts/waiting_on_author.py
@@ -0,0 +1,71 @@
name: Waiting on Author Review
# Review events on fork PRs receive a read-only token. Same-repo reviews run the
# hygiene script directly; fork reviews record only GitHub-provided numeric IDs
# for the privileged workflow_run consumer. No PR code is checked out.
on:
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
permissions:
contents: read
concurrency:
group: waiting-on-author-review-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
hygiene:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check out .github
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: .github
persist-credentials: false
- name: Update waiting-on-author state
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 .github/scripts/waiting_on_author.py
record:
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Record review event IDs
env:
EVENT_NAME: ${{ github.event_name }}
PULL_NUMBER: ${{ github.event.pull_request.number }}
ACTIVITY_ID: ${{ github.event.review.id || github.event.comment.id }}
run: |
mkdir -p relay
jq -n \
--arg event_name "$EVENT_NAME" \
--argjson pull_number "$PULL_NUMBER" \
--argjson activity_id "$ACTIVITY_ID" \
'{event_name: $event_name, pull_number: $pull_number, activity_id: $activity_id}' \
> relay/event.json
- name: Upload review event IDs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: waiting-on-author-review-event
path: relay/event.json
retention-days: 1
if-no-files-found: error
@@ -9,6 +9,8 @@ on:
- .github/scripts/waiting_on_author.py
- .github/scripts/waiting_on_author_test.py
- .github/workflows/waiting-on-author.yml
- .github/workflows/waiting-on-author-review.yml
- .github/workflows/waiting-on-author-review-run.yml
- .github/workflows/waiting-on-author-test.yml
workflow_dispatch:
+2 -4
View File
@@ -6,16 +6,14 @@ name: Waiting on Author Hygiene
# once a review is submitted). The two labels are mutually exclusive. PRs left
# waiting on the author for 7 days are closed. The workflow runs from trusted
# default-branch code and never checks out PR-authored files.
# Review events use the read-only -> workflow_run relay in
# waiting-on-author-review.yml and waiting-on-author-review-run.yml.
on:
pull_request_target:
types: [synchronize, labeled]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
+7
View File
@@ -159,6 +159,13 @@ POSTGRES_PASSWORD=change-me-please
# ── Optional OIDC tuning ─────────────────────────────────
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
#
# Absolute lifetime (days) of login-issued refresh grants — how long a
# host/CLI that logged in with `omnigent login` can keep renewing its
# access before a human must log in again. Default 30. Unattended hosts
# renew automatically within this window; each host replica needs its
# own login (refresh tokens rotate — shared copies revoke each other).
# OMNIGENT_GRANT_MAX_LIFETIME_DAYS=30
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
#
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
+88 -12
View File
@@ -17,9 +17,11 @@
> enforcement in `omnigent/server/auth.py` (`delegated_path_allowed`,
> `set_grant_revocation_check`). Wired in `omnigent/server/app.py`,
> **opt-in and default-off** via `OMNIGENT_DEVICE_GRANT_ENABLED` (the
> `/oauth/*` routes are unmounted unless it is truthy), and then only in
> **accounts** mode (OIDC delegates login to the IdP via the cli-ticket
> flow and never mounts these routes).
> `/oauth/device/*` consent routes are unmounted unless it is truthy), and
> then only in **accounts** mode (OIDC delegates login to the IdP via the
> cli-ticket flow and never mounts the consent routes). The token/revoke
> half (`/oauth/token`, `/oauth/revoke`) mounts **unconditionally** in both
> accounts and OIDC modes: login-issued refresh grants (below) need it.
> Slack: `integrations/slack/src/omnigent_slack/oauth.py`,
> `tokens.py` (Fernet-encrypted `oauth_tokens`), `auth_manager.py`, plus
> the bearer/refresh wiring in `omnigent.py` (`ClientAuth`,
@@ -182,15 +184,20 @@ approved it.
### Router `omnigent/server/routes/device_auth.py`
Mounted in `app.py` only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy**
(opt-in, **default-off** — the `/oauth/*` routes are absent otherwise), and
then **only in `accounts` mode** (OIDC delegates login to the IdP via the
cli-ticket flow and never mounts these routes; header mode has no
server-mintable identity — see `create_device_auth_router`, which raises if
constructed for any other source). The `device_grants` table is created
unconditionally by the migration regardless of the flag; only the router
mount is gated. This router **owns** `mint_delegated_token` and
`DELEGATED_SCOPE`.
The RFC 8628 consent surface (`/oauth/device/*`) is mounted in `app.py`
only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy** (opt-in,
**default-off**), and then **only in `accounts` mode** (the in-browser
consent needs the accounts login page; header mode has no server-mintable
identity — see `create_device_auth_router`, which raises if constructed for
any other source). The token/revoke half is factored into
`create_oauth_token_router` and mounts **unconditionally** in both accounts
and OIDC modes — login-issued refresh grants need `/oauth/token` even where
the device flow is off; a standalone mount refuses the `device_code` grant
type with `unsupported_grant_type`. The `device_grants` table is created
unconditionally by the migration regardless of the flag; only the consent
mount is gated. This router also **owns** `mint_delegated_token` and
`DELEGATED_SCOPE` (moved here from `oidc.py`, which retains only
`mint_session_token` / `mint_session_cookie`).
- `POST /oauth/device/authorize`**public** (rate-limited). Generates a
high-entropy `device_code` (`secrets.token_urlsafe`, stored **hashed**), a
@@ -355,3 +362,72 @@ stateless. This added invariant is the main thing for reviewers to scrutinize.
- Applying the same delegated grant to other non-browser clients (the CLI could
use it too, superseding the in-memory `_cli_tickets` store).
- Per-scope consent granularity beyond the single "session APIs, no admin" scope.
## Login-issued refresh grants
The fix for unattended hosts dying at session-JWT expiry (a host
authenticated via `omnigent login` previously had **no renewal path**
the stored `{token, user_id, expires_at}` record simply lapsed, default
8 h, and the next tunnel reconnect got a misleading 403).
- **Issuance** — both interactive login flows create a grant born
`redeemed` (`DeviceGrantStore.create_redeemed_grant`; the interactive
login *is* the consent step, so no device-code dance): the OIDC
cli-ticket fulfillment always, and accounts `POST /auth/login` when the
body carries `issue_refresh: true` (sent by the CLI, never the web
form — a browser must not receive refresh material). The raw refresh
token rides back once (`/auth/cli-poll` / the login response) as an
optional `refresh_token` key — old CLIs ignore it, new CLIs against old
servers see it absent. `client_id` is `"omnigent-cli"`.
- **Authority** — a refreshed login-grant token carries `grant_id` (so
revocation still kills it) but **no `scope` claim**, so the delegated
path allowlist does not apply: it renews the session JWT and keeps that
same authority. Scoping it like a third-party device grant instead made
every non-allowlisted route (`/v1/usage`, `/v1/scheduled-tasks`,
`/v1/policy-registry`) 401 after the first refresh. `LOGIN_GRANT_CLIENT_ID`
is the marker and is **reserved**`/oauth/device/authorize` refuses a
request naming it, so a device client cannot self-declare into this class.
- **No rotation** — login grants return the SAME refresh token on every
renewal. Rotation + reuse detection is right for a browser-adjacent
third-party client, but for an unattended host it turns any ambiguous
network failure (lost response, crash between the server committing and
the client persisting) into a permanently revoked grant. Only the
short-lived access token is renewed; revocation and the absolute lifetime
cap still bound exposure. Device grants keep rotating.
- **Renewal** — `omnigent.cli_auth.refresh_stored_token` POSTs
`grant_type=refresh_token`, persists the result, and returns the
fresh access token. The runner/host auth-token factory
calls it when the stored token lapses, and the host tunnel rebuilds
headers through that factory on every reconnect — so an unattended
host renews itself for the grant's lifetime. Refreshes on one machine
are serialized by an advisory file lock with a re-check after acquire,
so a losing racer picks up the winner's rotated pair instead of
replaying the stale token (which reuse detection would punish by
revoking the grant).
- **One grant per host replica (recommended)** — since login grants do
not rotate, N replicas sharing one token file no longer revoke each
other. A per-replica login is still preferred so a single host can be
revoked without cutting off the rest.
- **Lifetime** — the absolute grant lifetime stays 30 days by default;
`OMNIGENT_GRANT_MAX_LIFETIME_DAYS` lets an operator extend it
deliberately for long-lived unattended hosts. Invalid values fall back
to the default (never fail open to unbounded).
- **Client-secret interaction** — `OMNIGENT_DEVICE_CLIENT_SECRET` gates
the endpoints that mint from an ephemeral code (device authorize + the
`device_code` exchange). Refresh and revoke are NOT gated: the presented
refresh/access token is itself the credential, and a CLI renewing its own
login has no way to carry that secret (gating it made every automatic
refresh 401 in a Slack-serving deployment).
- **Housekeeping** — `/oauth/token` opportunistically purges expired and
aged-out grants. The device flow purges on `authorize`, which a
standalone token-router mount does not have, so login-grant rows would
otherwise accumulate one per login.
### Known limitation
General CLI commands (`omnigent usage`, session commands, direct
remote-URL chat) still read the stored token without attempting a refresh —
only the host/runner auth-token factory renews today. An expired login with
valid refresh material therefore leaves those commands unauthenticated
until something on the host path renews the shared file. Wiring the
remaining CLI entrypoints is follow-up work.
+14 -3
View File
@@ -72,11 +72,22 @@ they still work with no runner or LLM.
| Journey | Operation timed |
| --- | --- |
| `native_hook_spawn` | Spawn the per-chunk `MessageDisplay` hook exactly as Claude Code does — isolated interpreter, module entrypoint, JSON payload on stdin |
| `native_hook_spawn` | Spawn one **Python** command hook — isolated interpreter, module entrypoint, JSON payload on stdin — and time its whole lifetime |
Claude Code **blocks its TUI** on command hooks, so one hook subprocess's
lifetime is user-visible streaming latency, and the same interpreter+import
cost fronts every statusline refresh and per-tool-call policy hook. The
lifetime is user-visible latency. Read this number as *"what a hook costs if it
is Python"*.
It is **not** the per-chunk streaming cost, and treating it as one leads
straight to wasted work. The hooks that fire per chunk (`MessageDisplay`) and
per tool call (`evaluate-policy`) were deliberately moved off the interpreter —
a `/bin/sh` appender and a `curl` to the runner's relay — and
`test_message_display_shell_command_round_trips` pins that by asserting
`"python"` is absent from the installed command. What still pays this number is
the per-turn set (`SessionStart` / `Stop` / `UserPromptSubmit` / `PreCompact` /
`Task*`), the `PostToolUse` `TodoWrite`+`TaskUpdate` matchers, and the policy
hook's Python fallback before the relay is up. So the journey's real job is to
keep the argument for staying off the interpreter measurable. The
journey needs no server or runner; registering it here rides hook spawn cost
on the same nightly/release regression comparison as everything else
(`omnigent/__init__` re-exports lazily so this stays ~interpreter-sized). The
+22 -8
View File
@@ -881,12 +881,21 @@ async def _measure_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> N
# ── native hook spawn (no server involved) ───────────────────
# Claude Code blocks its TUI on command hooks, so one hook subprocess's whole
# lifetime is user-visible latency: the MessageDisplay hook runs once per
# streamed text chunk, and the same interpreter+import cost fronts every
# statusline refresh and per-tool-call policy hook. Spawn the per-chunk hook
# exactly as Claude Code does — isolated interpreter, module entrypoint, JSON
# payload on stdin — and time the full process lifetime. The import-graph side
# of this guarantee is pinned by tests/test_claude_native_message_display_hook.
# lifetime is user-visible latency. This journey times that lifetime for a
# Python hook — isolated interpreter, module entrypoint, JSON payload on stdin —
# which is the cost of ANY hook the bridge installs as a `python -m` command.
#
# It is NOT the per-chunk streaming path. The hooks that fire per chunk
# (MessageDisplay) and per tool call (evaluate-policy) were deliberately moved
# off the interpreter — a /bin/sh appender and a curl to the runner's relay
# respectively — and tests pin that (`test_message_display_shell_command_round_trips`
# asserts "python" is absent from the installed command). What still pays this
# is the per-turn set (SessionStart / Stop / UserPromptSubmit / PreCompact /
# Task*), the PostToolUse TodoWrite+TaskUpdate matchers, and the policy hook's
# Python fallback when the relay is not yet up. So read this number as
# "what a hook costs if it is Python", and as the standing argument for keeping
# the hot paths off it — not as a per-chunk cost. The import-graph side is
# pinned by tests/test_claude_native_message_display_hook.py.
_HOOK_SPAWN_PAYLOAD = json.dumps(
{
"hook_event_name": "MessageDisplay",
@@ -905,7 +914,7 @@ async def _setup_hook_spawn(env: BenchEnvironment) -> JourneyContext:
async def _measure_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
"""Spawn the MessageDisplay hook once, as Claude Code does, and wait."""
"""Spawn one Python hook subprocess and wait, as Claude Code would."""
del env
proc = await asyncio.create_subprocess_exec(
sys.executable,
@@ -1082,7 +1091,12 @@ ALL_JOURNEYS: dict[str, Journey] = {
measure=_measure_hook_spawn,
setup=_setup_hook_spawn,
teardown=_teardown_hook_spawn,
description="Spawn the per-chunk MessageDisplay hook exactly as Claude Code does.",
description=(
"Spawn one Python command hook (isolated interpreter, module "
"entrypoint) and time its whole lifetime — what any `python -m` "
"hook costs Claude's blocked TUI. Not the per-chunk path: that "
"one is a /bin/sh appender."
),
),
Journey(
name="cli_startup",
+9 -5
View File
@@ -800,10 +800,13 @@ async def _prepare_antigravity_terminal_via_daemon(
_update_progress(startup_progress, "Creating Antigravity session...")
bridge_id = _mint_agy_conversation_id()
conversation_id = bridge_id
session_id = await _create_antigravity_session(
client,
session_bundle,
bridge_id=bridge_id,
session_id, _ = await asyncio.gather(
_create_antigravity_session(
client,
session_bundle,
bridge_id=bridge_id,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_progress(startup_progress, "Loading Antigravity session...")
@@ -847,7 +850,8 @@ async def _prepare_antigravity_terminal_via_daemon(
conversation_id = external if isinstance(external, str) and external else bridge_id
resume = isinstance(external, str) and bool(external)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+19 -14
View File
@@ -1595,22 +1595,23 @@ async def _prepare_chat_session_via_daemon(
)
from omnigent.native_terminal import bind_session_runner
try:
async def resolve_session() -> tuple[str, bool]:
"""Fork, resume, or create the session to bind, and say if it is fresh.
:returns: The session id and whether it was created just now.
:raises click.ClickException: If the server rejects the create/fork.
"""
async with OmnigentClient(base_url=base_url, headers=headers, auth=auth) as sdk:
try:
if fork_session_id is not None:
fork_result = await sdk.sessions.fork(fork_session_id)
session_id = fork_result["id"]
fresh_session = False
elif resume_conversation_id is not None:
session_id = resume_conversation_id
fresh_session = False
else:
created = await sdk.sessions.create(
bundle, filename="agent.tar.gz", workspace=workspace
)
session_id = created.id
fresh_session = True
return fork_result["id"], False
if resume_conversation_id is not None:
return resume_conversation_id, False
created = await sdk.sessions.create(
bundle, filename="agent.tar.gz", workspace=workspace
)
return created.id, True
except ClientOmnigentError as exc:
# Any create/fork/resume rejection here is a server-side answer, not
# a client bug worth a traceback: a wrong base URL that answers
@@ -1621,6 +1622,7 @@ async def _prepare_chat_session_via_daemon(
f"Could not start a session on {base_url}: {exc}"
) from exc
try:
# A separate raw httpx client for the host-runner protocol (the daemon
# launch helpers operate on httpx, not the SDK), pinned to the host's replica.
timeout = httpx.Timeout(30.0, read=120.0)
@@ -1629,8 +1631,11 @@ async def _prepare_chat_session_via_daemon(
) as client:
if progress is not None:
progress.update(STARTUP_PHASE_CONNECTING)
await wait_for_host_online(
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
(session_id, fresh_session), _ = await asyncio.gather(
resolve_session(),
wait_for_host_online(
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
),
)
if progress is not None:
progress.update(STARTUP_PHASE_LAUNCHING_AGENT)
+33
View File
@@ -55,6 +55,21 @@ CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
#: takes — so it is not part of the vocabulary below.
CUSTOM_MODEL_OPTION_NAME_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
#: BACK-COMPAT. Omnigent's picker-row id for the custom slot. Named for the
#: model the slot first carried (Sonnet 5, which had no family alias of its
#: own), but a Smart Routing launch pins ITS model there, so the id does not
#: describe the contents — read the slot, never this name. Sessions persist
#: it as a model override, so retiring it needs a migration; slated for
#: removal in 0.10.0 along with the row, once the ``sonnet`` pin is Sonnet 5.
LEGACY_CUSTOM_SLOT_ROW_ID = "sonnet_5"
#: BACK-COMPAT. Spellings the pre-0.10 substring test read as "this is the
#: custom slot's model", kept because :func:`normalized_model_id` does not
#: fold a vendor-prefixed ``anthropic/claude-sonnet-5`` onto the catalog id
#: the slot holds. Consulted only after an exact match misses; retired with
#: :data:`LEGACY_CUSTOM_SLOT_ROW_ID` in 0.10.0.
LEGACY_CUSTOM_SLOT_SPELLINGS: tuple[str, ...] = ("sonnet-5", "sonnet_5")
#: Launch-env keys that define this session's model vocabulary.
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
*ALIAS_MODEL_ENV_VARS.values(),
@@ -162,6 +177,14 @@ def claude_model_alias(
candidate = model.strip().lower()
if candidate in CLAUDE_MODEL_ALIASES:
return candidate
# Bracket variants of the family aliases (``sonnet[1m]``) are settable
# aliases in their own right — the harness enumerates them in /model's
# usage line and resolves the marker itself (the family pin plus the
# marker on a pinned env). Stepping one down to its family would
# silently drop the marker; refusing it blocks a switch the pane accepts.
base, bracket, marker = candidate.partition("[")
if bracket and marker.endswith("]") and base in CLAUDE_MODEL_ALIASES:
return candidate
pins = alias_pins(env)
normalized = normalized_model_id(model)
for alias, pinned in pins.items():
@@ -202,4 +225,14 @@ def claude_model_command_arg(
custom = environ.get(CUSTOM_MODEL_OPTION_ENV_VAR, "").strip()
if custom and normalized_model_id(custom) == normalized_model_id(model):
return custom
candidate = model.strip()
if not alias_pins(env) and candidate.lower().startswith("claude-"):
# A full Anthropic model id names an EXACT generation, and ``/model``
# on an unpinned (canonical-endpoint) session accepts full ids
# verbatim — the same spelling the harness's own enumeration
# resolves. Stepping down to the family alias here would switch to
# claude's CURRENT generation of that family instead (picking
# "Opus 4.8 (1M context)" used to type ``/model opus`` and land on
# Opus 5).
return candidate
return claude_model_alias(model, env)
+570 -53
View File
@@ -81,8 +81,11 @@ from omnigent._wrapper_labels import (
)
from omnigent.claude_launcher import resolve_claude_launch
from omnigent.claude_model_vocabulary import (
ALIAS_MODEL_ENV_VARS,
CUSTOM_MODEL_OPTION_ENV_VAR,
CUSTOM_MODEL_OPTION_NAME_ENV_VAR,
LEGACY_CUSTOM_SLOT_ROW_ID,
claude_model_alias,
)
from omnigent.claude_native_bridge import (
BRIDGE_ID_LABEL_KEY,
@@ -112,7 +115,6 @@ from omnigent.host.daemon_launch import (
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.model_fallbacks import static_model_fallback
from omnigent.native_coding_agents import native_shell_terminal_spec
from omnigent.native_terminal import (
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
@@ -132,7 +134,6 @@ from omnigent.native_terminal import (
from omnigent.native_terminal import (
terminal_attach_url as _attach_url,
)
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
from omnigent.terminals.ws_bridge import (
WS_CLOSE_TERMINAL_DETACHED,
WS_CLOSE_TERMINAL_NOT_FOUND,
@@ -193,6 +194,18 @@ _CLAUDE_CODE_NESTED_SESSION_ENV = "CLAUDECODE"
_CLAUDE_CODE_API_KEY_HELPER_TTL_ENV = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
_CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS_ENV = "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"
_CLAUDE_CODE_USE_GATEWAY_ENV = "CLAUDE_CODE_USE_GATEWAY"
#: Kill-switch Claude Code treats as covering nonessential startup traffic;
#: the probe strips it so speed knobs never mask harness output.
_CLAUDE_NONESSENTIAL_TRAFFIC_ENV = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"
_CLAUDE_MODEL_PROBE_TIMEOUT_S = 20.0
#: Wall-clock cap for the per-alias resolution fan-out as a whole; aliases
#: still unresolved when it expires keep their bare rows (the cache's
#: revalidation retries them later). Startup dominates each run and
#: stretches with box load (measured 0.7s17s for the same command), so the
#: concurrency covers a whole alias set in one wave and the budget fits one
#: slow wave.
_CLAUDE_ALIAS_RESOLUTION_BUDGET_S = 30.0
_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY = 12
_CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
_CLAUDE_CODE_CUSTOM_HEADERS_ENV = "ANTHROPIC_CUSTOM_HEADERS"
# Claude Code forwards the ANTHROPIC_CUSTOM_HEADERS value verbatim as
@@ -233,15 +246,8 @@ _UCODE_CLAUDE_TIER_TO_ENV: dict[str, str] = {
# See https://code.claude.com/docs/en/model-config#custom-model-options
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = CUSTOM_MODEL_OPTION_ENV_VAR
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = CUSTOM_MODEL_OPTION_NAME_ENV_VAR
_UCODE_CLAUDE_CUSTOM_TIER = "sonnet_5"
_UCODE_CLAUDE_CUSTOM_TIER = LEGACY_CUSTOM_SLOT_ROW_ID
_UCODE_CLAUDE_CUSTOM_TIER_LABEL = "Sonnet 5"
_CLAUDE_NATIVE_STATIC_MODEL_OPTIONS: tuple[tuple[str, str], ...] = (
("fable", "Fable"),
("opus", "Opus"),
("sonnet", "Sonnet 4.6"),
(_UCODE_CLAUDE_CUSTOM_TIER, _UCODE_CLAUDE_CUSTOM_TIER_LABEL),
("haiku", "Haiku"),
)
_DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS = 900_000
_SESSION_LABELS = {
"omnigent.ui": "terminal",
@@ -409,6 +415,57 @@ def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> b
return host == "anthropic.com" or host.endswith(".anthropic.com")
def _claude_family(token: str) -> str | None:
"""
The family alias a model id or alias folds onto, bracket markers dropped.
:param token: A picker id or model id, e.g. ``"opus[1m]"``,
``"claude-opus-4-8"``.
:returns: The family alias, e.g. ``"opus"``, or ``None`` for none.
"""
from omnigent.claude_model_vocabulary import claude_model_alias
alias = claude_model_alias(token, {})
return alias.partition("[")[0] if alias else None
def claude_catalog_serves_model(
rows: list[dict[str, object]],
model: str,
claude_config: ClaudeNativeUcodeConfig | None,
) -> bool:
"""
Whether a launch of *model* is backed by this config's catalog.
An exact row — a picker id or its wire model — always serves. A canonical
Anthropic id no row spells exactly still launches when the endpoint takes
canonical spellings (``--model`` passes any string through, and a pane's
``/model`` persists exactly this id) and the catalog lists the id's
family: the same family fold ``/model`` applies to an unpinned canonical
id. A gateway that routes only its own ids, and a family the catalog
does not list, refuse — a genuinely stale pick still fails fast.
:param rows: Catalog rows, e.g.
``[{"id": "opus", "model": "claude-opus-5"}]``.
:param model: A picker id or model id, e.g. ``"claude-opus-4-8"``.
:param claude_config: The resolved launch config, or ``None`` (Claude's
own login).
:returns: ``True`` when the launch can run *model* against this catalog.
"""
from omnigent.model_catalog_store import catalog_contains
if catalog_contains(rows, model):
return True
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
return False
if not model.lower().startswith("claude-"):
return False
family = _claude_family(model)
return family is not None and any(
_claude_family(str(row.get("id") or row.get("model") or "")) == family for row in rows
)
def resolve_claude_native_model_selection(
model: str | None,
claude_config: ClaudeNativeUcodeConfig | None,
@@ -419,28 +476,21 @@ def resolve_claude_native_model_selection(
extra Sonnet 5 row uses Omnigent's ``sonnet_5`` id because it occupies
Claude Code's provider-configured custom model slot. Resolve that id to
the exact custom option, preserving provider suffixes such as ``[1m]``.
Direct Claude logins have no provider config, so they use the canonical
Anthropic model id.
Direct Claude logins have no provider config, so the pick degrades to
the ``sonnet`` family alias, which Claude resolves itself.
On a gateway/Bedrock endpoint, a family alias with no tier pin (launch env
or managed settings) resolves to the provider's default model — Claude
Code would canonicalize it to an Anthropic id the endpoint rejects.
Every other pick passes through verbatim: picker rows are pin-backed or
vouched for by the harness's own probe, so rewriting one switches the
pane to a model nobody chose (an unpinned family alias used to degrade
to the provider's default model this way — a Fable pick landed on
Opus). An out-of-band unpinned alias on a gateway endpoint now fails
visibly at inference instead of silently running the default.
:param model: Persisted picker id, built-in alias, or concrete model id.
:param claude_config: Resolved provider config for the terminal.
:returns: A model identifier suitable for ``--model`` or ``/model``.
"""
if model != _UCODE_CLAUDE_CUSTOM_TIER:
tier_env = _UCODE_CLAUDE_TIER_TO_ENV.get(model or "")
if (
tier_env is not None
and claude_config is not None
and not claude_config.env.get(tier_env)
and not _serves_canonical_anthropic_ids(claude_config)
):
managed = _managed_claude_model_config()
if managed is None or not managed.env.get(tier_env):
return claude_config.model or model
return model
if claude_config is not None:
custom_model = claude_config.env.get(_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV)
@@ -451,26 +501,9 @@ def resolve_claude_native_model_selection(
return provider_fallback
if claude_config.model:
return claude_config.model
fallback = static_model_fallback(SUBSCRIPTION_KIND, "claude")
if fallback is None:
raise ValueError("Claude subscription fallback has no routable Sonnet model")
exact_match = next(
(
model_id
for model_id in fallback.model_ids
if _claude_model_display_name("sonnet", model_id) == _UCODE_CLAUDE_CUSTOM_TIER_LABEL
),
None,
)
if exact_match is not None:
return exact_match
family_match = next(
(model_id for model_id in fallback.model_ids if "claude-sonnet-" in model_id.lower()),
None,
)
if family_match is None:
raise ValueError("Claude subscription fallback has no routable Sonnet model")
return family_match
# No provider config pins the custom slot: hand Claude Code its own
# ``sonnet`` alias and let it resolve the current Sonnet itself.
return "sonnet"
def claude_config_with_routed_arms_pinned(
@@ -705,15 +738,472 @@ def claude_native_model_options(
if not model_id:
return []
return [{"id": model_id, "model": model_id, "displayName": model_id, "isDefault": True}]
return [
{
"id": model_id,
"model": model_id,
"displayName": label,
"isDefault": False,
}
for model_id, label in _CLAUDE_NATIVE_STATIC_MODEL_OPTIONS
# No curated fallback: an unconfigured shape's rows come from the probe
# (the harness's own enumeration) or not at all — a hand-written list
# here is exactly how a frozen "Sonnet 4.6" once shipped.
return []
def _parse_claude_model_aliases(stdout: str) -> list[str]:
"""
Extract the alias list from ``claude -p "/model"``'s printed usage line.
The harness prints e.g. ``Usage: /model <name>. Available: sonnet, opus,
haiku, fable, best, sonnet[1m], opusplan, default, or a full model ID.``
— its own enumeration of every settable alias. Parsing keeps zero model
knowledge here: entries are taken verbatim, and only the trailing prose
fragment (anything with whitespace) is dropped.
:param stdout: The probe run's stdout.
:returns: Alias tokens in the harness's order; empty when no line parses.
"""
for line in stdout.splitlines():
_, marker, tail = line.partition("Available:")
if not marker:
continue
aliases: list[str] = []
for entry in tail.split(","):
token = entry.strip().rstrip(".")
if token and " " not in token:
aliases.append(token)
return aliases
return []
def _parse_claude_current_model(stdout: str) -> dict[str, str]:
"""
Extract the resolved model from a stream-json ``/model`` probe run.
Two harness-owned facts, taken verbatim: the ``init`` event's exact
model id, and the printed ``Current model:`` label with only the
trailing ``(effort: …)`` suffix stripped — so labels like
``Opus 4.8 (1M context)`` survive untouched.
:param stdout: The run's ``--output-format stream-json`` stdout.
:returns: Whichever of ``{"model": …, "label": …}`` parsed.
"""
resolved: dict[str, str] = {}
for line in stdout.splitlines():
try:
event = json.loads(line)
except ValueError:
continue
if not isinstance(event, dict):
continue
if event.get("type") == "system" and event.get("subtype") == "init":
model = event.get("model")
if isinstance(model, str) and model:
resolved["model"] = model
if event.get("type") == "result":
for text_line in str(event.get("result", "")).splitlines():
_, marker, tail = text_line.partition("Current model:")
if not marker:
continue
label = re.sub(r"\s*\(effort:[^)]*\)\s*$", "", tail).strip()
if label:
resolved["label"] = label
break
return resolved
def _claude_model_probe_invocation(
claude_config: ClaudeNativeUcodeConfig | None,
extra_args: Sequence[str] = (),
) -> tuple[str, list[str], dict[str, str]]:
"""
Assemble one headless ``/model`` probe invocation.
Shared by the alias-enumeration run and the per-alias resolution runs
so the two cannot drift: same launch resolution, session env, speed
env, and env-unset list.
:param claude_config: The resolved native launch config, or ``None``.
:param extra_args: Appended CLI args (e.g. ``--model <alias>``).
:returns: ``(command, launch_args, env)`` ready to exec.
"""
from omnigent.claude_launcher import resolve_claude_launch
args = [
"-p",
"/model",
# The probe asks one client-side question; the MCP fleet, session
# persistence, and background chatter are irrelevant startup weight.
"--strict-mcp-config",
"--mcp-config",
'{"mcpServers":{}}',
"--no-session-persistence",
*extra_args,
]
if claude_config is not None and claude_config.api_key_helper:
args.extend(("--settings", json.dumps({"apiKeyHelper": claude_config.api_key_helper})))
command, launch_args = resolve_claude_launch("claude", args)
env = dict(os.environ)
env.update(build_native_claude_terminal_env(claude_config))
env.update(
{
"DISABLE_TELEMETRY": "1",
"DISABLE_ERROR_REPORTING": "1",
"DISABLE_AUTOUPDATER": "1",
}
)
# Mirrors the native terminal's env-unset list, plus the nonessential-
# traffic kill-switch, so speed knobs never mask harness output.
env.pop("DATABRICKS_CONFIG_PROFILE", None)
env.pop(_CLAUDE_CODE_NESTED_SESSION_ENV, None)
env.pop(_CLAUDE_NONESSENTIAL_TRAFFIC_ENV, None)
if claude_config is not None and claude_config.api_key_helper:
env.pop(_ANTHROPIC_API_KEY_ENV, None)
return command, launch_args, env
async def _resolve_claude_model_alias(
claude_config: ClaudeNativeUcodeConfig | None,
alias: str,
) -> dict[str, str]:
"""
Ask the harness what one printed alias resolves to.
A ``--model <alias>`` run in stream-json mode carries the exact model
id in its init event and the human label in its ``Current model:``
line; both are the harness's own resolution, never computed here.
:param claude_config: The resolved native launch config, or ``None``.
:param alias: The alias exactly as the harness printed it.
:returns: Whichever of ``{"model": …, "label": …}`` resolved; empty on
any failure (the alias keeps its bare row).
"""
command, launch_args, env = _claude_model_probe_invocation(
claude_config,
("--model", alias, "--output-format", "stream-json", "--verbose"),
)
try:
process = await asyncio.create_subprocess_exec(
command,
*launch_args,
cwd=str(Path.home()),
env=env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError:
_logger.debug("Claude alias resolution could not launch for %r", alias, exc_info=True)
return {}
try:
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
stdout, _stderr = await process.communicate()
except (TimeoutError, asyncio.CancelledError) as exc:
if process.returncode is None:
process.kill()
with contextlib.suppress(Exception):
await process.wait()
if isinstance(exc, asyncio.CancelledError):
raise
_logger.debug("Claude alias resolution timed out for %r", alias)
return {}
if process.returncode != 0:
_logger.debug("Claude alias resolution exited %s for %r", process.returncode, alias)
return {}
return _parse_claude_current_model(stdout.decode(errors="replace"))
async def _resolve_claude_model_aliases(
claude_config: ClaudeNativeUcodeConfig | None,
aliases: Sequence[str],
) -> dict[str, dict[str, str]]:
"""
Resolve every printed alias concurrently, best-effort.
Bounded fan-out under one overall budget: whatever resolved in time is
kept and the rest stay bare, so a hung harness cannot stretch the
probe indefinitely.
:param claude_config: The resolved native launch config, or ``None``.
:param aliases: The harness-printed aliases.
:returns: Non-empty resolutions keyed by alias.
"""
if not aliases:
return {}
semaphore = asyncio.Semaphore(_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY)
async def _bounded(alias: str) -> dict[str, str]:
async with semaphore:
return await _resolve_claude_model_alias(claude_config, alias)
tasks = {alias: asyncio.create_task(_bounded(alias)) for alias in aliases}
try:
async with asyncio.timeout(_CLAUDE_ALIAS_RESOLUTION_BUDGET_S):
await asyncio.gather(*tasks.values())
except TimeoutError:
for task in tasks.values():
task.cancel()
await asyncio.gather(*tasks.values(), return_exceptions=True)
done = sum(1 for task in tasks.values() if not task.cancelled())
_logger.warning(
"Claude alias resolution budget expired; keeping %d/%d resolutions",
done,
len(tasks),
)
return {
alias: task.result()
for alias, task in tasks.items()
if not task.cancelled() and task.exception() is None and task.result()
}
def _claude_alias_row(alias: str, resolution: dict[str, str]) -> dict[str, object]:
"""
One picker row for a printed alias, shown as its resolution.
``id`` stays the alias — launches pass it through unchanged — while the
display shows only the resolved label. Labels are normalized so every
1M-context resolution (a ``[1m]``-suffixed model id) says so; the
harness omits the marker for some of them (e.g. ``sonnet[1m]`` prints
just "Sonnet 5").
:param alias: The harness-printed alias.
:param resolution: Its resolution, possibly empty.
:returns: The picker row.
"""
label = resolution.get("label")
model = resolution.get("model") or alias
if label and model.endswith("[1m]") and "1M context" not in label:
label = f"{label} (1M context)"
return {"id": alias, "model": model, "displayName": label or alias}
@dataclass(frozen=True)
class ClaudeModelProbe:
"""One harness enumeration: picker rows plus the bare-launch default.
:param alias_rows: The printed aliases as deduplicated picker rows.
:param default_model: The model the enumeration run itself launched on
(its init event's ``model``) — what a no-pick launch of this config
actually runs — or ``None`` when unreadable.
:param default_label: The harness's own label for *default_model*, or
``None``.
"""
alias_rows: list[dict[str, object]]
default_model: str | None = None
default_label: str | None = None
def _parse_claude_enumeration_aliases(stdout: str) -> list[str]:
"""Extract the alias list from a stream-json enumeration run.
The ``Available:`` line lives inside the ``result`` event's text on a
stream-json run; falls back to scanning the raw output so a plain-text
run still parses.
:param stdout: The enumeration run's decoded stdout.
:returns: Alias names, e.g. ``["sonnet", "opus", ...]``.
"""
for line in stdout.splitlines():
line = line.strip()
if not line.startswith("{"):
continue
try:
event = json.loads(line)
except ValueError:
continue
if isinstance(event, dict) and event.get("type") == "result":
result_text = event.get("result")
if isinstance(result_text, str):
aliases = _parse_claude_model_aliases(result_text)
if aliases:
return aliases
return _parse_claude_model_aliases(stdout)
async def probe_claude_model_options(
claude_config: ClaudeNativeUcodeConfig | None,
) -> ClaudeModelProbe | None:
"""
Ask Claude Code itself which models it would offer, and its default.
The harness is the source of truth: a short ``claude -p "/model"`` run
(stream-json, so its init event also names the model a bare launch of
this config actually runs — the truthful "Default") makes Claude Code
print its own alias list. Each printed alias is then resolved to its
concrete model by a per-alias harness run. All outputs are read
verbatim; no selection semantics are replicated here. Runs for every
config shape, including the bare subscription launch (``None`` config).
:param claude_config: The resolved native launch config
(:func:`resolve_native_claude_config`), or ``None``.
:returns: The probe result, or ``None`` when the probe failed (callers
fall back to the configured/static rows).
"""
command, launch_args, env = _claude_model_probe_invocation(
claude_config, ("--output-format", "stream-json", "--verbose")
)
try:
process = await asyncio.create_subprocess_exec(
command,
*launch_args,
cwd=str(Path.home()),
env=env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError:
_logger.warning("Claude model probe could not launch the claude CLI", exc_info=True)
return None
try:
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
stdout, stderr = await process.communicate()
except (TimeoutError, asyncio.CancelledError):
if process.returncode is None:
process.kill()
with contextlib.suppress(Exception):
await process.wait()
_logger.warning("Claude model probe timed out; keeping configured rows only")
return None
if process.returncode != 0:
_logger.warning(
"Claude model probe exited %s: %s",
process.returncode,
stderr.decode(errors="replace").strip()[-500:],
)
return None
text = stdout.decode(errors="replace")
aliases = _parse_claude_enumeration_aliases(text)
# The enumeration run's own init event names what a bare launch runs —
# the harness's truthful Default.
default_resolution = _parse_claude_current_model(text)
# The picker renders its own top-level Default choice (launch with no
# model), which is exactly what the harness's ``default`` alias does —
# listing it again would duplicate that row.
aliases = [alias for alias in aliases if alias != "default"]
resolutions = await _resolve_claude_model_aliases(claude_config, aliases)
alias_rows: list[dict[str, object]] = []
seen_models: set[object] = set()
for alias in aliases:
row = _claude_alias_row(alias, resolutions.get(alias, {}))
# Distinct aliases can resolve to a model an earlier row already
# covers (``best``, ``fable[1m]``, and ``opusplan`` all do today);
# repeating the model is picker noise.
if row["model"] in seen_models:
continue
seen_models.add(row["model"])
alias_rows.append(row)
return ClaudeModelProbe(
alias_rows=alias_rows,
default_model=default_resolution.get("model"),
default_label=default_resolution.get("label"),
)
def claude_catalog_fingerprint(claude_config: ClaudeNativeUcodeConfig | None) -> str:
"""The launch-config fingerprint keying claude's shared model catalog.
One formula for every consumer (host boot probe, runner launch, session
listing), so they read and write the same catalog file.
:param claude_config: The resolved launch config, or ``None``.
:returns: A stable fingerprint string.
"""
from omnigent.model_catalog_store import fingerprint_of
return fingerprint_of(
"claude-native",
sorted(claude_config.env.items()) if claude_config is not None else None,
claude_config.api_key_helper if claude_config is not None else None,
claude_config.model if claude_config is not None else None,
)
async def claude_model_catalog(
claude_config: ClaudeNativeUcodeConfig | None,
) -> list[dict[str, object]] | None:
"""
The harness-truth catalog: probe rows with one truthful ``isDefault``.
Rows come from the harness's own enumeration alone (no configured/static
merge). Servability filtering matches the listing composition: on a
non-canonical endpoint, aliases resolving to bare Anthropic ids are
dropped. The default marker is what a Default launch of this config
actually runs: the config's own launch pin when the provider resolves
one (those launches pass ``--model`` explicitly), else the enumeration
run's init-event model (a bare subscription launch). It is matched onto
its row, or appended as its own row when the catalog lacks it (a
``settings.json`` pin, say) and the endpoint can serve it.
:param claude_config: The resolved launch config, or ``None``.
:returns: Catalog rows, or ``None`` when the probe failed.
"""
probe = await probe_claude_model_options(claude_config)
if probe is None:
return None
rows = list(probe.alias_rows)
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
rows = [row for row in rows if not str(row.get("model", "")).startswith("claude-")]
configured_pin = claude_config.model if claude_config is not None else None
default_model = configured_pin or probe.default_model
marked = False
out: list[dict[str, object]] = []
for row in rows:
is_default = (
bool(default_model)
and not marked
and (row.get("model") == default_model or row.get("id") == default_model)
)
if is_default:
marked = True
out.append({**row, "isDefault": True})
else:
out.append({key: value for key, value in row.items() if key != "isDefault"})
if default_model and not marked:
# Append the observed default as its own honest row — but never
# claim a bare Anthropic id is launchable on an endpoint that
# rejects that spelling.
servable = (
claude_config is None
or _serves_canonical_anthropic_ids(claude_config)
or not default_model.startswith("claude-")
)
if servable:
# The probe's printed label describes the ENUMERATION run's
# model; it only names a config-pinned default when the two are
# the same model.
label = (
probe.default_label
if default_model == probe.default_model and probe.default_label
else default_model
)
out.append(
{
"id": default_model,
"model": default_model,
"displayName": label,
"isDefault": True,
}
)
return out
async def claude_launch_catalog(
claude_config: ClaudeNativeUcodeConfig | None,
) -> list[dict[str, object]] | None:
"""
The shared catalog for this launch config: read the store, probe on miss.
The store read is what keeps launches fast once the host's boot probe
(or a previous launch) has run; a cold miss pays one probe and persists
the answer for every later consumer.
:param claude_config: The resolved launch config, or ``None``.
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
"""
from omnigent import model_catalog_store
fingerprint = claude_catalog_fingerprint(claude_config)
return await model_catalog_store.ensure_catalog(
"claude-native", fingerprint, lambda: claude_model_catalog(claude_config)
)
def build_native_claude_terminal_env(
@@ -2234,9 +2724,27 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
family.base_url,
family.default_model,
)
# Pin the alias vocabulary to the entry's declared models, so ``/model``
# and the harness probe resolve aliases inside this gateway's routable
# set instead of falling back to canonical Anthropic ids the gateway
# rejects. The ``models:`` map's flat tier keys (``opus``/``sonnet``/…)
# pin their aliases directly; ``models.default`` pins its own family's
# alias when nothing else declared that family.
pin_env: dict[str, str] = {}
for alias, env_var in ALIAS_MODEL_ENV_VARS.items():
pinned = family.models.get(alias)
if isinstance(pinned, str) and pinned.strip():
pin_env[env_var] = pinned.strip()
if family.default_model:
default_alias = claude_model_alias(family.default_model, env={})
base_alias = (default_alias or "").partition("[")[0]
default_env_var = ALIAS_MODEL_ENV_VARS.get(base_alias)
if default_env_var:
pin_env.setdefault(default_env_var, family.default_model)
return ClaudeNativeUcodeConfig(
env={
_UCODE_CLAUDE_BASE_URL_ENV: family.base_url,
**pin_env,
# Disable beta flags gateways reject (400 "invalid beta flag");
# skip when CLAUDE_CODE_USE_GATEWAY=1 to keep tool search enabled.
**(
@@ -2247,6 +2755,15 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
},
api_key_helper=api_key_helper,
model=family.default_model,
# The declared models are exactly what this entry can route.
routable_models=tuple(
dict.fromkeys(
[
*pin_env.values(),
*([family.default_model] if family.default_model else []),
]
)
),
)
+253
View File
@@ -179,6 +179,27 @@ _PASTED_PLACEHOLDER_PREFIX = "[Pasted text"
# whether the draft is rendered in the input box. Short enough to fit
# on the prompt row of a default 80-column detached pane.
_DRAFT_NEEDLE_MAX_CHARS = 24
# Mode footer Claude Code renders below its input box, keyed by
# ``--permission-mode`` value. There is no non-interactive mode command, so
# a live switch cycles with shift+tab and reads this footer to know where it
# landed. The prompting mode is ``default`` on the CLI, "manual" on screen.
_PERMISSION_MODE_FOOTERS: dict[str, str] = {
"default": "manual mode on",
"acceptEdits": "accept edits on",
"plan": "plan mode on",
"auto": "auto mode on",
}
# Modes shift+tab can reach. ``dontAsk`` is never in the cycle and
# ``bypassPermissions`` only joins it when launched into, so both are
# rejected up front.
CYCLEABLE_PERMISSION_MODES = frozenset(_PERMISSION_MODE_FOOTERS)
# Cap on shift+tab presses. The cycle is 3-5 modes wide depending on which
# optional modes are enabled, so a full lap plus slack proves the target is
# unreachable rather than slow.
_MODE_CYCLE_MAX_PRESSES = 8
# Wait for the footer to repaint after a shift+tab before reading it.
_MODE_FOOTER_SETTLE_TIMEOUT_S = 2.0
_MODE_FOOTER_POLL_INTERVAL_S = 0.1
# Footer Claude Code's interactive ``/model`` picker renders while it is open.
# Omnigent never drives that picker — it switches with ``/model <id>`` — but a
# picker the person opened by hand covers the input box, so an injection would
@@ -1227,6 +1248,48 @@ def read_model_env(bridge_dir: Path) -> dict[str, str]:
}
def record_model_vocabulary(
bridge_dir: Path,
*,
launch_env: Mapping[str, str] | None,
launch_model: str | None,
) -> None:
"""
Persist the launch's model vocabulary after the bridge dir exists.
The runner prepares the bridge before it resolves the provider config,
so the vocabulary (alias pins + custom slot) and the launch model land
here in a second write once known the same keys
:func:`prepare_bridge_dir` records on the CLI path, so
:func:`read_model_env` / :func:`read_launch_model` serve both paths
identically.
:param bridge_dir: Bridge directory path.
:param launch_env: The resolved launch env (pins + custom slot), or
``None`` for a bare subscription launch.
:param launch_model: The model the launch pins via ``--model``, or
``None``.
:returns: None.
"""
config = _read_json_file(bridge_dir / _CONFIG_FILE)
if not isinstance(config, dict):
return
model_env = {
key: launch_env[key]
for key in MODEL_VOCABULARY_ENV_VARS
if launch_env is not None and launch_env.get(key)
}
changed = False
if model_env and config.get("model_env") != model_env:
config["model_env"] = model_env
changed = True
if launch_model and config.get("launch_model") != launch_model:
config["launch_model"] = launch_model
changed = True
if changed:
_write_json_file(bridge_dir / _CONFIG_FILE, config)
def read_bridge_id(bridge_dir: Path) -> str | None:
"""
Read the opaque bridge id from bridge config.
@@ -3370,6 +3433,171 @@ def _confirm_tui_dialog(
return False
def _permission_mode_from_pane(pane: str) -> str | None:
"""
Read Claude Code's current permission mode off a captured pane.
The footer (`` auto mode on``, `` plan mode on``, ...) always sits
below the input box's closing rule, so the scan starts there rather than
at a fixed offset from the bottom: the footer's height scales with
concurrent subagents, which a fixed window cannot bound (the same reason
:func:`_claude_prompt_rendered` anchors on :func:`_is_box_rule`). Anchoring
also excludes transcript text structurally a mode name Claude echoed
while *discussing* modes sits above the box and can't be misread as live.
:param pane: Captured pane text from :func:`_capture_pane`.
:returns: The ``--permission-mode`` value for the rendered footer,
e.g. ``"auto"``, or ``None`` when no footer is visible (the
pane is mid-repaint, or the mode is one with no footer).
"""
lines = [line for line in pane.splitlines() if line.strip()]
# Below the last box rule is the footer region. With no rule the input box
# isn't mounted; fall back to the tail so a footer still reads during boot.
last_rule = max((i for i, line in enumerate(lines) if _is_box_rule(line)), default=None)
region = lines[last_rule + 1 :] if last_rule is not None else lines[-_PROMPT_SCAN_TAIL_LINES:]
for line in reversed(region):
for mode, footer in _PERMISSION_MODE_FOOTERS.items():
if footer in line:
return mode
return None
def _read_settled_permission_mode(
socket_path: str,
tmux_target: str,
*,
previous: str | None = None,
) -> str | None:
"""
Poll the pane until its permission-mode footer settles.
A shift+tab repaints the footer asynchronously, so an immediate
capture can read nothing or, worse, still read the PREVIOUS mode
and make the cycler believe the keystroke did nothing. Passing
*previous* waits for the footer to actually change.
:param socket_path: Absolute path to the tmux socket.
:param tmux_target: tmux pane target string, e.g. ``"main"``.
:param previous: Mode read before the keystroke that prompted this
read, e.g. ``"plan"``. The pane keeps rendering it until the TUI
repaints, so a read that returns it is treated as not-yet-settled
and retried; ``None`` accepts the first mode seen (the initial
read, where there is nothing to change from).
:returns: The rendered mode, or ``None`` if none appeared or the
footer never moved off *previous* before
:data:`_MODE_FOOTER_SETTLE_TIMEOUT_S`.
"""
deadline = time.monotonic() + _MODE_FOOTER_SETTLE_TIMEOUT_S
while True:
mode = _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
if mode is not None and mode != previous:
return mode
if time.monotonic() >= deadline:
# Timed out: report the last mode seen so a pane that legitimately
# stayed put is distinguished from one with no footer at all.
return mode
time.sleep(_MODE_FOOTER_POLL_INTERVAL_S)
def set_permission_mode(
bridge_dir: Path,
*,
mode: str,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
) -> str:
"""
Switch the running Claude terminal to *mode* by cycling shift+tab.
Claude Code has no non-interactive way to set a live session's mode
(``--permission-mode`` is launch-only, ``/permissions`` is an interactive
dialog, settings load at startup), so this drives the TUI's shift+tab
cycle, reading the mode footer after each press. The cycle is walked
rather than computed: its width varies with which optional modes are
enabled, so a fixed press count could land on the wrong mode.
:param bridge_dir: Bridge directory path, e.g.
``/tmp/omnigent/claude-native/<digest>``.
:param mode: Target ``--permission-mode`` value, one of
:data:`CYCLEABLE_PERMISSION_MODES`, e.g. ``"auto"``.
:param timeout_s: Seconds to wait for ``tmux.json`` to be
advertised by the runner, e.g. ``30.0``.
:returns: The mode now rendered in the pane (== *mode*).
:raises ValueError: If *mode* is not cycle-reachable.
:raises RuntimeError: If the tmux target is not advertised in time,
a ``tmux`` invocation fails, the pane never renders a mode
footer, or the target is not reached within
:data:`_MODE_CYCLE_MAX_PRESSES` presses (the mode is not in
this session's cycle).
"""
if mode not in CYCLEABLE_PERMISSION_MODES:
raise ValueError(
f"permission mode {mode!r} cannot be switched on a running session; "
f"expected one of {sorted(CYCLEABLE_PERMISSION_MODES)}"
)
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path, tmux_target = info["socket_path"], info["tmux_target"]
# The footer only renders once the input box is mounted; without
# this gate a shift+tab sent mid-boot is dropped and the read below
# reports a mode the keystroke never reached.
_wait_for_claude_prompt_ready(socket_path, tmux_target, timeout_s=timeout_s)
current = _read_settled_permission_mode(socket_path, tmux_target)
if current is None:
pane = _capture_pane(socket_path, tmux_target)
raise RuntimeError(
"Claude Code did not render a permission-mode footer, so its current "
f"mode could not be read.{_format_terminal_failure_tail(pane)}"
)
seen = [current]
for _ in range(_MODE_CYCLE_MAX_PRESSES):
if current == mode:
return current
# No ``-l``: tmux must interpret ``BTab`` as the shift+tab key.
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "BTab")
settled = _read_settled_permission_mode(socket_path, tmux_target, previous=current)
if settled is not None:
current = settled
seen.append(current)
if current == mode:
return current
raise RuntimeError(
f"Could not switch Claude Code to {mode!r} mode: cycled shift+tab "
f"{_MODE_CYCLE_MAX_PRESSES} times and only reached {sorted(set(seen))}. "
"The mode is not available in this session's cycle."
)
def confirm_dialog_if_open(bridge_dir: Path, *, hint: str) -> bool:
"""
Accept the *hint* dialog iff it is on screen RIGHT NOW; never blind-Enter.
Loop-safe building block for watchers that outlive a single injection
(a mid-turn ``/model`` queues in Claude's composer and pops its confirm
dialog only when the turn settles minutes later). Unlike
:func:`_confirm_tui_dialog` there is no timeout fallback Enter, so
calling this every few seconds can never type into a surface that is
not the named dialog.
:param bridge_dir: Bridge directory path.
:param hint: Text the dialog renders, e.g.
:data:`SWITCH_MODEL_DIALOG_HINT`.
:returns: ``True`` when the dialog was on screen and confirmed.
"""
try:
info = _wait_for_tmux_info(bridge_dir, timeout_s=1.0)
except (RuntimeError, OSError):
return False
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
try:
pane = _capture_pane(socket_path, tmux_target)
if hint not in pane:
return False
_confirm_and_verify_dialog_closed(socket_path, tmux_target, hint=hint)
except (RuntimeError, OSError):
return False
return True
def _confirm_and_verify_dialog_closed(
socket_path: str,
tmux_target: str,
@@ -5086,6 +5314,31 @@ def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
return parsed
def read_permission_mode(bridge_dir: Path) -> str | None:
"""
Read the permission mode currently rendered in the Claude pane.
Non-blocking and best-effort: the forwarder calls this every poll so an
in-pane shift+tab switch reaches the web UI, which otherwise never sees it
(only UI-driven switches stamp the mode label). Returns ``None`` when the
terminal isn't up or the pane shows no mode footer, so a caller can treat
"unknown" as "no fresh observation" rather than a change.
:param bridge_dir: Bridge directory path, e.g.
``/tmp/omnigent/claude-native/<digest>``.
:returns: The ``--permission-mode`` value rendered in the pane, e.g.
``"auto"``, or ``None`` when it cannot be determined.
"""
payload = _read_json_file(bridge_dir / _TMUX_FILE)
if not isinstance(payload, dict):
return None
socket_path = payload.get("socket_path")
tmux_target = payload.get("tmux_target")
if not isinstance(socket_path, str) or not isinstance(tmux_target, str):
return None
return _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
def read_claude_status_model(bridge_dir: Path) -> str | None:
"""
Read the active model id from the statusLine snapshot ``context.json``.
+137 -77
View File
@@ -36,6 +36,7 @@ from omnigent.claude_native_bridge import (
read_hook_events_from_offset,
read_hook_events_since_with_position,
read_message_deltas_from_offset,
read_permission_mode,
read_transcript_items_from_offset,
read_transcript_items_since_with_position,
read_transcript_path,
@@ -82,6 +83,11 @@ _SUBAGENT_IDLE_QUIESCENCE_S = 5.0
# ``agent-<id>.jsonl`` transcript.
_SUBAGENT_META_GLOB = "agent-*.meta.json"
_DEFAULT_POLL_INTERVAL_S = 0.25
# Minimum spacing between permission-mode pane reads. Unlike the model mirror
# (which reads a JSON file), this spawns a ``tmux capture-pane`` subprocess, so
# it runs well below the poll interval; a mode switch is a human action and 2s
# of lag is imperceptible.
_PERMISSION_MODE_POLL_INTERVAL_S = 2.0
# Hard ceiling on one poll iteration of the forward loop. A silently stalled
# await anywhere in the pipeline used to stop mirroring, status and the busy
# signal forever; the deadline cancels the stall (the traceback names it) and
@@ -490,16 +496,15 @@ class _ForwardDedupeState:
:param usage: Last ``message.usage`` snapshot POSTed via
``external_session_usage``, or ``None`` if none yet.
:param context_window: Last context-window POSTed, or ``None``.
:param observed_model: Last tier alias seen in the transcript,
sticky across polls (the incremental window often carries no
fresh ``message.model``), e.g. ``"opus"``. ``None`` until first
seen.
:param posted_model: Last tier alias POSTed via
``external_model_change``. Seeded from the first observation
WITHOUT a POST so a passive spawn default never overwrites a
pending silent model handoff; only a later in-TUI switch is
mirrored. Left behind ``observed_model`` on a failed POST so the
next poll retries. ``None`` until the first observation.
:param observed_model: Last VERBATIM model seen (statusLine or
transcript), sticky across polls (the incremental window often
carries no fresh ``message.model``), e.g.
``"claude-opus-4-8[1m]"``. ``None`` until first seen.
:param posted_model: Last verbatim model POSTed via
``external_model_change``. Every observation posts — the first
one is the launch report that seeds the session's
``reported_model``. Left behind ``observed_model`` on a failed
POST so the next poll retries. ``None`` until the first post.
:param posted_cost: Last DISPLAY cost (USD) POSTed as
``cumulative_cost_usd`` — the statusLine total ``S`` verbatim.
``None`` until the first cost post. Used to dedupe so a steady
@@ -543,6 +548,13 @@ class _ForwardDedupeState:
# sub-agent spend so the gate can block mid-turn. Separate baseline
# because it can advance while ``posted_cost`` (S) is frozen.
posted_policy_cost: float | None = None
# Last permission mode POSTed as ``external_permission_mode_change`` —
# mirrors the launch mode and any in-pane shift+tab switch, neither of
# which the web UI can observe on its own.
posted_permission_mode: str | None = None
# Monotonic deadline before which the next pane read is skipped, so the
# subprocess spawn runs at _PERMISSION_MODE_POLL_INTERVAL_S, not every poll.
permission_mode_next_read: float = 0.0
# Turn-settle latch driving the scheduled-wake boundary. The Stop edge
# records the ended turn's id as PENDING; it activates (moves to
# ``settled_response_id``) only once a fully-consumed transcript batch
@@ -1012,6 +1024,14 @@ async def forward_claude_transcript_to_session(
bridge_dir=bridge_dir,
dedupe=dedupe,
)
# Same rationale for the permission mode: a shift+tab in
# the pane emits no event, so poll the footer.
await _forward_permission_mode_from_pane(
client=client,
session_id=current_session_id,
bridge_dir=bridge_dir,
dedupe=dedupe,
)
except asyncio.CancelledError:
raise
except TimeoutError:
@@ -3572,19 +3592,18 @@ async def _forward_available_items(
_http_status_for_log(exc),
exc_info=True,
)
# Mirror a TUI-side `/model` switch to the web picker. The transcript
# records the resolved concrete id (e.g. "claude-opus-4-8"); collapse
# it to the picker's tier alias. This transcript-derived observation
# only fires when a turn produces a fresh ``message.model``, so it lags
# an in-pane switch by one turn — the per-poll statusLine sync
# (:func:`_forward_model_from_status`) is the primary, low-latency
# source; this stays as a fallback for cold-resume before the first
# statusLine render. Both share ``dedupe`` so neither double-posts.
# Report the transcript's model verbatim. This transcript-derived
# observation only fires when a turn produces a fresh
# ``message.model``, so it lags an in-pane switch by one turn — the
# per-poll statusLine sync (:func:`_forward_model_from_status`) is the
# primary, low-latency source; this stays as a fallback for cold-resume
# before the first statusLine render. Both share ``dedupe`` so neither
# double-posts.
await _post_model_change_if_new(
client,
session_id=session_id,
dedupe=dedupe,
alias=_model_alias_for(result.latest_model),
model=result.latest_model,
)
# Mirror a TUI-side `/rename` to the web session list. Claude writes the
# operator's title as a `custom-title` metadata record, which renders no
@@ -4177,38 +4196,81 @@ def _gen_ai_usage_tokens(usage: Mapping[str, float | str] | None) -> dict[str, i
return tokens
def _model_alias_for(model: str | None) -> str | None:
async def _post_external_permission_mode_change(
client: httpx.AsyncClient,
*,
session_id: str,
mode: str,
) -> None:
"""
Collapse a concrete Claude model id to the picker's tier alias.
Post one ``external_permission_mode_change`` event to the Sessions API.
The web model picker speaks Claude Code's version-agnostic aliases
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
extra concrete-id slot ``"sonnet_5"`` (see
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
transcript records the resolved concrete id (e.g.
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
Mapping to the tier keeps the mirrored value in the picker's
vocabulary and makes a web→TUI round-trip a no-op. The older Sonnet
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias — it is the
default that row is bound to.
Lets the web mode picker reflect a shift+tab switch made inside the Claude
Code terminal, which Omnigent has no other way to observe.
:param model: Concrete model id from the transcript, e.g.
``"claude-opus-4-8"``; ``None`` when none observed yet.
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
``"haiku"`` when the id carries a known tier token, else ``None``
(the caller skips the post rather than surface an id the picker
can't render).
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id, e.g. ``"conv_abc123"``.
:param mode: Permission mode the pane now shows, e.g. ``"auto"``.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
if not model:
return None
lowered = model.lower()
if "sonnet-5" in lowered or "sonnet_5" in lowered:
return "sonnet_5"
for tier in ("fable", "opus", "sonnet", "haiku"):
if tier in lowered:
return tier
return None
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_permission_mode_change", "data": {"permission_mode": mode}},
)
resp.raise_for_status()
async def _forward_permission_mode_from_pane(
client: httpx.AsyncClient,
*,
session_id: str,
bridge_dir: Path,
dedupe: _ForwardDedupeState,
) -> None:
"""
Mirror the pane's permission-mode footer to the session label each poll.
A shift+tab pressed inside the TUI produces no event Omnigent can see, so
without this the web picker shows a stale mode until the next UI-driven
switch. Polling the footer is the only signal available: Claude Code emits
nothing on a mode change, and hook payloads only arrive on tool use.
The launch mode is posted too, not just later switches: a session started
in manual mode carries no ``--permission-mode`` arg and no mode label, so
with nothing posted the web picker has no mode to render and hides itself.
Best-effort and idempotent — the server ignores a mode equal to the stored
label, an unchanged mode or unreadable pane is a no-op, and a failed POST
is retried next poll.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param bridge_dir: Native Claude bridge directory.
:param dedupe: Shared per-session dedupe state; mutated in place.
"""
# Throttled: this spawns a tmux subprocess, unlike the file-backed model
# mirror that shares this poll loop.
now = time.monotonic()
if now < dedupe.permission_mode_next_read:
return
dedupe.permission_mode_next_read = now + _PERMISSION_MODE_POLL_INTERVAL_S
mode = await asyncio.to_thread(read_permission_mode, bridge_dir)
if mode is None or mode == dedupe.posted_permission_mode:
return
try:
await _post_external_permission_mode_change(
client,
session_id=session_id,
mode=mode,
)
except httpx.HTTPError:
_logger.debug(
"external_permission_mode_change post failed; session=%s mode=%s",
session_id,
mode,
exc_info=True,
)
return
dedupe.posted_permission_mode = mode
async def _post_external_model_change(
@@ -4220,13 +4282,15 @@ async def _post_external_model_change(
"""
Post one ``external_model_change`` event to the Sessions API.
Lets the web model picker reflect a model switch made inside the
Claude Code terminal (a ``/model`` command or the in-TUI picker).
Reports the model the pane is actually on — the launch's own model
included — so every surface renders the harness's truth.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id, e.g.
``"conv_abc123"``.
:param model: Tier alias the session is now on, e.g. ``"opus"``.
:param model: The harness's VERBATIM model, e.g.
``"claude-opus-4-8[1m]"`` — never collapsed to a picker alias
(a family word claims a generation the pane may not be on).
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
resp = await client.post(
@@ -4321,40 +4385,36 @@ async def _post_model_change_if_new(
*,
session_id: str,
dedupe: _ForwardDedupeState,
alias: str | None,
model: str | None,
) -> None:
"""
Mirror an observed model tier alias to ``model_override``, deduped.
Report the observed model to ``reported_model``, verbatim and deduped.
Shared by the transcript-driven path (:func:`_forward_available_items`)
and the statusLine-driven per-poll path
(:func:`_forward_model_from_status`). The FIRST observation is the
session's spawn default, not a switch, so it seeds the dedupe baseline
WITHOUT posting (posting it could clobber a pending silent model
handoff). Every later change posts ``external_model_change``. Both
callers pass the same ``dedupe`` so whichever observes a switch first
posts it and the other no-ops. Best-effort: a failed POST leaves
``posted_model`` behind ``observed_model`` so the next poll retries.
(:func:`_forward_model_from_status`). EVERY observation posts — the
first one is the launch report that seeds the session's
``reported_model``, so surfaces show the pane's truth within seconds
of spawn; the server dedupes by equality, so a steady model costs one
POST total. Both callers pass the same ``dedupe`` so whichever
observes a change first posts it and the other no-ops. Best-effort: a
failed POST leaves ``posted_model`` behind ``observed_model`` so the
next poll retries.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param dedupe: Shared per-session dedupe state; mutated in place.
:param alias: Tier alias just observed (``"opus"`` / ``"sonnet"`` /
), or ``None`` when this source carried no recognizable model on
this poll. ``observed_model`` is sticky across polls, so passing
``None`` does NOT clear it — it just means "no fresh observation,"
and a previously-observed-but-unposted model is still reconciled
(retried) here.
:param model: The harness's verbatim model just observed (e.g.
``"claude-opus-4-8[1m]"``), or ``None`` when this source carried
no model on this poll. ``observed_model`` is sticky across
polls, so passing ``None`` does NOT clear it — it just means "no
fresh observation," and a previously-observed-but-unposted model
is still reconciled (retried) here.
"""
if alias is not None:
dedupe.observed_model = alias
if model is not None:
dedupe.observed_model = model
if dedupe.observed_model is None or dedupe.observed_model == dedupe.posted_model:
return
if dedupe.posted_model is None:
# First observation = the spawn default; seed the baseline without
# posting so it can't clobber a pending silent model handoff.
dedupe.posted_model = dedupe.observed_model
return
try:
await _post_external_model_change(
client,
@@ -4380,7 +4440,7 @@ async def _forward_model_from_status(
dedupe: _ForwardDedupeState,
) -> None:
"""
Mirror the statusLine-reported active model to ``model_override`` each poll.
Report the statusLine's active model to ``reported_model`` each poll.
Claude Code rewrites the statusLine stdin on every TUI render — including
right after an in-pane ``/model`` switch, BEFORE the next turn runs. The
@@ -4392,8 +4452,9 @@ async def _forward_model_from_status(
turn later, which is what happened when the model was derived solely
from the next turn's transcript ``message.model``.
Best-effort and idempotent: shares ``dedupe`` with the transcript path,
so a no-op when the model is unchanged.
The value posts VERBATIM — the harness's own spelling, never collapsed
to a picker alias. Best-effort and idempotent: shares ``dedupe`` with
the transcript path, so a no-op when the model is unchanged.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
@@ -4404,12 +4465,11 @@ async def _forward_model_from_status(
if status_state is None:
return
model = status_state.get("model")
alias = _model_alias_for(model if isinstance(model, str) else None)
await _post_model_change_if_new(
client,
session_id=session_id,
dedupe=dedupe,
alias=alias,
model=model.strip() if isinstance(model, str) and model.strip() else None,
)
+139 -4
View File
@@ -1781,6 +1781,98 @@ def _set_log_to_stderr(
return value
def _finish_cli_profile(profiler: Any, output_path: Path) -> None: # type: ignore[explicit-any]
"""Stop a requested cProfile run and render a focused bottleneck summary."""
import pstats
profiler.disable()
profiler.dump_stats(output_path)
stats = pstats.Stats(profiler)
stats_state = vars(stats)
raw_stats = cast(
dict[tuple[str, int, str], tuple[int, int, float, float, object]],
stats_state["stats"],
)
total_time = float(stats_state["total_tt"])
total_calls = int(stats_state["total_calls"])
primitive_calls = int(stats_state["prim_calls"])
package_root = Path(__file__).resolve().parent
source_root = package_root.parent
# (filename, line, function, primitive calls, total calls, self, cumulative)
rows = [(*key, cc, nc, tt, ct) for key, (cc, nc, tt, ct, _) in raw_stats.items()]
def _location(filename: str, line: int, function: str) -> str:
path = Path(filename).resolve()
try:
display = str(path.relative_to(source_root))
except ValueError:
display = path.name
return f"{display}:{line}({function})"
def _duration(seconds: float) -> str:
if seconds < 0.01:
return f"{seconds * 1_000:.2f} ms"
if seconds < 1:
return f"{seconds * 1_000:.1f} ms"
return f"{seconds:.3f} s"
def _print_rows(
title: str,
selected: list[tuple[str, int, str, int, int, float, float]],
) -> None:
click.echo(f"\n{title}", err=True)
click.echo(f" {'self':>9} {'cumulative':>10} {'calls':>9} function", err=True)
for filename, line, function, primitive, calls, self_time, cumulative in selected[:10]:
call_count = str(calls) if calls == primitive else f"{calls}/{primitive}"
click.echo(
f" {_duration(self_time):>9} {_duration(cumulative):>10} "
f"{call_count:>9} {_location(filename, line, function)}",
err=True,
)
omnigent_rows = [
row for row in rows if Path(row[0]).resolve().is_relative_to(package_root) and row[6] > 0
]
omnigent_rows.sort(key=lambda row: row[6], reverse=True)
self_time_rows = [row for row in omnigent_rows if row[5] > 0]
self_time_rows.sort(key=lambda row: row[5], reverse=True)
click.echo(
f"\nCLI profile: {_duration(total_time)}, "
f"{total_calls:,} calls ({primitive_calls:,} primitive)",
err=True,
)
_print_rows("Top Omnigent call paths", omnigent_rows)
_print_rows("Top Omnigent functions by self time", self_time_rows)
click.echo(f"\nFull profile data: {output_path}", err=True)
def _start_cli_profile(
ctx: click.Context,
_param: click.Parameter,
value: bool,
) -> bool:
"""Start cProfile early and finish it after the selected command exits."""
if not value:
return value
import cProfile
timestamp = time.strftime("%Y%m%d-%H%M%S")
microseconds = time.time_ns() // 1_000 % 1_000_000
profile_dir = data_dir() / "profiles"
profile_dir.mkdir(parents=True, exist_ok=True)
output_path = profile_dir / (f"omnigent-cli-{timestamp}-{microseconds:06d}-{os.getpid()}.prof")
profiler = cProfile.Profile()
profiler.enable()
ctx.call_on_close(lambda: _finish_cli_profile(profiler, output_path))
# Click closes callbacks last-in-first-out, so measurement stops before
# rendering the summary above.
ctx.call_on_close(profiler.disable)
return value
def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, bool]:
"""Remove global logging flags before run-shorthand rewriting."""
debug_logging = False
@@ -1801,6 +1893,17 @@ def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, boo
@click.group(cls=_OmnigentCLI)
@click.option(
"--profiling",
is_flag=True,
is_eager=True,
expose_value=False,
callback=_start_cli_profile,
help=(
"Profile CLI execution, print a summary, and write a timestamped .prof file. "
"Place before COMMAND."
),
)
@click.option(
"--debug",
"debug_logging",
@@ -2031,7 +2134,17 @@ def main() -> None:
# intentionally tiny (currently only help/version); runner flags live on
# ``run``. Treat a leading non-top-level flag as bare-run shorthand so
# users can type the natural no-AGENT launcher form.
if argv and argv[0].startswith("-") and argv[0] not in {"--help", "-h", "--version"}:
if (
argv
and argv[0].startswith("-")
and argv[0]
not in {
"--help",
"-h",
"--version",
"--profiling",
}
):
argv = ["run", *argv]
# Shorthand: ``omnigent myagent.yaml [opts]`` → ``run myagent.yaml [opts]``.
@@ -2201,6 +2314,10 @@ def _is_removed_ad_hoc_invocation(argv: list[str]) -> bool:
# help listing subcommands, not the legacy argparse help.
if argv[0] in {"--help", "-h", "--version"}:
return False
# A root profiling flag may precede an eager help/version flag or stand
# alone. These are valid Click invocations, not removed ad-hoc chat.
if all(token in {"--profiling", "--help", "-h", "--version"} for token in argv):
return False
# Skip leading flags to find the first positional. If all
# tokens are flags (e.g. ``omnigent --system-prompt "..."``),
# treat it as removed ad-hoc chat rather than handing it to click
@@ -2863,6 +2980,7 @@ def _spawn_host_daemon_process(
proc = subprocess.Popen(
args,
env=env,
stdin=subprocess.DEVNULL,
stdout=log_fh,
stderr=log_fh,
**_proc.spawn_kwargs(),
@@ -10950,6 +11068,12 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
the workspace rejects it).
:raises click.ClickException: When the Databricks CLI binary is
missing or the login exits non-zero.
The login writes to a ``.databrickscfg`` profile named after the
workspace's first DNS label (e.g. ``acme`` for
``acme.cloud.databricks.com``), keeping distinct workspaces off the
shared ``DEFAULT`` profile. The OAuth grant itself stays host-keyed,
so :func:`_databricks_workspace_token` still resolves it by host.
"""
databricks_bin = shutil.which("databricks")
if databricks_bin is None:
@@ -10958,14 +11082,21 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
"Install it first: https://docs.databricks.com/dev-tools/cli/install.html"
)
login_host = _host_with_org(workspace_host, org_id)
click.echo(f"Opening browser to log in to {login_host} ...")
# Pin the grant to a profile named for the workspace's first DNS label so
# distinct workspaces don't clobber each other under the CLI's ``DEFAULT``.
from urllib.parse import urlsplit
split = urlsplit(workspace_host.rstrip("/"))
host = split.hostname or split.netloc or split.path
profile = host.split(".")[0]
click.echo(f"Opening browser to log in to {login_host} (profile {profile}) ...")
result = subprocess.run(
[databricks_bin, "auth", "login", "--host", login_host],
[databricks_bin, "auth", "login", "--host", login_host, "--profile", profile],
check=False,
)
if result.returncode != 0:
raise click.ClickException(
f"`databricks auth login --host {login_host}` failed "
f"`databricks auth login --host {login_host} --profile {profile}` failed "
f"(exit {result.returncode}). If the workspace is unreachable from "
"this machine (VPN / IP access lists), resolve that and retry."
)
@@ -11188,6 +11319,9 @@ def login(server_url: str) -> None:
token=token,
user_id=user_id,
expires_at=_time.time() + expires_in,
# Login-issued refresh grant (newer servers) — lets the
# host/CLI renew past session expiry unattended.
refresh_token=result.get("refresh_token"),
)
click.echo(f"Logged in as {user_id}")
_remember_default_server(server)
@@ -11267,6 +11401,7 @@ def _accounts_login(server: str) -> None:
token=token,
user_id=user_id,
expires_at=_time.time() + expires_in,
refresh_token=body.get("refresh_token"),
)
click.echo(f"Logged in as {user_id}.")
+263 -10
View File
@@ -21,11 +21,13 @@ from __future__ import annotations
import contextlib
import json
import logging
import math
import os
import stat
import tempfile
import time
import urllib.parse
from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -35,6 +37,14 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
_TOKEN_FILE_NAME = "auth_tokens.json"
# Treat a stored token with less than this much life left as needing
# renewal. Shared by the "is it still usable" read path and the refresh
# path's already-renewed check, so a caller that decides to refresh is
# never handed back the same near-expiry token it wanted to replace.
# Comfortably longer than a WebSocket handshake, far shorter than the
# server's 1-hour access-token TTL.
REFRESH_MIN_REMAINING_SECONDS = 90.0
def _token_file_path() -> Path:
"""Return the path to the auth token storage file.
@@ -134,6 +144,10 @@ def _store_entry(server_url: str, entry: dict[str, str | float]) -> None:
except (json.JSONDecodeError, OSError):
data = {}
# Corrupt token files (non-dict JSON) read as empty — never crash.
if not isinstance(data, dict):
data = {}
data[_normalize_server_url(server_url)] = entry
_write_tokens_file(path, data)
@@ -144,6 +158,7 @@ def store_token(
token: str,
user_id: str,
expires_at: float,
refresh_token: str | None = None,
) -> None:
"""Persist a session token for a server.
@@ -153,15 +168,19 @@ def store_token(
:param user_id: The authenticated user's email, e.g.
``"alice@example.com"``.
:param expires_at: Unix timestamp when the token expires.
:param refresh_token: Login-issued refresh grant token, when the
server handed one out. Lets :func:`refresh_stored_token` renew
the access token past expiry without a human re-running
``omnigent login``.
"""
_store_entry(
server_url,
{
"token": token,
"user_id": user_id,
"expires_at": expires_at,
},
)
entry: dict[str, str | float] = {
"token": token,
"user_id": user_id,
"expires_at": expires_at,
}
if refresh_token is not None:
entry["refresh_token"] = refresh_token
_store_entry(server_url, entry)
def store_databricks_auth(
@@ -217,11 +236,15 @@ def _load_entry(server_url: str) -> dict[str, str | float] | None:
except (json.JSONDecodeError, OSError):
return None
# A token file holding valid JSON of the wrong shape (``[]``, ``null``,
# a bare string) is corrupt, not fatal — read as "nothing stored".
if not isinstance(data, dict):
return None
entry = data.get(_normalize_server_url(server_url))
return entry if isinstance(entry, dict) else None
def load_token(server_url: str) -> str | None:
def load_token(server_url: str, *, min_remaining_seconds: float = 0.0) -> str | None:
"""Load a stored session token for a server.
Returns ``None`` if no token is stored, the token has expired,
@@ -231,6 +254,11 @@ def load_token(server_url: str) -> str | None:
:param server_url: The server URL, e.g.
``"http://localhost:6767"``.
:param min_remaining_seconds: Require at least this much remaining
lifetime. ``0`` (the default) accepts any not-yet-expired token
the historical behaviour. A caller that can renew passes
:data:`REFRESH_MIN_REMAINING_SECONDS` so a token about to lapse
mid-handshake is refreshed instead of used.
:returns: The session JWT string, or ``None``.
"""
entry = _load_entry(server_url)
@@ -239,13 +267,238 @@ def load_token(server_url: str) -> str | None:
expires_at = entry.get("expires_at", 0)
if isinstance(expires_at, (int, float)) and expires_at < time.time():
_logger.debug("Stored token for %s has expired", _normalize_server_url(server_url))
_warn_expired_once(server_url, expires_at, has_refresh="refresh_token" in entry)
return None
# Near-expiry but still valid: decline quietly (no expiry warning — it
# has not expired) so the caller can choose to renew.
if (
min_remaining_seconds > 0
and isinstance(expires_at, (int, float))
and expires_at - time.time() < min_remaining_seconds
):
return None
token = entry.get("token")
return token if isinstance(token, str) else None
# Servers already warned about an expired stored token, so a poll/retry
# loop doesn't repeat the warning every few seconds.
_warned_expired_servers: set[str] = set()
def _warn_expired_once(server_url: str, expires_at: float, *, has_refresh: bool) -> None:
"""Warn (once per process per server) that a stored token expired.
Expiry used to be a DEBUG line, which left the host dialing
unauthenticated into a misleading 403 with no breadcrumb an
operator's first actionable signal must name the cause and the
remedy.
"""
normalized = _normalize_server_url(server_url)
if normalized in _warned_expired_servers:
return
_warned_expired_servers.add(normalized)
expired_on = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(expires_at))
if has_refresh:
_logger.warning(
"Stored login session for %s expired on %s; refresh will be "
"attempted on the next command if possible",
normalized,
expired_on,
)
else:
_logger.warning(
"Stored login session for %s expired on %s and holds no refresh "
"material. Run `omnigent login %s` to re-authenticate.",
normalized,
expired_on,
normalized,
)
def stored_token_status(server_url: str) -> str:
"""Classify the stored auth state for a server.
Lets callers distinguish "never logged in" from "logged in but the
session lapsed" — the difference between proceeding unauthenticated
(header-mode servers accept that) and surfacing an actionable
re-login message.
:param server_url: The server URL.
:returns: ``"ok"`` (valid token stored), ``"expired"`` (entry exists
but the token lapsed), or ``"absent"`` (no token entry at all;
includes Databricks pointer records, which hold no token).
"""
entry = _load_entry(server_url)
if entry is None or not isinstance(entry.get("token"), str):
return "absent"
expires_at = entry.get("expires_at", 0)
if isinstance(expires_at, (int, float)) and expires_at < time.time():
return "expired"
return "ok"
@contextlib.contextmanager
def _token_file_lock() -> Iterator[None]:
"""Exclusive advisory lock over token-file read-modify-write cycles.
Serializes concurrent refreshes on one machine (host + CLI sharing
``auth_tokens.json``) so only one performs the network exchange and
the other picks up its result. Best-effort on platforms without
``fcntl`` (Windows): the refresh still works, only the local
serialization is lost.
:raises OSError: If the lock file cannot be created or opened the
caller degrades to "cannot refresh" (a state directory we cannot
write is one we could not persist the result to either).
"""
lock_path = _token_file_path().with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
try:
import fcntl
except ImportError:
yield
return
with open(lock_path, "w") as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
def refresh_stored_token(server_url: str, *, timeout: float = 10.0) -> str | None:
"""Renew the stored access token from its login-issued refresh grant.
POSTs ``grant_type=refresh_token`` to the server's ``/oauth/token``,
persists the result, and returns the fresh access token. Safe to call
opportunistically: returns ``None`` when there is nothing to refresh
(no entry / no refresh material) or when the server refuses (grant
revoked, past its absolute lifetime, or an older server without the
endpoint).
Runs under the token-file lock and re-checks state after acquiring
it, so of two concurrent callers only one performs the network
refresh and the other returns the already-renewed token.
:param server_url: The server URL, e.g. ``"http://localhost:6767"``.
:param timeout: HTTP timeout in seconds.
:returns: A valid access token, or ``None``.
"""
normalized = _normalize_server_url(server_url)
# Cheap pre-check BEFORE touching the lock file: with no refresh
# material there is nothing to do, and creating a lock file would
# raise on a read-only state directory — masking the caller's other
# credential sources (e.g. the Databricks SDK fallback).
pre = _load_entry(server_url)
if pre is None or not isinstance(pre.get("refresh_token"), str) or not pre["refresh_token"]:
return None
try:
with _token_file_lock():
return _refresh_locked(server_url, normalized, timeout)
except OSError as exc:
# Cannot lock/persist (read-only or full state dir) — a refresh we
# could not store is worse than none, so decline and let the caller
# fall through to its other credential sources.
_logger.debug("Token refresh for %s skipped, state dir unusable: %s", normalized, exc)
return None
def _refresh_locked(server_url: str, normalized: str, timeout: float) -> str | None:
"""Perform the refresh exchange; caller holds the token-file lock."""
entry = _load_entry(server_url)
if entry is None:
return None
# Another process may have refreshed while we waited on the lock. A
# freshly minted token is far from expiry, so this cleanly separates
# "someone already renewed" from "this is the same near-expiry token".
expires_at = entry.get("expires_at", 0)
token = entry.get("token")
if (
isinstance(token, str)
and isinstance(expires_at, (int, float))
and expires_at - time.time() > REFRESH_MIN_REMAINING_SECONDS
):
return token
refresh_token = entry.get("refresh_token")
if not isinstance(refresh_token, str) or not refresh_token:
return None
import httpx
try:
resp = httpx.post(
f"{normalized}/oauth/token",
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
timeout=timeout,
)
except httpx.HTTPError as exc:
_logger.warning("Token refresh against %s failed: %s", normalized, exc)
return None
if resp.status_code != 200:
_logger.warning(
"Token refresh against %s refused (HTTP %d) — run `omnigent login %s` "
"to re-authenticate.",
normalized,
resp.status_code,
normalized,
)
return None
try:
payload = resp.json()
except ValueError:
_logger.warning("Token refresh against %s returned a malformed response", normalized)
return None
if not isinstance(payload, dict):
_logger.warning("Token refresh against %s returned a malformed response", normalized)
return None
access_token = payload.get("access_token")
new_refresh = payload.get("refresh_token")
# Only overwrite the stored pair with genuinely usable material —
# a null/non-string field must never clobber a working credential.
if not isinstance(access_token, str) or not access_token:
_logger.warning("Token refresh against %s returned no access token", normalized)
return None
if not isinstance(new_refresh, str) or not new_refresh:
# A server that renews without returning refresh material keeps the
# one we already hold (login grants deliberately do not rotate).
new_refresh = refresh_token
expires_in = _coerce_expires_in(payload.get("expires_in"))
user_id = entry.get("user_id")
store_token(
server_url,
token=access_token,
user_id=user_id if isinstance(user_id, str) else "",
expires_at=time.time() + expires_in,
refresh_token=new_refresh,
)
# A fresh token means any earlier expiry warning is stale; allow
# a new one if this credential ever lapses again.
_warned_expired_servers.discard(normalized)
_logger.info("Refreshed login session for %s", normalized)
return access_token
def _coerce_expires_in(raw: object) -> float:
"""Return a sane access-token lifetime in seconds from *raw*.
Falls back to one hour for anything missing, non-numeric, or
non-finite ``float("NaN")``/``float("Infinity")`` parse happily but
would yield an expiry that never compares as expired, pinning a dead
token forever.
"""
default = 3600.0
try:
value = float(raw) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
if not math.isfinite(value) or value <= 0:
return default
return value
def load_databricks_workspace_host(server_url: str) -> str | None:
"""Load the workspace host from a Databricks Apps pointer record.
+10 -6
View File
@@ -853,11 +853,14 @@ async def _prepare_codex_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Codex session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Codex session...")
session_id = await _create_codex_session(
client,
session_bundle,
bridge_id=None,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_codex_session(
client,
session_bundle,
bridge_id=None,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Codex session...")
@@ -910,7 +913,8 @@ async def _prepare_codex_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+268 -20
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
from omnigent.onboarding.provider_config import ProviderEntry
from omnigent.spec.types import AgentSpec
from omnigent.codex_model_vocabulary import codex_spawn_model
from omnigent.codex_native_bridge import write_policy_hook_config
from omnigent.codex_native_process_registry import (
CodexNativeProcessOwnerLock,
@@ -784,13 +786,18 @@ async def _start_codex_model_discovery_process(
listen_url: str,
env: dict[str, str],
cwd: Path,
config_overrides: Sequence[str] = (),
) -> asyncio.subprocess.Process:
"""Start the isolated Codex process used only for model discovery."""
override_args: list[str] = []
for override in config_overrides:
override_args.extend(("-c", override))
return await asyncio.create_subprocess_exec(
codex_path,
"app-server",
"--listen",
listen_url,
*override_args,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
@@ -828,6 +835,189 @@ async def _wait_for_discovery_listener(
raise TimeoutError("Timed out waiting for Codex model discovery app-server")
def _probe_codex_home(config_overrides: Sequence[str]) -> Path:
"""
Persistent probe ``CODEX_HOME`` for one provider configuration.
Persistent (unlike the hermetic discovery's temp dir) so Codex's own
``models_cache.json`` ETag handling makes repeat probes cheap; keyed by
the override set so a provider change never replays another provider's
cache. The account's real ``auth.json`` is symlinked in, the same way
a session launch links it: the credential decides which models the
account's catalog lists (login-gated entries, the account default), so
a credential-less probe answers for a catalog no session will see.
:param config_overrides: The probe's ``-c`` overrides.
:returns: The created ``CODEX_HOME`` directory.
"""
key = hashlib.sha256("\n".join(config_overrides).encode("utf-8")).hexdigest()[:12]
home = Path.home() / ".omnigent" / "cache" / "codex-model-probe" / key
home.mkdir(mode=0o700, parents=True, exist_ok=True)
real_auth = _codex_home_config_source_from_env() / "auth.json"
probe_auth = home / "auth.json"
if real_auth.exists():
with contextlib.suppress(OSError):
if probe_auth.is_symlink() or probe_auth.exists():
probe_auth.unlink()
probe_auth.symlink_to(real_auth)
return home
def mark_launch_default(rows: list[_JsonObject], pinned_model: str | None) -> list[_JsonObject]:
"""
Reduce ``model/list`` rows to exactly one ``isDefault`` marker.
The launch-pinned model wins when a row names it (either spelling);
otherwise Codex's own first default stands. Rows are otherwise verbatim.
Codex's own ``isDefault`` is its built-in preference, which says nothing
about the model this session launched on, so a picker that trusted it
would name a model the pane is not running.
:param rows: Raw ``model/list`` rows.
:param pinned_model: The model the session runs, or ``None``.
:returns: The rows with a single default marked.
"""
from omnigent.codex_model_vocabulary import comparable_model_id
codex_default_index: int | None = None
pinned_index: int | None = None
pinned_key = comparable_model_id(pinned_model) if pinned_model else None
marked: list[_JsonObject] = []
for index, row in enumerate(rows):
cleaned = {key: value for key, value in row.items() if key != "isDefault"}
marked.append(cleaned)
if codex_default_index is None and row.get("isDefault") is True:
codex_default_index = index
if pinned_index is None and pinned_key is not None:
for spelling in (row.get("id"), row.get("model")):
if isinstance(spelling, str) and comparable_model_id(spelling) == pinned_key:
pinned_index = index
break
default_index = pinned_index if pinned_index is not None else codex_default_index
if default_index is not None:
marked[default_index]["isDefault"] = True
return marked
async def probe_codex_model_options(*, codex_path: str | None = None) -> list[_JsonObject]:
"""
Ask a session-configured Codex app-server for its own model list.
The harness is the source of truth for what a session's ``/model``
picker would offer, so the probe boots ``codex app-server`` with the
SAME materialization a session launch gets for every launch shape.
A Databricks profile contributes its provider overrides (gateway base
URL + minted auth + model pin) and ``DATABRICKS_HOST``; other provider
shapes carry their resolved ``-c`` overrides verbatim; the plain
Codex-login shape probes bare, which yields the ACCOUNT's visible
catalog: the probe home is isolated (never the user's real
``~/.codex``) but links the real ``auth.json`` in the way a session
launch does, so login-gated entries and the account default match what
a live session will offer.
:param codex_path: Optional Codex executable override.
:returns: The probe rows with a single default marked.
:raises ImportError: When the Codex CLI is unavailable.
:raises OSError: When a Databricks profile resolves no workspace host.
:raises RuntimeError: When the probe app-server exits before connecting.
:raises TimeoutError: When the probe app-server does not become ready.
"""
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
resolved_codex = codex_path or _find_codex_cli()
if not resolved_codex:
raise ImportError("Native Codex model probing requires the 'codex' CLI on PATH.")
config_overrides = list(launch.config_overrides)
pinned_model = launch.model
env = _clean_codex_env()
if launch.profile is not None:
databricks = await asyncio.to_thread(
_databricks_launch_materialization, model=launch.model, profile=launch.profile
)
config_overrides.extend(databricks.config_overrides)
env["DATABRICKS_HOST"] = databricks.host
pinned_model = databricks.model
codex_home = await asyncio.to_thread(_probe_codex_home, config_overrides)
env["CODEX_HOME"] = str(codex_home)
port = _allocate_loopback_port()
listen_url = f"ws://127.0.0.1:{port}"
process = await _start_codex_model_discovery_process(
codex_path=resolved_codex,
listen_url=listen_url,
env=env,
cwd=codex_home,
config_overrides=config_overrides,
)
client: CodexAppServerClient | None = None
try:
await _wait_for_discovery_listener(process, port)
client = CodexAppServerClient(
ws_url=listen_url,
client_name="omnigent-codex-model-probe",
)
await client.connect()
rows = await list_codex_model_options(client)
finally:
if client is not None:
with contextlib.suppress(Exception):
await client.close()
_proc.terminate_tree(process)
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except TimeoutError:
_proc.kill_tree(process)
await process.wait()
return mark_launch_default(rows, pinned_model)
def codex_catalog_fingerprint(launch: NativeCodexLaunch) -> str:
"""The launch-config fingerprint keying codex's shared model catalog.
One formula for every consumer (host boot probe, runner launch, live
write-back), so they read and write the same catalog file. Callers
fingerprint the SHAPE a ``model=None`` resolution so per-session
picks never fragment the catalog.
:param launch: The resolved launch (``resolve_native_codex_launch``).
:returns: A stable fingerprint string.
"""
from omnigent.model_catalog_store import fingerprint_of
return fingerprint_of(
"codex-native", launch.profile, launch.model, tuple(launch.config_overrides)
)
async def codex_launch_catalog(*, codex_path: str | None = None) -> list[_JsonObject] | None:
"""
The shared codex catalog for this host's default shape: store, then probe.
Reads the on-disk catalog for the ``model=None`` launch shape; a miss
pays one session-shaped probe (real auth linked in) and persists the
answer for every later consumer.
:param codex_path: Optional Codex executable override.
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
"""
from omnigent import model_catalog_store
try:
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
except Exception: # noqa: BLE001 — a broken provider config means no catalog
_logger.warning("codex catalog: launch shape resolution failed", exc_info=True)
return None
fingerprint = codex_catalog_fingerprint(launch)
async def _probe() -> list[_JsonObject] | None:
try:
return await probe_codex_model_options(codex_path=codex_path)
except Exception: # noqa: BLE001 — probe failure means "no catalog", never a crash
_logger.warning("codex catalog probe failed", exc_info=True)
return None
return await model_catalog_store.ensure_catalog("codex-native", fingerprint, _probe)
def _build_native_codex_app_server_argv(
*,
tagged_argv0: str,
@@ -1776,6 +1966,63 @@ def _trust_codex_project(codex_home: Path, cwd: Path) -> None:
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
@dataclass(frozen=True)
class _DatabricksLaunchMaterialization:
"""
The Databricks-profile pieces of a Codex launch, shared by the real
app-server build and the model-options probe so the two cannot drift.
:param config_overrides: ``-c`` overrides routing Codex through the
profile's AI Gateway (provider block + auth command + model pin).
:param model: The model the overrides pin, e.g. ``"databricks-gpt-5-4"``
the explicit *model* when given, else the catalog default.
:param host: The profile's workspace origin for ``DATABRICKS_HOST``.
"""
config_overrides: list[str]
model: str
host: str
def _databricks_launch_materialization(
*, model: str | None, profile: str
) -> _DatabricksLaunchMaterialization:
"""
Resolve the Databricks-profile routing pieces of a Codex launch.
Uses the profile's own host so the gateway base URL matches the token
the profile-pinned auth command mints; a ``DATABRICKS_HOST`` override in
the runner env must not point the base URL at another workspace.
:param model: Optional explicit model pin; ``None`` resolves the
catalog default.
:param profile: ``~/.databrickscfg`` profile name, e.g. ``"oss"``.
:returns: The materialized overrides, pinned model, and host.
:raises OSError: When the profile resolves no workspace host.
"""
host = _databricks_gateway_host(profile)
if not host:
raise OSError(
f"Native Codex with Databricks profile {profile!r} (from your "
"provider config) requires a matching ~/.databrickscfg section "
"with a host visible to the runner process."
)
host = host.rstrip("/")
# Resolve against what the workspace actually serves (live UC listing →
# ucode state → bundled catalog), never the bundled catalog alone — its
# legacy ``databricks-`` spellings can 501 on today's gateway.
resolved_model = _resolve_databricks_codex_model(host, profile, model)
return _DatabricksLaunchMaterialization(
config_overrides=_databricks_codex_config_overrides(
model=resolved_model,
base_url=_databricks_codex_base_url(host),
auth_command=_databricks_codex_auth_command(host, profile),
),
model=resolved_model,
host=host,
)
# DATABRICKS-PATCH(codex-live-model-discovery)
def _resolve_databricks_codex_model(host: str, profile: str, requested: str | None) -> str:
"""Resolve the codex launch model against what the workspace serves.
@@ -1910,26 +2157,18 @@ def build_codex_native_server(
)
env = _clean_codex_env()
config_overrides: list[str] = []
pinned_model = model
if profile is not None:
# Use the profile's own host so the gateway base URL matches the token
# the profile-pinned auth command mints; a DATABRICKS_HOST override in
# the runner env must not point the base URL at another workspace.
host = _databricks_gateway_host(profile)
if not host:
raise OSError(
f"Native Codex with Databricks profile {profile!r} (from your "
"provider config) requires a matching ~/.databrickscfg section "
"with a host visible to the runner process."
)
host = host.rstrip("/")
config_overrides.extend(
_databricks_codex_config_overrides(
model=_resolve_databricks_codex_model(host, profile, model),
base_url=_databricks_codex_base_url(host),
auth_command=_databricks_codex_auth_command(host, profile),
)
)
env["DATABRICKS_HOST"] = host
databricks = _databricks_launch_materialization(model=model, profile=profile)
config_overrides.extend(databricks.config_overrides)
env["DATABRICKS_HOST"] = databricks.host
# A launch that names no model still routes through the profile's
# resolved model via ``-c model=``, which outranks the config.toml
# copied from the user's shared home. Pin that model in codex's own
# spelling — the vocabulary config.toml and its readers use — so the
# forwarder mirror and cost gate report the model this session runs
# instead of whatever the shared file was last left on.
pinned_model = codex_spawn_model(databricks.model) or databricks.model
if extra_config_overrides:
config_overrides.extend(extra_config_overrides)
if bypass_sandbox:
@@ -1943,6 +2182,15 @@ def build_codex_native_server(
'sandbox_mode="danger-full-access"',
]
)
# Every launch is explicit: the resolved model rides argv (``-c model=``)
# AND the private config copy's ``model =`` line (pinned in ``start``),
# both written from this one value — so a stale line copied from the
# user's shared config can never govern a session, and the two artifacts
# cannot drift.
if pinned_model and not any(
override.split("=", 1)[0] == "model" for override in config_overrides
):
config_overrides.append(f"model={json.dumps(pinned_model)}")
return CodexNativeAppServer(
codex_path=resolved_codex,
socket_path=socket_path,
@@ -1955,7 +2203,7 @@ def build_codex_native_server(
ap_server_url=ap_server_url,
ap_auth_headers=ap_auth_headers,
python_executable=python_executable,
pinned_model=model,
pinned_model=pinned_model,
trust_project=trust_project,
)
+16 -2
View File
@@ -342,9 +342,23 @@ def read_codex_config_model(bridge_dir: Path) -> str | None:
:returns: The top-level ``model`` from ``config.toml`` (e.g.
``"gpt-5.4"``), or ``None`` when undeterminable.
"""
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
return read_codex_home_config_model(codex_home_for_bridge_dir(bridge_dir))
def read_codex_home_config_model(codex_home: Path) -> str | None:
"""
Read the active model straight from a session's ``CODEX_HOME``.
Same value and fail-safe behaviour as :func:`read_codex_config_model`,
for callers that hold the ``CODEX_HOME`` path (e.g. a live bridge
state) rather than the bridge directory.
:param codex_home: The session's private ``CODEX_HOME`` directory.
:returns: The top-level ``model`` from ``config.toml`` (e.g.
``"gpt-5.4"``), or ``None`` when undeterminable.
"""
try:
data = tomllib.loads(config_path.read_text())
data = tomllib.loads((codex_home / "config.toml").read_text())
except (OSError, tomllib.TOMLDecodeError):
return None
model = data.get("model")
+9 -5
View File
@@ -511,10 +511,13 @@ async def _prepare_cursor_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Cursor session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Cursor session...")
session_id = await _create_cursor_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_cursor_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
# Persist the model pin before the runner binds and launches the
# TUI (it reads model_override from the snapshot to build --model).
@@ -575,7 +578,8 @@ async def _prepare_cursor_terminal_via_daemon(
_update_startup_progress(startup_progress, "Updating Cursor session...")
await _patch_cursor_session(client, session_id, patch)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+3
View File
@@ -1455,6 +1455,9 @@ class SqlScheduledTask(OmnigentBase):
# mirror the matching conversations.* override columns.
model_override: Mapped[str | None] = mapped_column(String(128), nullable=True)
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Per-firing cost budget in USD. When set, the fire path attaches a
# cost_budget policy to each spawned session. NULL = no per-firing cap.
max_cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
workspace: Mapped[str | None] = mapped_column(String(2048), nullable=True)
# Git base ref a firing branches from when it creates a worktree at fire
# time (mirrors session-create's git.base_branch input). None when unset.
@@ -0,0 +1,36 @@
"""add max_cost_usd column to scheduled_tasks
Revision ID: za1b2c3d4e5f
Revises: za2b3c4d5e6f
Create Date: 2026-08-13 00:00:00.000000
Adds an optional ``max_cost_usd`` (FLOAT, nullable) column to
``scheduled_tasks``. When set, the fire path attaches a ``cost_budget``
policy to each spawned session capping cumulative spend at this limit.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "za1b2c3d4e5f"
down_revision: str | None = "za2b3c4d5e6f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add max_cost_usd to scheduled_tasks."""
op.add_column(
"scheduled_tasks",
sa.Column("max_cost_usd", sa.Float(), nullable=True),
)
def downgrade() -> None:
"""Remove max_cost_usd from scheduled_tasks."""
with op.batch_alter_table("scheduled_tasks") as batch_op:
batch_op.drop_column("max_cost_usd")
+10 -3
View File
@@ -103,9 +103,15 @@ class Conversation:
(alongside the runner-binding primitive of the Alpha
runner-state design). Both paths validate the value against
the supported set; invalid values fail with ``invalid_input``.
:param model_override: Per-session LLM model override,
e.g. ``"claude-opus-4-7"``. ``None`` means use the agent
default from the spec's ``llm.model``. Mutable via
:param reported_model: The model the harness last REPORTED the
session is actually on, verbatim in the harness's own
spelling, e.g. ``"claude-opus-4-8[1m]"``. Written only by
harness reports (``external_model_change``); never by user
picks. The only model value UI surfaces display. ``None``
means no report has arrived yet.
:param model_override: Per-session LLM model override the user's
REQUEST, e.g. ``"claude-opus-4-7"``. ``None`` means use the
agent default from the spec's ``llm.model``. Mutable via
``PATCH /v1/sessions/{id}`` and the REPL's ``/model``
command. Mirrors the persistence shape of
``reasoning_effort`` so the web UI and the TUI stay
@@ -221,6 +227,7 @@ class Conversation:
session_usage: dict[str, Any] = field(default_factory=dict)
reasoning_effort: str | None = None
model_override: str | None = None
reported_model: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
harness_override: str | None = None
+6
View File
@@ -45,6 +45,11 @@ class ScheduledTask:
``"claude-opus-4-7"``. ``None`` means use the agent default.
:param reasoning_effort: Per-task reasoning-effort hint, e.g. ``"high"``.
``None`` means use the agent default.
:param max_cost_usd: Optional per-firing cost budget in USD. When set, the
fire path attaches a ``cost_budget`` policy to each spawned session that
blocks all models once cumulative spend reaches this limit. ``None``
means no per-firing cost cap (the session runs unconstrained unless the
agent spec or server-wide defaults impose one).
:param workspace: Absolute existing path where a fired session's connected
host runner should start. ``None`` only for legacy or invalid rows.
:param base_branch: Reserved legacy column; scheduled tasks currently do
@@ -74,6 +79,7 @@ class ScheduledTask:
workspace_id: int = 0
model_override: str | None = None
reasoning_effort: str | None = None
max_cost_usd: float | None = None
workspace: str | None = None
base_branch: str | None = None
execution_target: str = "connected_host"
+9 -5
View File
@@ -326,10 +326,13 @@ async def _prepare_goose_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Goose session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Goose session...")
session_id = await _create_goose_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_goose_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Goose session...")
@@ -372,7 +375,8 @@ async def _prepare_goose_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+9 -5
View File
@@ -324,10 +324,13 @@ async def _prepare_hermes_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Hermes session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Hermes session...")
session_id = await _create_hermes_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_hermes_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Hermes session...")
@@ -370,7 +373,8 @@ async def _prepare_hermes_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+227 -127
View File
@@ -29,7 +29,7 @@ from websockets.exceptions import ConnectionClosed, InvalidStatus, InvalidURI
from omnigent._platform import IS_POSIX, WINDOWS_ENV_PASSTHROUGH
from omnigent.env_credentials import env_names_with_omnigent_prefix
from omnigent.gateway_inference import gateway_inference_map
from omnigent.harness_aliases import canonicalize_harness
from omnigent.harness_aliases import canonicalize_harness, is_claude_sdk_harness_name
from omnigent.harness_availability import HARNESS_BINARY_MISSING, HarnessAvailability
from omnigent.host import HOST_FATAL_EXIT_CODE
from omnigent.host.frames import (
@@ -327,15 +327,24 @@ def _connection_refused(exc: BaseException) -> bool:
_RECONNECT_BASE_S = 0.5
_RECONNECT_CAP_S = 10.0
_RECONNECT_CAP_S = 3.0
_RECONNECT_JITTER = 0.5
# Keep first startup tolerant of a cold server, but do not spend the library's
# full default timeout on each reconnect after an established tunnel drops.
_INITIAL_CONNECT_OPEN_TIMEOUT_S = 10.0
_RECONNECT_OPEN_TIMEOUT_S = 3.0
# Fresh hosts get a short auth-retry window for Databricks OAuth refreshes.
# Established hosts retry auth failures indefinitely to preserve sessions.
_MAX_CONSECUTIVE_AUTH_ERRORS = 3
# Consecutive connection-refused failures against a loopback server before the
# host exits (~5 minutes at the backoff cap). Refused on loopback means no
# process listens on the port — the local server is gone, not unreachable.
_LOOPBACK_REFUSED_FATAL_ATTEMPTS = 30
_LOOPBACK_REFUSED_FATAL_ATTEMPTS = 100
# Consecutive post-connect 401/403 rejections (~5 min at the backoff cap)
# before the retry loop escalates from "check your VPN" to a re-auth prompt.
# Operator-facing only — the host keeps retrying and never exits.
_AUTH_REJECT_ESCALATE_ATTEMPTS = 30
# Consecutive accepted-then-silent connections (upgrade completed, then the
# socket died without one inbound frame) before the reconnect loop treats the
@@ -774,6 +783,19 @@ def _paginate_list_dir(
)
@dataclass(frozen=True)
class ModelOptionsResult:
"""One resolved model listing: picker rows + the settable-but-unlisted ids.
:param models: Verbatim catalog rows (id/model/displayName/isDefault).
:param routable_models: Ids a launch can pin that the picker does not
list (older generations the endpoint still serves).
"""
models: list[dict[str, object]]
routable_models: list[str]
@dataclass
class _RunnerHandle:
"""A spawned runner subprocess and where its output lands.
@@ -1254,18 +1276,37 @@ class HostProcess:
self._auth_retry_streak < _MAX_CONSECUTIVE_AUTH_ERRORS
)
if should_retry:
_logger.warning("%s Retrying — check your VPN/network.", cause)
if should_retry and self._auth_retry_streak == 1:
# The warning above lands only in the CLI log file; print once
# per outage so a foreground `omnigent host` isn't silent.
print(
f"{cause} Retrying — this usually means the VPN or "
"network dropped. It will reconnect automatically once "
"connectivity returns.",
file=sys.stderr,
flush=True,
)
if should_retry:
# A sustained streak (vs. a brief VPN blip) means the credential
# is very likely permanently rejected: escalate the operator
# signal and name re-auth, but keep retrying so a real outage
# self-heals.
if (
self._auth_retry_streak >= _AUTH_REJECT_ESCALATE_ATTEMPTS
and self._auth_retry_streak % _AUTH_REJECT_ESCALATE_ATTEMPTS == 0
):
escalated = (
f"{cause} The server has rejected it "
f"{self._auth_retry_streak} times in a row — this is no "
"longer a transient network blip. If it persists, the "
"stored credential is likely no longer valid: run "
f"`omnigent login {self._server_url}` and restart the "
"host. Still retrying."
)
_logger.warning("%s", escalated)
print(f"{escalated}", file=sys.stderr, flush=True)
else:
_logger.warning("%s Retrying — check your VPN/network.", cause)
if self._auth_retry_streak == 1:
# The warning above lands only in the CLI log file;
# print once per outage so a foreground `omnigent host`
# isn't silent.
print(
f"{cause} Retrying — this usually means the VPN or "
"network dropped. It will reconnect automatically "
"once connectivity returns.",
file=sys.stderr,
flush=True,
)
return None
if status == 401:
return HostConnectError(
@@ -1276,6 +1317,20 @@ class HostProcess:
+ self._login_fix_hint()
)
if status == 403:
# An expired stored login is the common way to land here: the
# token loader yields nothing, the dial goes out
# unauthenticated, and the server's refusal looks like an
# authorization or version-skew problem. Name the real cause.
from omnigent.cli_auth import stored_token_status
if stored_token_status(self._server_url) == "expired":
return HostConnectError(
"Connection refused (HTTP 403): your stored login "
f"session for {self._server_url} has EXPIRED, so the "
"tunnel was dialed without credentials. Run `omnigent "
f"login {self._server_url}` to re-authenticate, then "
"restart the host."
)
return HostConnectError(
"Connection refused (HTTP 403): the server repeatedly rejected the host "
"tunnel. Either your "
@@ -2226,6 +2281,70 @@ class HostProcess:
payload=payload,
)
async def _prewarm_model_options(self) -> None:
"""
Fill the on-disk model catalogs for the probing harnesses at boot.
Runs both harness probes CONCURRENTLY, as detached background work
nothing (the tunnel, registration, readiness reporting, launches)
ever waits on this. A picker request racing the boot probe joins the
same single-flight probe through the shared store instead of
starting a second one.
:returns: None. Probe failures are absorbed by the probe wrappers.
"""
await asyncio.gather(
self._probed_codex_model_options(),
self._probed_claude_model_options(),
return_exceptions=True,
)
async def _probed_codex_model_options(self) -> ModelOptionsResult | None:
"""
Store-backed harness-truth Codex listing, or ``None`` on failure.
Every launch shape is answered from the shared on-disk catalog
(probed from the configured Codex binary on a miss). There is no
curated fallback: no catalog means an honest empty answer.
:returns: The catalog listing, or ``None`` when unavailable.
"""
from omnigent.codex_native_app_server import codex_launch_catalog
try:
rows = await codex_launch_catalog()
except Exception: # noqa: BLE001 — no catalog, never a crash
_logger.warning("Codex model catalog unavailable", exc_info=True)
return None
if rows is None:
return None
routable = [row["id"] for row in rows if isinstance(row.get("id"), str) and row["id"]]
return ModelOptionsResult(models=rows, routable_models=routable)
async def _probed_claude_model_options(self) -> ModelOptionsResult | None:
"""
Store-backed harness-truth Claude listing, or ``None`` on failure.
The shared catalog is keyed by the resolved launch config's
fingerprint the same file the runner reads at launch and serves in
the session gear, so the pre-launch picker and the session cannot
drift.
:returns: The catalog listing, or ``None`` when unavailable.
"""
from omnigent.claude_native import claude_launch_catalog, resolve_native_claude_config
try:
config = await asyncio.to_thread(resolve_native_claude_config, spec=None)
rows = await claude_launch_catalog(config)
except Exception: # noqa: BLE001 — no catalog, never a crash
_logger.warning("Claude model catalog unavailable", exc_info=True)
return None
if rows is None:
return None
routable = list(config.routable_models) if config is not None else []
return ModelOptionsResult(models=rows, routable_models=routable)
async def _handle_model_options(
self,
frame: HostModelOptionsFrame,
@@ -2240,107 +2359,23 @@ class HostProcess:
"""
harness = canonicalize_harness(frame.harness) or frame.harness
if harness == "codex-native":
try:
from omnigent.codex_native_app_server import (
discover_codex_model_options,
resolve_native_codex_launch,
)
from omnigent.model_catalog import (
is_direct_openai_provider,
list_models_for_worker,
resolve_catalog_model,
resolve_model_provider,
)
from omnigent.spec.types import AgentSpec, ExecutorSpec
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
spec = AgentSpec(
spec_version=1,
name="codex-native-prelaunch",
executor=ExecutorSpec(
type="omnigent",
config={
"harness": "codex-native",
**({"profile": launch.profile} if launch.profile else {}),
},
),
)
listing = await asyncio.to_thread(list_models_for_worker, spec, "codex-native")
default_model = launch.model
if default_model is None and launch.profile is not None:
default_model = (
await asyncio.to_thread(
resolve_catalog_model,
"databricks",
family="openai",
)
).model_id
default_id = (
default_model if default_model in {m.id for m in listing.models} else None
)
provider = (
resolve_model_provider(spec, "codex-native")
if listing.source == "openai-compatible"
else None
)
models: list[dict[str, object]]
if provider is not None and is_direct_openai_provider(provider):
available_ids = {model.id for model in listing.models}
models = []
seen: set[str] = set()
selected_default = False
try:
codex_options = await discover_codex_model_options()
except Exception:
_logger.exception("Failed to discover Codex-compatible pre-launch models")
codex_options = []
for option in codex_options:
raw_id = option.get("model") or option.get("id")
if (
not isinstance(raw_id, str)
or raw_id not in available_ids
or raw_id in seen
):
continue
seen.add(raw_id)
display_name = option.get("displayName")
is_default = raw_id == default_id or (
default_model is None
and not selected_default
and option.get("isDefault") is True
)
selected_default = selected_default or is_default
models.append(
{
"id": raw_id,
"displayName": (
display_name
if isinstance(display_name, str) and display_name
else raw_id
),
**({"isDefault": True} if is_default else {}),
}
)
else:
models = [
{
"id": model.id,
"displayName": model.id,
**({"isDefault": True} if model.id == default_id else {}),
}
for model in listing.models
]
except Exception:
_logger.exception("Failed to resolve pre-launch Codex model options")
# Harness-truth lane: every launch shape is answered from the
# shared catalog, probed from the configured Codex binary itself.
# No curated fallback and no serving-endpoints listing — a probe
# that cannot run yields an honest empty answer with the reason.
probed = await self._probed_codex_model_options()
if probed is not None:
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error="failed to resolve Codex model options",
status="ok",
models=probed.models,
routable_models=probed.routable_models,
)
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=models,
models=[],
error="the codex model probe failed — see the host log",
)
if harness == "pi-native":
@@ -2361,34 +2396,67 @@ class HostProcess:
models=pi_models,
)
if is_claude_sdk_harness_name(harness):
# SDK-mode Claude is a pass-through client with no model catalog
# of its own, so the endpoint listing IS the harness truth — the
# ids are already in the exact spelling the SDK sends.
try:
from omnigent.model_catalog import list_models_for_worker
from omnigent.spec.types import AgentSpec, ExecutorSpec
sdk_spec = AgentSpec(
spec_version=1,
name="claude-sdk-prelaunch",
executor=ExecutorSpec(
type="omnigent",
config={"harness": "claude-sdk"},
),
)
listing = await asyncio.to_thread(list_models_for_worker, sdk_spec, "claude-sdk")
except Exception:
_logger.exception("Failed to resolve pre-launch Claude SDK model options")
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error="failed to resolve Claude SDK model options",
)
if not listing.models:
# Subscription / CLI-login providers list nothing endpoint-side.
# The SDK drives the claude CLI, so the CLI's own probed rows
# (its aliases resolve inside the harness) are the truth here.
probed = await self._probed_claude_model_options()
if probed is not None:
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=probed.models,
routable_models=probed.routable_models,
)
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=[{"id": model.id, "displayName": model.id} for model in listing.models],
routable_models=[model.id for model in listing.models],
)
if harness != "claude-native":
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error=f"model options are unsupported for harness {frame.harness!r}",
)
try:
from omnigent.claude_native import (
claude_native_model_options,
resolve_native_claude_config,
)
config = await asyncio.to_thread(resolve_native_claude_config, spec=None)
models = await asyncio.to_thread(claude_native_model_options, config)
except Exception:
_logger.exception("Failed to resolve pre-launch Claude model options")
probed = await self._probed_claude_model_options()
if probed is not None:
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error="failed to resolve Claude model options",
status="ok",
models=probed.models,
routable_models=probed.routable_models,
)
return HostModelOptionsResultFrame(
request_id=frame.request_id,
status="ok",
models=models,
# The picker names the newest model of each family; the endpoint
# serves older generations too, and a launch takes an exact id.
routable_models=list(config.routable_models) if config is not None else [],
models=[],
error="the claude model probe failed — see the host log",
)
@staticmethod
@@ -2913,6 +2981,11 @@ class HostProcess:
additional_headers=headers,
max_size=100 * 1024 * 1024,
ssl=ssl_ctx,
open_timeout=(
_RECONNECT_OPEN_TIMEOUT_S
if self._ever_connected
else _INITIAL_CONNECT_OPEN_TIMEOUT_S
),
# Align the host->server tunnel's protocol keepalive to the same
# 90 s app-level budget as the runner tunnel (not the 20 s library
# default that drops a busy-but-healthy tunnel with 1011 — #1116).
@@ -3073,7 +3146,18 @@ class HostProcess:
flush=True,
)
# Readiness refresh runs in its own task, never on this receive loop:
# a harness probe that blocks (a hung CLI ``--version`` / ``auth
# status``) must not delay ``ws.recv()`` or the inline keepalive pong
# the server's watchdog counts as liveness, or it closes the tunnel
# with ``4003 ping timeout``.
readiness_task = asyncio.create_task(self._harness_readiness_loop(ws))
# Warm the pre-launch model listings once a server can actually ask
# for them, so the first picker open is served from cache instead of
# waiting on a harness probe. Cache-fresh reconnects are a no-op.
prewarm_task = asyncio.create_task(
self._prewarm_model_options(), name="host-model-options-prewarm"
)
try:
while True:
raw = await ws.recv()
@@ -3094,6 +3178,9 @@ class HostProcess:
# _runner_lifecycle_lock in _dispatch_host_frame.
self._start_frame_task(ws, raw)
finally:
prewarm_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await prewarm_task
readiness_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await readiness_task
@@ -3302,7 +3389,20 @@ class HostProcess:
fs_result = await asyncio.to_thread(self._handle_fs_request, frame)
await ws.send(encode_host_frame(fs_result))
elif isinstance(frame, HostModelOptionsFrame):
await ws.send(encode_host_frame(await self._handle_model_options(frame)))
# Every dispatched frame already runs on its own task (see
# _start_frame_task), so a cold harness probe here cannot stall
# the receive loop — answer inline, with a crash converted to an
# honest failed frame so the server's request future settles.
try:
options_result = await self._handle_model_options(frame)
except Exception:
_logger.exception("Model options resolution crashed for %r", frame.harness)
options_result = HostModelOptionsResultFrame(
request_id=frame.request_id,
status="failed",
error=f"model options resolution crashed for {frame.harness!r}",
)
await ws.send(encode_host_frame(options_result))
def run_host_process(
+6 -2
View File
@@ -2123,6 +2123,9 @@ class _CodexAppServerSession:
# on the next ``turn/completed`` so each TurnComplete carries the
# usage for the turn that just finished.
self._last_turn_usage: dict[str, object] | None = None
# Serialize concurrent writes to the subprocess stdin so that parallel
# tool-call responses don't interleave bytes on the pipe.
self._stdin_lock = asyncio.Lock()
async def start(self) -> None:
if self._started:
@@ -2947,8 +2950,9 @@ class _CodexAppServerSession:
async def _send_message(self, payload: CodexMessage) -> None:
assert self._proc is not None and self._proc.stdin is not None
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
await self._proc.stdin.drain()
async with self._stdin_lock:
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
await self._proc.stdin.drain()
@staticmethod
async def _iter_stream_chunks(stream: asyncio.StreamReader) -> AsyncIterator[bytes]:
+6 -2
View File
@@ -40,7 +40,11 @@ from omnigent.inner.native_attachments import (
parse_data_uri,
unresolved_attachment_marker,
)
from omnigent.reasoning_effort import CODEX_EFFORTS, effort_for_model_switch, validate_effort
from omnigent.reasoning_effort import (
CODEX_NATIVE_EFFORTS,
effort_for_model_switch,
validate_effort,
)
_logger = logging.getLogger(__name__)
@@ -379,7 +383,7 @@ def _model_effort_overrides(config: ExecutorConfig | None) -> dict[str, object]:
overrides["model"] = model
raw_effort = config.extra.get("reasoning_effort")
try:
effort = validate_effort(raw_effort, "codex", CODEX_EFFORTS)
effort = validate_effort(raw_effort, "codex", CODEX_NATIVE_EFFORTS)
except ValueError:
# A bad effort must not sink the turn — drop it and keep Codex's
# current effort rather than failing the whole dispatch.
+6 -2
View File
@@ -294,8 +294,9 @@ def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
Build tmux options for scrollback and pane input behavior.
``history-limit`` is generated per terminal because it comes from
``TerminalEnvSpec.scrollback``. ``mouse on`` makes the attached web
terminal scrollable. ``focus-events on`` lets interactive programs
``TerminalEnvSpec.scrollback``. ``set-clipboard external`` exports tmux
copy-mode selections without trusting pane OSC 52 requests. ``mouse on``
makes the attached web terminal scrollable. ``focus-events on`` lets interactive programs
observe pane focus changes. ``extended-keys`` with CSI-u formatting
lets programs inside tmux receive Kitty Keyboard Protocol keys such
as Shift+Enter when the attached terminal supports them. Terminals
@@ -311,6 +312,9 @@ def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
["set-option", "-g", "history-limit", str(scrollback)],
["set-option", "-sq", "extended-keys", "on"],
["set-option", "-sq", "extended-keys-format", "csi-u"],
# Export tmux copy-mode selections to attached terminals without letting
# pane applications create tmux buffers through OSC 52.
["set-option", "-sq", "set-clipboard", "external"],
["set-option", "-g", "mouse", "on"],
["set-option", "-g", "focus-events", "on"],
["set-option", "-g", "escape-time", "0"],
+9 -5
View File
@@ -333,10 +333,13 @@ async def _prepare_kimi_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Kimi session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Kimi session...")
session_id = await _create_kimi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_kimi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Kimi session...")
@@ -385,7 +388,8 @@ async def _prepare_kimi_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+9 -5
View File
@@ -364,10 +364,13 @@ async def _prepare_kiro_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Kiro session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Kiro session...")
session_id = await _create_kiro_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_kiro_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Kiro session...")
@@ -409,7 +412,8 @@ async def _prepare_kiro_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+15 -29
View File
@@ -46,7 +46,6 @@ from cachetools import TTLCache
from omnigent._platform import default_shell_argv
from omnigent.json_types import JsonObject as _JsonObject
from omnigent.llms.anthropic_model_metadata import parse_anthropic_model_metadata
from omnigent.model_fallbacks import StaticModelFallback, static_model_fallback
from omnigent.model_metadata import (
ModelCapability,
ModelCostTier,
@@ -206,15 +205,12 @@ class ModelListing:
:param models: The enumerated models, e.g.
``(ModelEntry(id="databricks-gpt-5-4", family="openai"),)``.
:param note: Human-readable provenance / failure explanation.
:param static_fallback: Ownership metadata for a release-curated fallback;
``None`` for live or empty listings.
"""
source: str
verified: bool
models: tuple[ModelEntry, ...]
note: str
static_fallback: StaticModelFallback | None = None
@dataclass(frozen=True)
@@ -897,8 +893,7 @@ def _listing_payload(listing: ModelListing) -> _JsonObject:
"""Serialize a :class:`ModelListing` into the tool's JSON row shape.
:param listing: The listing to serialize.
:returns: Row dict; ``context_window`` and ``static_fallback`` appear only
when known.
:returns: Row dict; ``context_window`` appears only when known.
"""
models: list[_JsonObject] = []
for entry in listing.models:
@@ -934,12 +929,6 @@ def _listing_payload(listing: ModelListing) -> _JsonObject:
"models": models,
"note": listing.note,
}
if listing.static_fallback is not None:
payload["static_fallback"] = {
"owner": listing.static_fallback.owner,
"provenance": listing.static_fallback.provenance,
"discovery_gap": listing.static_fallback.discovery_gap,
}
return payload
@@ -1061,23 +1050,24 @@ def _fetch_cursor_cli_listing(provider: ResolvedModelProvider) -> ModelListing:
def _static_subscription_listing(provider: ResolvedModelProvider) -> ModelListing:
"""Build the curated static listing for a subscription CLI login.
"""Build the (empty) pre-launch listing for a subscription CLI login.
Subscription logins expose no model-listing API, and the curated
stand-ins this used to serve are gone the live harness probes are the
source of truth, so a path that cannot probe reports nothing rather
than a plausible-but-stale list.
:param provider: A ``kind="subscription"`` provider descriptor.
:returns: A ``source="static"`` listing with ``verified=False``.
:returns: A ``source="static"`` listing with no models.
"""
fallback = static_model_fallback(SUBSCRIPTION_KIND, provider.cli or "")
ids = fallback.model_ids if fallback is not None else ()
return ModelListing(
source="static",
verified=False,
models=tuple(ModelEntry(id=i, family=model_family_token(i)) for i in ids),
models=(),
note=(
f"curated aliases for the {provider.cli or 'unknown'} CLI login "
"(subscription logins expose no model-listing API; availability "
"depends on the logged-in plan)"
f"the {provider.cli or 'unknown'} CLI login exposes no model-listing "
"API before launch; the live listing comes from probing the harness"
),
static_fallback=fallback,
)
@@ -1091,20 +1081,16 @@ def _static_cli_config_listing(provider: ResolvedModelProvider) -> ModelListing:
resolve not a "no credentials" preflight failure.
:param provider: A ``kind="cli-config"`` provider descriptor.
:returns: A ``source="static"`` listing with ``verified=False``.
:returns: A ``source="static"`` listing with no models.
"""
fallback = static_model_fallback(CLI_CONFIG_KIND, provider.cli or "")
ids = fallback.model_ids if fallback is not None else ()
return ModelListing(
source="static",
verified=False,
models=tuple(ModelEntry(id=i, family=model_family_token(i)) for i in ids),
models=(),
note=(
f"curated ids for {provider.detail}; its credential lives in the "
"CLI's own config file and is resolved by the CLI at launch, so "
"it cannot be verified from here"
f"{provider.detail} enumerates its models only from the CLI's own "
"config at launch; the live listing comes from probing the harness"
),
static_fallback=fallback,
)
+203
View File
@@ -0,0 +1,203 @@
"""The shared on-disk model-catalog store (model-flows-design.md §1.2).
One probe result, many consumers: whoever ran a harness's ``list_models``
(the host at boot, the runner at launch when the file is absent, a live
codex session writing back) persists the catalog here, keyed by harness and
a launch-config fingerprint, and every surface the pre-launch picker, the
in-session gear, launch resolution and validation reads the same bytes.
Because writer and readers share one file, host/runner drift and
probe-vs-session mismatch are impossible by construction.
The store holds only verbatim harness answers; nothing else ever writes it.
A fingerprint mismatch is a miss (never a "close enough" hit), so an answer
probed under one config can never serve another.
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
import tempfile
import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
def fingerprint_of(*parts: object) -> str:
"""
Stable fingerprint of a resolved harness configuration.
:param parts: Hashable configuration facets resolved overrides, env
pairs, binary identity. Stringified in order.
:returns: A short hex digest.
"""
digest = hashlib.sha256()
for part in parts:
digest.update(repr(part).encode("utf-8"))
digest.update(b"\x00")
return digest.hexdigest()[:16]
#: Catalog entries older than this get a background refresh on read (the
#: readers decide; the store only reports staleness).
CATALOG_STALE_AFTER_S = 3600.0
def _data_dir() -> Path:
"""Return the omnigent data dir (must stay in lock-step with
``omnigent.host.local_server._local_data_dir`` /
``omnigent.chat._omnigent_persistent_dir``).
:returns: ``$OMNIGENT_DATA_DIR`` when set, else ``~/.omnigent``.
"""
value = os.environ.get("OMNIGENT_DATA_DIR")
if value:
return Path(value).expanduser()
return Path.home() / ".omnigent"
def catalog_path(harness: str, fingerprint: str) -> Path:
"""Return the catalog file path for one (harness, fingerprint).
:param harness: Canonical harness name, e.g. ``"claude-native"``.
:param fingerprint: The launch-config fingerprint (:func:`fingerprint_of`).
:returns: ``<data-dir>/cache/model-catalogs/<harness>-<fingerprint>.json``.
"""
return _data_dir() / "cache" / "model-catalogs" / f"{harness}-{fingerprint}.json"
def read_catalog(harness: str, fingerprint: str) -> list[dict[str, Any]] | None:
"""Read the stored catalog rows for one (harness, fingerprint).
:param harness: Canonical harness name.
:param fingerprint: The launch-config fingerprint.
:returns: The verbatim rows, or ``None`` on a miss / damaged file.
"""
path = catalog_path(harness, fingerprint)
try:
payload = json.loads(path.read_text())
except (OSError, ValueError):
return None
rows = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return None
return [row for row in rows if isinstance(row, dict) and row.get("id")]
def catalog_age_s(harness: str, fingerprint: str) -> float | None:
"""Age of the stored catalog in seconds, or ``None`` on a miss."""
path = catalog_path(harness, fingerprint)
try:
return max(0.0, time.time() - path.stat().st_mtime)
except OSError:
return None
def write_catalog(harness: str, fingerprint: str, rows: list[dict[str, Any]]) -> None:
"""Persist catalog rows atomically (best-effort; failures only log).
:param harness: Canonical harness name.
:param fingerprint: The launch-config fingerprint.
:param rows: Verbatim harness rows to persist.
"""
path = catalog_path(harness, fingerprint)
payload = {
"harness": harness,
"fingerprint": fingerprint,
"written_at": time.time(),
"models": rows,
}
try:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(handle, "w") as tmp:
json.dump(payload, tmp, separators=(",", ":"))
os.replace(tmp_name, path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp_name)
raise
except OSError:
_logger.warning("could not persist the %s model catalog", harness, exc_info=True)
#: In-flight probes, keyed (harness, fingerprint) — the thin single-flight
#: wrapper the design keeps process-side: concurrent misses join one probe
#: instead of each spawning CLI processes.
_inflight: dict[tuple[str, str], asyncio.Task[list[dict[str, Any]] | None]] = {}
async def ensure_catalog(
harness: str,
fingerprint: str,
resolve: Callable[[], Awaitable[list[dict[str, Any]] | None]],
) -> list[dict[str, Any]] | None:
"""Store-first catalog access with a single probe in flight per key.
A hit serves immediately; a miss runs *resolve* once (concurrent
callers join it), persists a non-empty answer, and returns it.
:param harness: Canonical harness name.
:param fingerprint: The launch-config fingerprint.
:param resolve: Probe coroutine factory producing verbatim rows.
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
"""
cached = read_catalog(harness, fingerprint)
if cached is not None:
return cached
key = (harness, fingerprint)
task = _inflight.get(key)
if task is None or task.done():
async def _run() -> list[dict[str, Any]] | None:
try:
rows = await resolve()
finally:
_inflight.pop(key, None)
if rows:
write_catalog(harness, fingerprint, rows)
return rows
task = asyncio.create_task(_run(), name=f"model-catalog-{harness}")
_inflight[key] = task
return await asyncio.shield(task)
def default_row(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Return the catalog's single ``isDefault`` row, if any.
:param rows: Catalog rows.
:returns: The default row, or ``None``.
"""
return next((row for row in rows if row.get("isDefault") is True), None)
def catalog_contains(rows: list[dict[str, Any]], token: str) -> bool:
"""Whether *token* names a catalog row (by ``id`` or wire ``model``).
:param rows: Catalog rows.
:param token: A picker row id or wire model id.
:returns: ``True`` when some row's ``id`` or ``model`` equals *token*.
"""
return any(row.get("id") == token or row.get("model") == token for row in rows)
__all__ = [
"CATALOG_STALE_AFTER_S",
"catalog_age_s",
"catalog_contains",
"catalog_path",
"default_row",
"ensure_catalog",
"fingerprint_of",
"read_catalog",
"write_catalog",
]
+37 -43
View File
@@ -1,10 +1,16 @@
"""Owned static model fallbacks for CLI surfaces without discovery."""
"""Owned static model tables for Smart Routing.
Pre-launch picker listings carry no static stand-ins anymore the live
harness probes (see ``omnigent.host.connect``) are their source of truth.
What remains here is the router's operational data: rankings, arm menus,
and probed exclusions that no discovery API can provide.
"""
from __future__ import annotations
from dataclasses import dataclass
from omnigent.onboarding.provider_config import CLI_CONFIG_KIND, SUBSCRIPTION_KIND
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
@dataclass(frozen=True)
@@ -17,56 +23,44 @@ class StaticModelFallback:
discovery_gap: str
_CLAUDE_SUBSCRIPTION_MODELS = (
"claude-fable-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-haiku-4-5",
#: Curated preference ORDER for codex's current arms — a ranking hint only
#: (preferred first), consumed by the Databricks live-discovery ranker to
#: sort servable ids. It never invents picker rows: ids absent from the live
#: listing are simply not ranked by it.
_CODEX_ARM_PREFERENCE = StaticModelFallback(
model_ids=("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.5"),
owner="Databricks model discovery (omnigent.databricks_model_discovery)",
provenance="Omnigent's release-curated Codex arm ordering",
discovery_gap="a workspace listing ranks models by neither recency nor capability",
)
#: Codex's own model slugs, which spell the version with a DOT
#: (``gpt-5.6-sol``). These reach codex's ChatGPT-account backend directly, so
#: the Databricks serving spelling (``databricks-gpt-5-6-sol``, hyphens only)
#: is rejected here with a 400 — unlike the gateway catalogs below, which are
#: correctly hyphenated. Ordered cheapest-safe default first.
_CODEX_MODELS = ("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.5")
_STATIC_MODEL_FALLBACKS = {
(SUBSCRIPTION_KIND, "claude"): StaticModelFallback(
model_ids=_CLAUDE_SUBSCRIPTION_MODELS,
owner="Claude subscription adapter",
provenance="Omnigent's release-curated Claude Code alias catalog",
discovery_gap="Claude subscription logins expose no model-listing API",
),
(SUBSCRIPTION_KIND, "codex"): StaticModelFallback(
model_ids=_CODEX_MODELS,
owner="Codex subscription adapter",
provenance="Omnigent's release-curated Codex alias catalog",
discovery_gap="Codex subscription availability is not exposed before launch",
),
(CLI_CONFIG_KIND, "codex"): StaticModelFallback(
model_ids=_CODEX_MODELS,
owner="Codex CLI-config adapter",
provenance="Omnigent's release-curated Codex alias catalog",
discovery_gap=(
"Custom model_provider entries live in Codex config.toml and cannot "
"be enumerated on this catalog path"
),
),
_STATIC_MODEL_FALLBACKS: dict[tuple[str, str], StaticModelFallback] = {
(SUBSCRIPTION_KIND, "codex"): _CODEX_ARM_PREFERENCE,
}
def static_model_fallback(provider_kind: str, cli: str) -> StaticModelFallback | None:
"""Return the owned fallback for a provider kind and CLI, if registered."""
"""Return the owned fallback table for a provider kind and CLI, if registered."""
return _STATIC_MODEL_FALLBACKS.get((provider_kind, cli))
#: Codex's launch default when nothing else names a model. The bundled OpenAI
#: catalog's newest row is a bare family alias (``gpt-5.6``) that codex rejects,
#: so a codex launch defaults to a concrete variant from its own catalog.
CODEX_DEFAULT_MODEL = _STATIC_MODEL_FALLBACKS[(SUBSCRIPTION_KIND, "codex")].model_ids[0]
#: Codex's launch default when nothing else names a model. The bundled
#: OpenAI catalog's newest row is a bare family alias (``gpt-5.6``) that
#: codex rejects, so a codex launch defaults to a concrete variant from
#: codex's own catalog — dotted spelling, since the Databricks hyphenated
#: form 400s against codex's own backend.
_CODEX_LAUNCH_DEFAULT = StaticModelFallback(
model_ids=("gpt-5.6-sol",),
owner="Codex native launch (omnigent.inner.codex_executor)",
provenance="codex's own catalog slug for the cheapest current arm",
discovery_gap=(
"the launch default is resolved before any app-server probe can "
"answer, and codex rejects the bundled catalog's newest row (a bare "
"family alias)"
),
)
CODEX_DEFAULT_MODEL = _CODEX_LAUNCH_DEFAULT.model_ids[0]
# ── Smart Routing ───────────────────────────────────────────────────────────
+7 -3
View File
@@ -295,8 +295,11 @@ async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
"Creating an OpenCode session requires a session bundle."
)
_update_startup_progress(startup_progress, "Creating OpenCode session...")
session_id = await _create_opencode_session(
client, session_bundle, terminal_launch_args=persist_args or None
session_id, _ = await asyncio.gather(
_create_opencode_session(
client, session_bundle, terminal_launch_args=persist_args or None
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading OpenCode session...")
@@ -337,7 +340,8 @@ async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+9 -5
View File
@@ -377,10 +377,13 @@ async def _prepare_pi_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a Pi session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating Pi session...")
session_id = await _create_pi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_pi_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading Pi session...")
@@ -421,7 +424,8 @@ async def _prepare_pi_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+9 -5
View File
@@ -324,10 +324,13 @@ async def _prepare_qwen_terminal_via_daemon(
if session_bundle is None:
raise click.ClickException("Creating a qwen session requires a session bundle.")
_update_startup_progress(startup_progress, "Creating qwen session...")
session_id = await _create_qwen_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
session_id, _ = await asyncio.gather(
_create_qwen_session(
client,
session_bundle,
terminal_launch_args=persist_args or None,
),
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
)
else:
_update_startup_progress(startup_progress, "Loading qwen session...")
@@ -370,7 +373,8 @@ async def _prepare_qwen_terminal_via_daemon(
f"({resp.status_code}): {error_text(resp)}"
)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
if not fresh_session:
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
_update_startup_progress(startup_progress, "Starting runner...")
runner_id = await launch_or_reuse_daemon_runner(
client,
+19 -11
View File
@@ -8,23 +8,29 @@ from types import MappingProxyType
from omnigent.llms.errors import PermanentLLMError
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max"})
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"})
EFFORT_CLEAR_VALUES = frozenset({"default", "off", "reset"})
# Deprecated / vendor-written effort values mapped to the canonical value to
# use instead. The ChatGPT desktop app writes ``model_reasoning_effort =
# "ultra"`` into ``~/.codex/config.toml``, and the codex CLI forwards it as
# the retired ``max`` wire value — the OpenAI Responses API accepts neither
# (its ladder tops out at ``xhigh``). ``validate_effort`` coerces an alias
# only when the raw value is unsupported but the canonical value IS
# supported, so providers that genuinely support ``max`` (Anthropic) keep it
# unchanged.
# Fold a value to a canonical one, but only where the target ladder lacks it:
# ``validate_effort`` applies an alias only when the raw value is unsupported
# but the canonical one is. On the SDK/Responses codex ladder (``CODEX_EFFORTS``,
# capped at ``xhigh``) the ChatGPT app's ``ultra`` / retired ``max`` fold to
# ``xhigh``; ladders that carry them (codex-native ``CODEX_NATIVE_EFFORTS``,
# Anthropic's ``max``) keep them unchanged.
EFFORT_ALIASES: dict[str, str] = {"ultra": "xhigh", "max": "xhigh"}
OPENAI_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
ANTHROPIC_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
CLAUDE_EFFORTS = ANTHROPIC_EFFORTS
CODEX_EFFORTS = OPENAI_EFFORTS
# Codex-native drives the real codex process, which is the per-model authority
# on reasoning levels — it advertises them via ``model/list`` and validates the
# pairing itself. Sol reaches ``ultra``; the picker already gates which levels a
# model offers, so accept codex's full ladder here rather than re-clamping a
# valid pick down to ``xhigh``.
CODEX_NATIVE_EFFORTS = frozenset(
{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
)
OPENAI_AGENTS_EFFORTS = OPENAI_EFFORTS
GEMINI_EFFORTS = frozenset({"low", "medium", "high"})
ANTIGRAVITY_EFFORTS = GEMINI_EFFORTS
@@ -36,7 +42,7 @@ COPILOT_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
def format_supported(values: Iterable[str]) -> str:
"""Return a stable comma-separated supported-values string."""
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
values_set = set(values)
return ", ".join(value for value in order if value in values_set)
@@ -61,8 +67,10 @@ def unsupported_effort_message(effort: str, provider: str, supported: Iterable[s
# above stay frozen: they are the wire APIs' own vocabularies.
_MODEL_EFFORT_FALLBACK: Mapping[str, str] = MappingProxyType({"glm-5-2": "medium"})
# Efforts a fallback model cannot accept, so a pinned high value coerces down.
# GLM tops out at ``high``, so every rung above it (``xhigh``/``max``/``ultra``)
# is unsupported.
_MODEL_EFFORT_UNSUPPORTED: Mapping[str, frozenset[str]] = MappingProxyType(
{"glm-5-2": frozenset({"xhigh", "max"})}
{"glm-5-2": frozenset({"xhigh", "max", "ultra"})}
)
+3 -2
View File
@@ -4814,7 +4814,7 @@ async def _cmd_theme(
host.output(_build_preview(selected.name))
_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
_EFFORT_CLEAR_ALIASES = {"default", "off", "reset"}
@@ -4880,7 +4880,8 @@ async def _cmd_effort(
host.output(
Text.from_markup(
" [bold red]Invalid effort: "
f"{value} · expected none, minimal, low, medium, high, xhigh, max, or default[/]"
f"{value} · expected none, minimal, low, medium, high, "
"xhigh, max, ultra, or default[/]"
)
)
return
+14 -11
View File
@@ -221,17 +221,20 @@ def _dispatch_by_runtime(
"""
from omnigent.db.db_models import InvalidUuidError, uuid_to_bytes
# Resolve the id the argument contains, then canonicalize to bare hex
# before any lookup: a paste drags punctuation along (trailing period,
# wrapping quotes or backticks), and none of it can ever be part of a
# valid id — so strip it and resume rather than erroring. A malformed
# id would otherwise surface as a raw StatementError traceback from
# the local store's Uuid16 bind, and downstream consumers key
# sessions on the bare spelling.
try:
target = uuid_to_bytes(target.strip(_PASTE_PUNCTUATION)).hex()
except InvalidUuidError as exc:
raise click.ClickException("Invalid session id.") from exc
# Paste punctuation (trailing period, wrapping quotes/backticks) is never
# part of an id, so strip it. Only the local path binds the id to the sqlite
# store's Uuid16 column, so it must be a real uuid — reject a malformed one
# loudly rather than surfacing a raw StatementError. The remote server owns
# its id space (a managed deployment keys sessions on non-uuid ids) and
# validates the id itself, so forward it untouched, like the runner and SDK.
stripped = target.strip(_PASTE_PUNCTUATION)
if server is None:
try:
target = uuid_to_bytes(stripped).hex()
except InvalidUuidError as exc:
raise click.ClickException("Invalid session id.") from exc
else:
target = stripped
if server is not None:
wrapper = _read_wrapper_label_remote(server=server, conv_id=target)
+25 -2
View File
@@ -613,11 +613,34 @@ def _make_auth_token_factory(
"""
# Check stored OIDC token first.
if resolved_server_url:
from omnigent.cli_auth import load_token
from omnigent.cli_auth import (
REFRESH_MIN_REMAINING_SECONDS,
load_token,
refresh_stored_token,
)
oidc_token = load_token(resolved_server_url)
# Require enough remaining life that the token cannot lapse
# mid-handshake; a token inside that window falls through to
# the renewal path below rather than being used and rejected.
oidc_token = load_token(
resolved_server_url,
min_remaining_seconds=REFRESH_MIN_REMAINING_SECONDS,
)
if oidc_token:
return oidc_token
# Expired or near-lapse: renew from the login-issued refresh
# grant when one exists. This is what keeps an unattended host
# alive past session-JWT expiry — the tunnel rebuilds headers
# through this factory on every reconnect.
refreshed = refresh_stored_token(resolved_server_url)
if refreshed:
return refreshed
# Nothing to renew with: a near-expiry token that has NOT
# actually lapsed still authenticates, so prefer it over
# falling through to no credential at all.
still_valid = load_token(resolved_server_url)
if still_valid:
return still_valid
return _sdk_token()
# Probe once to check if a user credential is available.
+365 -28
View File
@@ -170,6 +170,25 @@ from omnigent.tools.builtins.load_skill import (
_logger = logging.getLogger(__name__)
# Claude-native session model listing: how long one request waits inline for
# the probe before answering 503-pending, and how long the probe may stay
# pending before the configured rows are served instead. Module-level so
# tests can patch the pacing.
_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S = 2.5
# Claude-native model switch confirmation: how long to watch the pane's
# statusLine snapshot for the switched model after typing ``/model``, and how
# often to re-read it. Module-level so tests can patch the pacing.
_CLAUDE_MODEL_CONFIRM_TIMEOUT_S = 10.0
_CLAUDE_MODEL_CONFIRM_POLL_S = 0.25
# How long the detached watcher keeps answering a /model confirm dialog that
# pops after the active turn settles (a mid-turn switch queues in the
# composer), and how often it looks. Long turns are common; the watch is
# cheap (one tmux capture per poll) and never types blind.
_CLAUDE_MODEL_LATE_DIALOG_BUDGET_S = 1200.0
_CLAUDE_MODEL_LATE_DIALOG_POLL_S = 2.0
def _warn_unresolved_sub_agent(session_id: str | None, sub_agent_name: str) -> None:
"""
@@ -2070,6 +2089,10 @@ def create_runner_app(
_active_turns: dict[str, asyncio.Task[None] | None] = {}
app.state.active_turns = _active_turns
_native_pane_status: dict[str, str] = {}
app.state.native_pane_status = _native_pane_status
# Detached watchers answering a /model confirm dialog that pops after
# the active turn settles (a mid-turn switch queues in the composer).
_model_dialog_watchers: set[asyncio.Task[None]] = set()
_session_message_buffers: dict[str, list[dict[str, Any]]] = {}
app.state.session_message_buffers = _session_message_buffers
_author_attribution_sessions: set[str] = set()
@@ -2092,6 +2115,7 @@ def create_runner_app(
app.state.desync_terminalized = _desync_terminalized
_background_tasks: set[asyncio.Task[Any]] = set()
_subagent_wake_pending: set[str] = set()
_last_rewake_notice: dict[str, str] = {}
_session_histories = _session_histories_ref
_last_server_item_id: dict[str, str] = {}
@@ -2273,6 +2297,8 @@ def create_runner_app(
app.state.session_resource_registry = resource_registry
def _publish_terminal_activity(session_id: str, terminal_id: str) -> None:
if process_manager is not None:
process_manager.note_activity(session_id)
_publish_event(
session_id,
{
@@ -3501,6 +3527,7 @@ def create_runner_app(
_session_event_queues.pop(session_id, None)
_session_inboxes.pop(session_id, None)
_subagent_wake_pending.discard(session_id)
_last_rewake_notice.pop(session_id, None)
_session_sub_agent_names.pop(session_id, None)
unregister_child_session(session_id)
unregister_subagent_work_for_session(session_id)
@@ -4075,7 +4102,16 @@ def create_runner_app(
return Response(status_code=204)
state = await _codex_native_bridge_state_for_session(conv_id, action="settings update")
if state is None:
return Response(status_code=204)
# No loaded Codex bridge means nothing applied the settings; a
# silent 204 here would let the caller claim a switch the
# app-server never saw.
return JSONResponse(
status_code=503,
content={
"error": "codex_native_settings_update_failed",
"detail": "Codex-native settings update requires a loaded Codex bridge.",
},
)
codex_client = client_for_transport(
state.socket_path,
@@ -4126,7 +4162,13 @@ def create_runner_app(
if resp.status_code == 200:
snapshot = resp.json()
if isinstance(snapshot, dict):
raw_model = snapshot.get("model_override") or snapshot.get("llm_model")
# ``llm_model`` is the harness's own report — the model
# the pane is actually on. ``model_override`` is only a
# request and may predate a relaunch or an unconfirmed
# switch, so it is the fallback, not the lead: a
# plan-mode toggle must re-assert the pane's real
# model, never resurrect a stale ask.
raw_model = snapshot.get("llm_model") or snapshot.get("model_override")
if isinstance(raw_model, str) and raw_model.strip():
model = raw_model.strip()
raw_effort = snapshot.get("reasoning_effort")
@@ -4188,7 +4230,9 @@ def create_runner_app(
from omnigent.codex_native_app_server import (
client_for_transport,
list_codex_model_options,
mark_launch_default,
)
from omnigent.codex_native_bridge import read_codex_home_config_model
state = await _codex_native_bridge_state_for_session(
conv_id,
@@ -4204,10 +4248,48 @@ def create_runner_app(
)
try:
await codex_client.connect()
return await list_codex_model_options(codex_client)
rows = await list_codex_model_options(codex_client)
finally:
with contextlib.suppress(Exception):
await codex_client.close()
active_model = await asyncio.to_thread(
read_codex_home_config_model,
Path(state.codex_home),
)
marked = mark_launch_default(rows, active_model)
# Write the live account rows back to the shared catalog store so the
# pre-launch picker converges to account truth after the first
# session — keeping the SHAPE's stored default (a session's own pin
# must not become the host-wide default).
asyncio.get_running_loop().create_task(
_write_back_codex_catalog([dict(row) for row in rows])
)
return marked
async def _write_back_codex_catalog(rows: list[_JsonObject]) -> None:
try:
from omnigent import model_catalog_store
from omnigent.codex_native_app_server import (
codex_catalog_fingerprint,
mark_launch_default,
resolve_native_codex_launch,
)
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
fingerprint = codex_catalog_fingerprint(launch)
stored = model_catalog_store.read_catalog("codex-native", fingerprint)
stored_default = next(
(row.get("id") for row in stored or [] if row.get("isDefault") is True),
None,
)
shaped = mark_launch_default(
rows, stored_default if isinstance(stored_default, str) else None
)
await asyncio.to_thread(
model_catalog_store.write_catalog, "codex-native", fingerprint, shaped
)
except Exception: # noqa: BLE001 — write-back is best-effort
_logger.debug("codex model-catalog write-back skipped", exc_info=True)
async def _handle_pi_native_model_change(
conv_id: str,
@@ -4267,6 +4349,50 @@ def create_runner_app(
publish_event=_publish_event,
)
async def _handle_claude_native_permission_mode_change(
conv_id: str,
mode: str | None,
) -> Response:
"""
Switch a live claude-native session's permission mode.
Claude Code can only set the mode at launch (``--permission-mode``)
or from its own shift+tab cycle, so the bridge drives that cycle
and verifies the pane landed on *mode*. A 200 carries the mode now
rendered, which the Omnigent server persists as the session's
current mode.
"""
from omnigent.claude_native_bridge import (
bridge_dir_for_bridge_id,
set_permission_mode,
)
if mode is None or not mode.strip():
return Response(status_code=204)
bridge_id = await _claude_native_bridge_id_for_session(
server_client=server_client,
session_id=conv_id,
)
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
try:
settled = await asyncio.to_thread(
set_permission_mode,
bridge_dir,
mode=mode.strip(),
timeout_s=1.0,
)
except (RuntimeError, ValueError) as exc:
return JSONResponse(
status_code=503,
content={
"error": "claude_native_permission_mode_failed",
"detail": _client_safe_error_detail(
exc, context="claude-native permission mode change"
),
},
)
return JSONResponse(status_code=200, content={"permission_mode": settled})
async def _handle_claude_native_effort_change(
conv_id: str,
effort: str | None,
@@ -4310,6 +4436,48 @@ def create_runner_app(
)
return Response(status_code=204)
async def _watch_late_model_dialog(
conv_id: str,
bridge_dir: Path,
expected: set[str],
) -> None:
"""Answer a ``/model`` confirm dialog that pops after the active turn.
A mid-turn switch queues in Claude's composer; the confirm dialog
renders only when the turn settles potentially minutes after the
injection's own watch and the request's confirm window. This watcher
presses Enter ONLY when the model dialog is verifiably on screen
(never blind), stops as soon as the statusLine reports one of the
expected spellings, and gives up quietly after its budget the
persisted request and the forwarder's verbatim report remain the
authoritative record either way.
"""
from omnigent.claude_native_bridge import (
SWITCH_MODEL_DIALOG_HINT,
confirm_dialog_if_open,
read_claude_status_model,
)
deadline = time.monotonic() + _CLAUDE_MODEL_LATE_DIALOG_BUDGET_S
while time.monotonic() < deadline:
try:
current = await asyncio.to_thread(read_claude_status_model, bridge_dir)
if current and current in expected:
return
await asyncio.to_thread(
confirm_dialog_if_open, bridge_dir, hint=SWITCH_MODEL_DIALOG_HINT
)
except Exception: # noqa: BLE001 — best-effort; the report reconciles
_logger.debug(
"late model-dialog watch errored for session=%s", conv_id, exc_info=True
)
return
await asyncio.sleep(_CLAUDE_MODEL_LATE_DIALOG_POLL_S)
_logger.info(
"late model-dialog watch for session=%s ended without a confirmed switch",
conv_id,
)
async def _handle_claude_native_model_change(
conv_id: str,
model: str | None,
@@ -4321,7 +4489,9 @@ def create_runner_app(
from omnigent.claude_native_bridge import (
SWITCH_MODEL_DIALOG_HINT,
bridge_dir_for_bridge_id,
confirm_dialog_if_open,
inject_slash_command,
read_claude_status_model,
read_model_env,
)
@@ -4362,6 +4532,7 @@ def create_runner_app(
},
)
command = f"/model {model_arg}"
baseline = await asyncio.to_thread(read_claude_status_model, bridge_dir)
try:
# Accepted trade-off: ``/model <id>`` also saves the pick as the
# person's global default in ``~/.claude/settings.json``. Driving
@@ -4383,7 +4554,80 @@ def create_runner_app(
"detail": _client_safe_error_detail(exc, context="claude-native model change"),
},
)
return Response(status_code=204)
# Verify against the statusLine snapshot the forwarder already polls:
# Claude rewrites it on every render, including right after ``/model``.
# Expected spellings come from this session's own catalog rows (every
# row's ``model`` is the harness's own resolution), plus the typed arg
# and its selection mapping. Success replies only after the pane
# actually switched; the swallowed-dialog case answers non-2xx so the
# server surfaces it instead of the row silently claiming the pick.
expected = {value for value in (resolved_model, model_arg) if value}
for row in _claude_model_options_rows.get(conv_id) or []:
if row.get("id") in (selected_model, resolved_model) or row.get("model") in (
selected_model,
resolved_model,
):
row_model = row.get("model")
if isinstance(row_model, str) and row_model:
expected.add(row_model)
deadline = time.monotonic() + _CLAUDE_MODEL_CONFIRM_TIMEOUT_S
while True:
current = await asyncio.to_thread(read_claude_status_model, bridge_dir)
if current and (current in expected or (baseline and current != baseline)):
# The pane switched. When it landed somewhere other than the
# expected spelling, the forwarder's verbatim report is the
# truth the UI will settle on — the command still took effect.
return Response(status_code=204)
if baseline is None and current is None:
# No statusLine snapshot on either side of the injection: a
# live wrapper-managed pane writes one on every render, so
# this is a shape without the wrapper — the switch is
# unverifiable, not failed. Report success and leave the
# forwarder to reconcile the row.
_logger.warning(
"claude-native model change for session=%s could not be verified: "
"no statusLine snapshot in %s",
conv_id,
bridge_dir,
)
return Response(status_code=204)
# The confirm dialog can render well after the injection's own
# short watch (a warm repaint, or a queued command surfacing) —
# answer it whenever it shows inside the window.
await asyncio.to_thread(
confirm_dialog_if_open, bridge_dir, hint=SWITCH_MODEL_DIALOG_HINT
)
if time.monotonic() >= deadline:
break
await asyncio.sleep(_CLAUDE_MODEL_CONFIRM_POLL_S)
if _native_pane_status.get(conv_id) in ("running", "waiting"):
# Mid-turn switch: Claude queues the typed command and applies it
# when the turn settles — its confirm dialog can pop minutes from
# now. Not a failure: answer success, keep a detached watcher on
# the late dialog, and let the forwarder's report settle the
# picker when the switch actually lands.
watcher = asyncio.create_task(
_watch_late_model_dialog(conv_id, bridge_dir, expected),
name=f"claude-model-dialog-{conv_id}",
)
_model_dialog_watchers.add(watcher)
watcher.add_done_callback(_model_dialog_watchers.discard)
_logger.info(
"claude-native model change for session=%s is queued behind an active "
"turn; watching for the late confirm dialog",
conv_id,
)
return Response(status_code=204)
return JSONResponse(
status_code=503,
content={
"error": "claude_native_model_unconfirmed",
"detail": (
f"the terminal did not confirm the switch to {model_arg} within "
f"{_CLAUDE_MODEL_CONFIRM_TIMEOUT_S:.0f}s — a dialog may be open in the pane"
),
},
)
async def _apply_claude_native_plan_verdict(
conv_id: str,
@@ -5386,12 +5630,20 @@ def create_runner_app(
_cond.notify_all()
async def _post_subagent_wake_notice(
parent_id: str, notice: str, child_id: str, created_by: str | None
parent_id: str,
notice: str,
child_id: str,
created_by: str | None,
*,
is_rewake: bool = False,
) -> None:
delivered = await _deliver_subagent_wake_post(
server_client, parent_id, notice, created_by=created_by
)
if not delivered:
if delivered:
if is_rewake:
_last_rewake_notice[parent_id] = notice
else:
_subagent_wake_pending.discard(parent_id)
_logger.warning(
"Sub-agent wake POST failed for parent=%s child=%s after %d attempt(s); "
@@ -5401,7 +5653,7 @@ def create_runner_app(
_WAKE_POST_MAX_ATTEMPTS,
)
def _schedule_subagent_wake(entry: _SubagentWorkEntry) -> None:
def _schedule_subagent_wake(entry: _SubagentWorkEntry, *, is_rewake: bool = False) -> None:
if entry.parent_session_id == entry.child_session_id:
return
inbox = _session_inboxes.get(entry.parent_session_id)
@@ -5413,30 +5665,39 @@ def create_runner_app(
loop = asyncio.get_running_loop()
except RuntimeError:
return
_subagent_wake_pending.add(entry.parent_session_id)
notice = _format_subagent_wake_notice(
agent=entry.agent,
title=entry.title,
status=entry.status,
pending=inbox.qsize(),
)
if is_rewake and notice == _last_rewake_notice.get(entry.parent_session_id):
return
_subagent_wake_pending.add(entry.parent_session_id)
_wake_task = loop.create_task(
_post_subagent_wake_notice(
entry.parent_session_id,
notice,
entry.child_session_id,
entry.created_by,
is_rewake=is_rewake,
)
)
_wake_task.add_done_callback(_background_tasks.discard)
_background_tasks.add(_wake_task)
def _rewake_parent_if_inbox_stranded(parent_session_id: str) -> None:
inbox = _session_inboxes.get(parent_session_id)
drained = inbox is None or inbox.empty()
if drained:
# A drained inbox ends the stranding episode, so the recorded
# re-wake no longer describes outstanding work; forget it or a
# later episode's matching notice is wrongly deduped.
_last_rewake_notice.pop(parent_session_id, None)
if parent_session_id not in _subagent_wake_pending:
return
_subagent_wake_pending.discard(parent_session_id)
inbox = _session_inboxes.get(parent_session_id)
if inbox is None or inbox.empty():
if drained:
return
entries = list_subagent_work(parent_session_id)
if not entries:
@@ -5445,7 +5706,7 @@ def create_runner_app(
entries,
key=lambda entry: entry.completed_at if entry.completed_at is not None else 0.0,
)
_schedule_subagent_wake(latest)
_schedule_subagent_wake(latest, is_rewake=True)
def _mark_subagent_terminal_and_wake(
child_session_id: str, *, status: str, output: str | None
@@ -6939,6 +7200,24 @@ def create_runner_app(
)
return Response(status_code=204)
if body_type == "permission_mode_change":
harness = _session_harness_name(conversation_id)
if harness == "claude-native":
mode = body.get("permission_mode") if isinstance(body, dict) else None
if mode is not None and not isinstance(mode, str):
return JSONResponse(
status_code=400,
content={
"error": "invalid_input",
"detail": "Body 'permission_mode' must be a string or null",
},
)
return await _handle_claude_native_permission_mode_change(
conversation_id,
mode,
)
return Response(status_code=204)
codex_goal_response = await codex_goal_runner.handle_event(
conversation_id,
body_type,
@@ -7824,18 +8103,23 @@ def create_runner_app(
override=transport,
spec_transport=entry.instance.terminal_transport,
)
bridge = (
bridge_tmux_control_to_websocket
if resolved_transport == TERMINAL_TRANSPORT_CONTROL
else bridge_tmux_pty_to_websocket
)
await bridge(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
on_client_interaction=entry.instance.note_client_interaction,
)
if resolved_transport == TERMINAL_TRANSPORT_CONTROL:
await bridge_tmux_control_to_websocket(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
on_client_interaction=entry.instance.note_client_interaction,
)
else:
await bridge_tmux_pty_to_websocket(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
on_client_interaction=entry.instance.note_client_interaction,
allow_osc52_clipboard=not entry.instance.tmux_allow_passthrough,
)
# Reused by the loopback direct-attach listener (see
# ``omnigent.runner.direct_attach``): same attach handler served on a
@@ -8538,10 +8822,23 @@ def create_runner_app(
}
return JSONResponse(status_code=200, content={"models": models})
# Claude's session listing IS the shared launch catalog: the same
# fingerprint-keyed store file the launch resolved against and the
# host's pre-launch picker serves — identical by construction, no
# separate composition. Cached per session for its lifetime (the launch
# config cannot change under it). A cold store pays one probe: a short
# inline wait answers a warm one, past that the endpoint answers 503
# (the server's fetch retries those) while the store's single-flight
# probe completes in the background.
_claude_model_options_rows: dict[str, list[dict[str, object]]] = {}
@app.get("/v1/sessions/{session_id}/claude-model-options")
async def get_session_claude_model_options(session_id: str) -> JSONResponse:
if _session_harness_name(session_id) != "claude-native":
return JSONResponse(status_code=200, content={"models": []})
cached = _claude_model_options_rows.get(session_id)
if cached is not None:
return JSONResponse(status_code=200, content={"models": cached})
try:
claude_config = await _resolve_session_claude_launch_config(session_id)
except click.ClickException as exc:
@@ -8573,12 +8870,52 @@ def create_runner_app(
),
},
)
from omnigent.claude_native import claude_native_model_options
from omnigent.claude_native import claude_launch_catalog
return JSONResponse(
status_code=200,
content={"models": claude_native_model_options(claude_config)},
)
rows: list[dict[str, object]] | None
try:
# The store's single-flight probe survives this wait expiring
# (ensure_catalog shields it), so a 503 here is genuinely
# "pending", not "restarted".
async with asyncio.timeout(_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S):
rows = await claude_launch_catalog(claude_config)
except TimeoutError:
return JSONResponse(
status_code=503,
content={
"error": "claude_native_model_options_pending",
"detail": "the harness model probe is still resolving",
},
)
if not rows:
return JSONResponse(
status_code=503,
content={
"error": "claude_native_model_options_failed",
"detail": "the harness model probe failed; retrying",
},
)
_claude_model_options_rows[session_id] = rows
return JSONResponse(status_code=200, content={"models": rows})
@app.get("/v1/sessions/{session_id}/model-options")
async def get_session_model_options(session_id: str) -> JSONResponse:
"""One route for every harness family's session model listing.
The runner derives the harness from the session the four
harness-named routes above/below remain as compatibility aliases
for older servers (deprecated; remove in 0.11.0).
"""
harness = _session_harness_name(session_id)
if harness == "claude-native":
return await get_session_claude_model_options(session_id)
if harness in ("codex-native", "opencode-native"):
return await get_session_codex_model_options(session_id)
if harness == "cursor-native":
return await get_session_cursor_model_options(session_id)
if harness == "kiro-native":
return await get_session_kiro_model_options(session_id)
return JSONResponse(status_code=200, content={"models": []})
@app.post("/v1/sessions/{session_id}/skills/resolve")
async def resolve_session_skill(session_id: str, request: Request) -> JSONResponse:
+80
View File
@@ -3768,6 +3768,37 @@ async def _auto_create_codex_terminal(
from omnigent.inner.codex_executor import _find_codex_cli
_codex_cli_path = _find_codex_cli()
# Explicit launches (model-flows design §4): validate an explicit request
# against the shared catalog, and give a Default launch on codex's own
# login the ACCOUNT's real default — so the ``model =`` line copied from
# the user's shared config can never govern a session (the stale-gpt-5.4
# 400 class). Profile-backed shapes already resolve their default at
# materialization time and are left alone.
if launch_config.model_override or (
_codex_launch.model is None and _codex_launch.profile is None
):
from dataclasses import replace as _dataclass_replace
from omnigent.codex_native_app_server import codex_launch_catalog
from omnigent.model_catalog_store import catalog_contains, default_row
_codex_catalog = await codex_launch_catalog(codex_path=_codex_cli_path)
if launch_config.model_override and _codex_catalog:
if not catalog_contains(_codex_catalog, launch_config.model_override):
raise click.ClickException(
f"the requested model {launch_config.model_override!r} is not in "
"this host's current model list — it may have changed since the "
"pick. Pick again from the model menu."
)
if _codex_launch.model is None and _codex_launch.profile is None and _codex_catalog:
_catalog_default = default_row(_codex_catalog)
_default_id = (
str(_catalog_default.get("id") or _catalog_default.get("model") or "") or None
if _catalog_default is not None
else None
)
if _default_id:
_codex_launch = _dataclass_replace(_codex_launch, model=_default_id)
# Cancel any surviving forwarder first so its teardown closes the OLD app-server,
# not the one registered below — and so it can't mirror alongside the new one.
await _cancel_auto_forwarder_task(session_id)
@@ -6450,6 +6481,43 @@ async def _auto_create_claude_terminal(
or (claude_config.model if claude_config is not None else None),
claude_config,
)
# Explicit launches (model-flows design §4): consult the shared catalog
# only when it can change the outcome — to validate an explicit request,
# or to resolve a Default launch that would otherwise pass no ``--model``
# and leave the model to invisible CLI-private state.
if session_model_override or launch_model is None:
from omnigent.claude_native import claude_catalog_serves_model, claude_launch_catalog
from omnigent.model_catalog_store import default_row
launch_catalog: list[dict[str, object]] | None = None
try:
launch_catalog = await claude_launch_catalog(claude_config)
except Exception: # noqa: BLE001 — no catalog means no validation/default
_logger.warning(
"claude launch catalog unavailable for session=%s", session_id, exc_info=True
)
if session_model_override and launch_catalog:
resolved_request = (
resolve_claude_native_model_selection(session_model_override, claude_config)
or session_model_override
)
# A pane's ``/model`` persists the exact id it runs; the catalog
# may spell that model only by its family alias.
if not (
claude_catalog_serves_model(launch_catalog, session_model_override, claude_config)
or claude_catalog_serves_model(launch_catalog, resolved_request, claude_config)
):
raise click.ClickException(
f"the requested model {session_model_override!r} is not in this "
"host's current model list — it may have changed since the pick. "
"Pick again from the model menu."
)
if launch_model is None and launch_catalog:
catalog_default = default_row(launch_catalog)
if catalog_default is not None:
launch_model = (
str(catalog_default.get("model") or catalog_default.get("id") or "") or None
)
# Give an exact launch model (a Smart Routing pick is resolved before the
# terminal exists) a spelling of its own in the picker, so a later
# ``/model`` can return to it instead of stepping onto whatever the family
@@ -6461,6 +6529,18 @@ async def _auto_create_claude_terminal(
claude_config = claude_config_with_launch_model_pinned(claude_config, launch_model)
if record_launch_config is not None:
record_launch_config(session_id, claude_config)
# Persist the vocabulary + launch model onto the bridge so mid-session
# ``/model`` conversion reads THIS session's pins, not the runner's
# ambient env (the CLI path records these at prepare time; the runner
# resolves its config only after the bridge exists).
from omnigent.claude_native_bridge import record_model_vocabulary
await asyncio.to_thread(
record_model_vocabulary,
bridge_dir,
launch_env=claude_config.env if claude_config is not None else None,
launch_model=launch_model,
)
_logger.info(
"Claude terminal provider config resolved: session=%s configured=%s "
"env_keys=%s api_key_helper_set=%s model_set=%s launch_model=%s",
@@ -989,6 +989,20 @@ class HarnessProcessManager:
"""
return conversation_id in self._in_flight_response_ids
def note_activity(self, conversation_id: str) -> None:
"""Refresh the idle lease for an existing harness subprocess.
Native terminal turns do not pass through ``proxy_stream``, so their
terminal activity calls this method instead. No-op when the
conversation has no registered subprocess.
:param conversation_id: AP-allocated conversation id,
e.g. ``"conv_abc123"``.
"""
entry = self._entries.get(conversation_id)
if entry is not None:
entry.last_used_at = time.monotonic()
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
"""
Record that *conversation_id* has a live harness turn.
+38 -10
View File
@@ -1268,6 +1268,7 @@ def create_app(
agent_store=agent_store,
conversation_store=conversation_store,
permission_store=permission_store,
policy_store=policy_store,
host_store=host_store,
host_registry=host_registry,
agent_cache=agent_cache,
@@ -2720,6 +2721,22 @@ def create_app(
# auth routes and ``/v1/me`` share one roster. Consulted on each login
# to promote listed identities — the only admin path for OIDC, and an
# additive convenience for accounts.
# Login-issued refresh grants: both server-mintable providers
# (accounts, oidc) get a grant store so `omnigent login` can hand
# the CLI refresh material — without it, an unattended host dies
# permanently at session-JWT expiry (default 8 h). The store also
# backs the opt-in RFC 8628 device flow below.
device_grant_store = None
if (
isinstance(auth_provider, UnifiedAuthProvider)
and auth_provider._source in ("accounts", "oidc")
and permission_store is not None
):
from omnigent.server.device_grant_store import DeviceGrantStore
device_grant_store = DeviceGrantStore(permission_store.storage_location)
auth_provider.set_grant_revocation_check(device_grant_store.is_revoked)
if (
isinstance(auth_provider, UnifiedAuthProvider)
and auth_provider._source == "accounts"
@@ -2731,7 +2748,10 @@ def create_app(
app.include_router(
create_accounts_auth_router(
auth_provider, account_store, admin_list, permission_store
auth_provider,
account_store,
admin_list,
permission_store,
),
prefix="/auth",
tags=["auth"],
@@ -2762,6 +2782,7 @@ def create_app(
admin_list,
oidc_account_store,
allowed_domains=frozenset(allowed_domains or ()) or None,
device_grant_store=device_grant_store,
),
prefix="/auth",
tags=["auth"],
@@ -2773,24 +2794,20 @@ def create_app(
)
# Device Authorization Grant (RFC 8628): opt-in, default-off via
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only. OIDC delegates
# login to the IdP (cli-ticket flow), so it neither needs nor mounts
# these routes. Wires the revocation lookup into the auth provider so
# revoking a grant immediately rejects its delegated access tokens.
# See designs/DEVICE_AUTH.md.
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only (the
# in-browser consent flow needs the accounts login page). Wires
# the full /oauth/* surface including the token endpoint. See
# designs/DEVICE_AUTH.md.
from omnigent.server.auth import env_var_is_truthy
if (
env_var_is_truthy("OMNIGENT_DEVICE_GRANT_ENABLED", default=False)
and isinstance(auth_provider, UnifiedAuthProvider)
and auth_provider._source == "accounts"
and permission_store is not None
and device_grant_store is not None
):
from omnigent.server.device_grant_store import DeviceGrantStore
from omnigent.server.routes.device_auth import create_device_auth_router
device_grant_store = DeviceGrantStore(permission_store.storage_location)
auth_provider.set_grant_revocation_check(device_grant_store.is_revoked)
app.include_router(
create_device_auth_router(auth_provider, device_grant_store),
tags=["oauth"],
@@ -2811,6 +2828,17 @@ def create_app(
"the server and its trusted client(s) to restrict initiation "
"to authorized clients. See designs/DEVICE_AUTH.md.",
)
elif isinstance(auth_provider, UnifiedAuthProvider) and device_grant_store is not None:
# No device flow, but login-issued refresh grants still need
# their token/revoke endpoints — in OIDC mode and in accounts
# mode without the flag alike.
from omnigent.server.routes.device_auth import create_oauth_token_router
app.include_router(
create_oauth_token_router(auth_provider, device_grant_store),
tags=["oauth"],
)
_logger.info("login-grant: /oauth/token + /oauth/revoke enabled")
# Mount the built web SPA at "/" if a build is present. The SPA is
# built into ``omnigent/server/static/web-ui/`` by ``web/``'s Vite
+15 -6
View File
@@ -53,6 +53,9 @@ _TRUTHY_STRINGS = ("1", "true", "yes")
# any path not covered here, so it can never touch admin / user-management
# endpoints (``/auth/users``, ``/auth/invite``, ``/auth/setup`` …) even if
# its underlying identity is an admin. Delegated clients only need these.
# First-party login-grant tokens carry no ``scope`` and are NOT restricted
# here — they renew the session JWT and keep its authority (see
# ``_check_cookie`` and ``routes/device_auth.LOGIN_GRANT_CLIENT_ID``).
_DELEGATED_ALLOWED_PREFIXES = (
"/health",
"/v1/agents",
@@ -601,18 +604,24 @@ class UnifiedAuthProvider(AuthProvider):
if not isinstance(user_id, str) or not user_id or user_id in _RESERVED_USERS:
return None
# Delegated (device-grant) tokens carry a ``grant_id`` claim.
# They get two extra, request-scoped checks — a fail-closed path
# allowlist and a live revocation lookup — so they are never
# served from the plain user-id cache (which would skip both).
# Grant-derived tokens carry a ``grant_id`` claim. They get
# request-scoped checks — a live revocation lookup, plus (for
# restricted tokens) a fail-closed path allowlist — so they are
# never served from the plain user-id cache (which would skip both).
grant_id = payload.get("grant_id")
if grant_id is not None:
if not isinstance(grant_id, str):
return None
if not delegated_path_allowed(request.url.path):
return None
if self._grant_revoked is not None and self._grant_revoked(grant_id):
return None
# The allowlist restricts DELEGATED tokens — a third-party
# client (e.g. Slack) acting on a user's behalf, marked by the
# ``scope`` claim. A first-party login grant carries no scope:
# its bearer is the user's own CLI/host, and the token renews
# the session JWT it replaced, so it keeps that same authority
# (still revocable via ``grant_id`` above).
if payload.get("scope") is not None and not delegated_path_allowed(request.url.path):
return None
return user_id
# Cache for remaining lifetime of the token.
+51
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
import hashlib
import hmac
import secrets
from typing import cast
from sqlalchemy import and_, delete, or_, update
@@ -135,6 +136,56 @@ class DeviceGrantStore:
session.flush()
return _to_device_grant(row)
def create_redeemed_grant(
self,
grant_id: str,
*,
user_id: str,
client_id: str | None,
refresh_token_hash: str,
created_at: int,
) -> DeviceGrant:
"""Persist a grant born ``redeemed`` — no device-code consent step.
Backs login-issued refresh grants: the user just proved their
identity interactively (IdP browser flow or password prompt), so
the RFC 8628 pending approved dance would re-ask for consent
already given. The row starts ``redeemed`` with its refresh-token
digest set, exactly as if it had completed the device flow.
The device_code/user_code columns are filled with discarded
random material: no client ever polls with them, and ``pending``
purge conditions never match a ``redeemed`` row.
:param grant_id: Opaque grant id (public travels in JWTs).
:param user_id: The authenticated identity (the token ``sub``).
:param client_id: Public client name for audit, e.g.
``"omnigent-cli"``.
:param refresh_token_hash: HMAC digest of the initial refresh
token. The store never sees the raw token.
:param created_at: Unix epoch seconds; also ``approved_at``, the
anchor for the grant's absolute lifetime.
:returns: The created :class:`DeviceGrant`.
"""
with self._session("insert_redeemed_device_grant") as session:
row = SqlDeviceGrant(
id=grant_id,
device_code_hash=secrets.token_urlsafe(32),
user_code=secrets.token_urlsafe(16),
status=encode_device_grant_status("redeemed"),
client_id=client_id,
user_id=user_id,
refresh_token_hash=refresh_token_hash,
prev_refresh_token_hash=None,
created_at=created_at,
expires_at=created_at,
approved_at=created_at,
last_polled_at=None,
)
session.add(row)
session.flush()
return _to_device_grant(row)
def get_by_user_code(self, user_code: str) -> DeviceGrant | None:
"""Look up a grant by its short verification code.
@@ -129,6 +129,9 @@ _EXTERNAL_SESSION_USAGE_TYPE: str = "external_session_usage"
_EXTERNAL_MODEL_CHANGE_TYPE: str = "external_model_change"
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE: str = "external_permission_mode_change"
_EXTERNAL_SESSION_TITLE_TYPE: str = "external_session_title"
@@ -189,6 +192,21 @@ _EXTERNAL_CODEX_APPROVAL_MODE_CHANGE_TYPE: str = "external_codex_approval_mode_c
_CODEX_NATIVE_COLLABORATION_MODES: frozenset[str] = frozenset({"default", "plan"})
# Current permission mode of a live claude-native session.
# ``terminal_launch_args`` records only the launch mode, so this label is what
# the web UI reads back after a reload.
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY = "omnigent.claude_native.permission_mode"
# Permission modes switchable on a running session — the ones Claude
# Code's shift+tab cycle can reach. Mirrors
# ``claude_native_bridge.CYCLEABLE_PERMISSION_MODES``; ``dontAsk`` and
# ``bypassPermissions`` are launch-only and rejected on PATCH.
_CLAUDE_NATIVE_PERMISSION_MODES: frozenset[str] = frozenset(
{"default", "acceptEdits", "plan", "auto"}
)
_CODEX_NATIVE_SUBAGENT_DISPLAY_FALLBACK = "Codex"
@@ -419,6 +437,7 @@ _ALLOWED_EVENT_TYPES: frozenset[str] = frozenset(ITEM_TYPE_TO_DATA_CLS.keys()) |
_EXTERNAL_MCP_STARTUP_TYPE,
_EXTERNAL_MODEL_CHANGE_TYPE,
_EXTERNAL_MODEL_OPTIONS_TYPE,
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
_EXTERNAL_SESSION_TITLE_TYPE,
_EXTERNAL_SESSION_TODOS_TYPE,
@@ -797,6 +816,8 @@ __all__ = [
"_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S",
"_CLAUDE_NATIVE_MODEL",
"_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S",
"_CLAUDE_NATIVE_PERMISSION_MODES",
"_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY",
"_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS",
"_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY",
"_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE",
@@ -842,6 +863,7 @@ __all__ = [
"_EXTERNAL_MODEL_OPTIONS_TYPE",
"_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE",
"_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE",
"_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE",
"_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE",
"_EXTERNAL_SESSION_INTERRUPTED_TYPE",
"_EXTERNAL_SESSION_STATUS_TYPE",
+163 -20
View File
@@ -136,6 +136,8 @@ from omnigent.server.routes._sessions.common import ( # noqa: F401
_CLAUDE_NATIVE_DESCRIPTION_LABEL_KEY,
_CLAUDE_NATIVE_EDIT_TOOLS,
_CLAUDE_NATIVE_HARNESS,
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
_CLAUDE_NATIVE_PERMISSION_MODES,
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS,
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY,
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
@@ -249,6 +251,7 @@ from omnigent.server.schemas import (
SessionMcpStartupEvent,
SessionModelEvent,
SessionModelOptionsEvent,
SessionPermissionModeEvent,
SessionReasoningEffortEvent,
SessionResourceListPage,
SessionResourcePaginatedList,
@@ -311,6 +314,22 @@ def _publish_collaboration_mode(session_id: str, mode: str) -> None:
session_stream.publish(session_id, event.model_dump())
def _publish_permission_mode(session_id: str, mode: str) -> None:
"""
Publish the live claude-native permission mode for a session.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param mode: The active permission mode, e.g. ``"auto"``.
:returns: None.
"""
event = SessionPermissionModeEvent(
type="session.permission_mode",
conversation_id=session_id,
permission_mode=mode,
)
session_stream.publish(session_id, event.model_dump())
def _publish_policy_denied(session_id: str, reason: str, phase: str) -> None:
"""
Publish a native policy-DENY signal on the session stream.
@@ -2126,28 +2145,32 @@ async def _persist_external_model_change(
conversation_store: ConversationStore,
) -> None:
"""
Persist and broadcast a model switch made inside the terminal.
Persist and broadcast the model the harness reports it is running.
Mirrors a ``/model`` change typed into a claude-native session's
Claude Code pane (or picked via its in-TUI model picker) onto the
Omnigent session: writes ``model_override`` so the value survives reload
and publishes a ``session.model`` SSE event so the web picker
updates live. Unlike the PATCH path
(:func:`update_session`), this deliberately does NOT forward a
``model_change`` back to the runner the terminal is already on
the model, so re-injecting ``/model`` would loop.
Mirrors a harness-side model report the launch's own model, or a
``/model`` change made inside the pane onto the Omnigent session:
writes ``reported_model`` VERBATIM (the harness's own spelling,
never collapsed to a picker alias) so the value survives reload,
and publishes a ``session.model`` SSE event so every surface
re-renders from it. The user's request (``model_override``) is
deliberately untouched: requests and reports are separate roles,
and only reports are ever displayed. Unlike the PATCH path
(:func:`update_session`), this does NOT forward a ``model_change``
back to the runner the terminal is already on the model, so
re-injecting ``/model`` would loop.
No-ops (no write, no event) when the observed model already equals
the persisted ``model_override`` the common case on the webTUI
round-trip where the web PATCH set the override moments earlier.
No-ops (no write, no event) when the reported model already equals
the persisted ``reported_model`` the steady state between real
changes, since forwarders re-observe on every poll.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for ``session_id`` (read at the route
boundary); ``conv.model_override`` is the dedupe baseline.
boundary); ``conv.reported_model`` is the dedupe baseline.
:param body: External model-change event body. ``data.model`` must
be a non-empty string tier alias, e.g. ``"opus"``.
:param conversation_store: Store used to upsert ``model_override``.
be a non-empty string the harness's verbatim model, e.g.
``"claude-opus-4-8[1m]"`` or ``"gpt-5.6-luna"``.
:param conversation_store: Store used to upsert ``reported_model``.
:raises OmnigentError: If ``data.model`` is missing or not a
non-empty string.
"""
@@ -2158,12 +2181,12 @@ async def _persist_external_model_change(
code=ErrorCode.INVALID_INPUT,
)
model = raw_model.strip()
if conv.model_override == model:
if conv.reported_model == model:
return
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
model_override=model,
reported_model=model,
)
event = SessionModelEvent(
type="session.model",
@@ -2425,6 +2448,51 @@ async def _persist_external_codex_collaboration_mode_change(
_publish_collaboration_mode(session_id, mode)
async def _persist_external_permission_mode_change(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> None:
"""
Persist a pane-observed claude-native permission mode as a session label.
The forwarder posts this when the pane's mode footer differs from what it
last reported i.e. the user pressed shift+tab in the TUI. Unlike the
PATCH path this needs no runner confirmation: the pane IS the source, so
the mode is already in effect.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param conv: Conversation row for ``session_id`` at the route boundary.
:param body: Event body; ``data.permission_mode`` must be a switchable mode.
:param conversation_store: Store used to upsert the mode label.
:returns: None.
:raises OmnigentError: If ``data.permission_mode`` is missing or unsupported.
"""
raw_mode = body.data.get("permission_mode")
if not isinstance(raw_mode, str) or not raw_mode.strip():
raise OmnigentError(
"external_permission_mode_change requires data.permission_mode "
"to be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
mode = raw_mode.strip()
if mode not in _CLAUDE_NATIVE_PERMISSION_MODES:
raise OmnigentError(
"external_permission_mode_change requires data.permission_mode in "
f"{sorted(_CLAUDE_NATIVE_PERMISSION_MODES)}; got {mode!r}",
code=ErrorCode.INVALID_INPUT,
)
if conv.labels.get(_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY) == mode:
return
await asyncio.to_thread(
conversation_store.set_labels,
session_id,
{_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY: mode},
)
_publish_permission_mode(session_id, mode)
async def _persist_external_codex_approval_mode_change(
session_id: str,
conv: Conversation,
@@ -3725,6 +3793,58 @@ def _require_collaboration_mode_forward(
)
def _require_permission_mode_forward(
session_id: str,
mode: str,
runner_result: _RunnerForwardResult | None,
) -> str:
"""
Fail when a live claude-native permission-mode switch wasn't applied.
The mode lives in the running TUI, so persisting the label without a
confirmed 2xx forward would let the UI claim auto mode while Claude still
prompts on every edit. Returns the mode the runner actually reached, so
the caller stores what the pane shows rather than what was asked for.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param mode: Requested permission mode, e.g. ``"auto"``.
:param runner_result: HTTP result returned by the runner, or ``None``
when no runner could be reached.
:returns: The mode the runner reports the pane is now in the
requested *mode* when the runner didn't echo one back.
:raises OmnigentError: If no runner was reachable or the runner could
not switch the session into *mode*.
"""
if runner_result is None:
raise OmnigentError(
f"Could not switch to {mode} mode: no live Claude runner is available "
f"for session {session_id!r}. Reconnect the session and try again.",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
if not 200 <= runner_result.status_code < 300:
# The runner's body carries why the cycle failed (e.g. the mode
# isn't in this session's cycle); surface it so the UI banner
# explains the failure instead of showing a bare status code.
detail = ""
try:
payload = json.loads(runner_result.body)
except (TypeError, ValueError):
payload = None
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
detail = f" {payload['detail']}"
raise OmnigentError(
f"Could not switch to {mode} mode for session {session_id!r}.{detail}",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
try:
body = json.loads(runner_result.body)
except (TypeError, ValueError):
return mode
settled = body.get("permission_mode") if isinstance(body, dict) else None
return settled if isinstance(settled, str) and settled else mode
def _publish_status(
session_id: str,
status: str,
@@ -5222,6 +5342,13 @@ def _surface_model_change_forward_failure(
if 200 <= runner_result.status_code < 300:
return
reason = f"the runner returned status {runner_result.status_code}"
# The runner's own detail names the concrete cause (e.g. "a dialog may
# be open in the pane"); carry it into the visible notice when present.
with contextlib.suppress(ValueError, TypeError):
parsed_body = json.loads(runner_result.body)
detail = parsed_body.get("detail") if isinstance(parsed_body, dict) else None
if isinstance(detail, str) and detail.strip():
reason = f"{reason} ({detail.strip()})"
_logger.warning(
"Model change not applied to the terminal for session=%s model=%r: %s (body=%s)",
session_id,
@@ -9251,14 +9378,19 @@ async def _load_model_options(
runner_client: httpx.AsyncClient,
session_id: str,
path: str,
fallback_path: str | None = None,
) -> None:
"""
Background single-flight fetch of a session's native model catalog.
:param runner_client: HTTP client pointed at the bound runner.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc"``.
:param path: Runner route to query, e.g.
``"/v1/sessions/conv_abc/cursor-model-options"``.
:param path: Runner route to query the unified
``"/v1/sessions/conv_abc/model-options"``.
:param fallback_path: Legacy harness-named route to fall back to when
*path* 404s (an older runner without the unified route), e.g.
``"/v1/sessions/conv_abc/cursor-model-options"``. ``None`` disables
the fallback.
"""
# Read the retry schedule off the facade so tests patching
# ``sessions._MODEL_OPTIONS_RETRY_DELAYS_S`` reach this impl.
@@ -9272,6 +9404,11 @@ async def _load_model_options(
_logger.debug("Runner model-options query failed for %s", session_id)
return
if resp.status_code != 200:
# An older runner has no unified route; drop to the harness-named
# one without consuming a retry.
if resp.status_code == 404 and fallback_path and path != fallback_path:
path = fallback_path
continue
# 503 means the native backend (Codex app-server bridge / cursor
# login) is still booting. Keep the background single-flight alive
# so the web picker fills without a second manual refresh.
@@ -9401,7 +9538,10 @@ def prefetch_session_routing_catalogs(
options_task = asyncio.create_task(
_run_catalog_prefetch(
_load_model_options(
runner_client, session_id, f"/v1/sessions/{session_id}/{endpoint}"
runner_client,
session_id,
f"/v1/sessions/{session_id}/model-options",
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
),
session_id,
)
@@ -9599,6 +9739,7 @@ __all__ = [
"_persist_external_codex_collaboration_mode_change",
"_persist_external_model_change",
"_persist_external_model_options",
"_persist_external_permission_mode_change",
"_persist_external_reasoning_effort_change",
"_persist_external_session_title",
"_persist_external_subagent_start",
@@ -9635,6 +9776,7 @@ __all__ = [
"_publish_interrupted",
"_publish_mcp_startup",
"_publish_model_options",
"_publish_permission_mode",
"_publish_policy_denied",
"_publish_policy_deny",
"_publish_runner_skills",
@@ -9660,6 +9802,7 @@ __all__ = [
"_require_declared_subagent",
"_require_external_status_forward",
"_require_host_conn_for_worktree",
"_require_permission_mode_forward",
"_reset_runner_resources_after_switch",
"_reset_runner_resources_after_switch_impl",
"_resolve_harness",
@@ -1221,7 +1221,13 @@ def _accumulate_session_usage(
llm_model = (
usage_model
if isinstance(usage_model, str) and usage_model
else (conv.model_override if conv and conv.model_override else _resolve_llm_model(conv))
else (
conv.reported_model
if conv and conv.reported_model
else (
conv.model_override if conv and conv.model_override else _resolve_llm_model(conv)
)
)
)
if llm_model:
if isinstance(provider_cost, (int, float)):
@@ -4253,7 +4259,12 @@ async def _refresh_stale_native_model_options(
if inflight is None:
endpoint = _MODEL_OPTIONS_ENDPOINT_BY_WRAPPER[_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE]
inflight = asyncio.create_task(
_load_model_options(runner_client, session_id, f"/v1/sessions/{session_id}/{endpoint}")
_load_model_options(
runner_client,
session_id,
f"/v1/sessions/{session_id}/model-options",
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
)
)
_model_options_inflight[session_id] = inflight
inflight.add_done_callback(
@@ -8977,8 +8988,16 @@ async def _fetch_model_options(
if cached is not None and session_id not in _model_options_stale:
return cached
if session_id not in _model_options_inflight:
path = f"/v1/sessions/{session_id}/{endpoint}"
task = asyncio.create_task(_load_model_options(runner_client, session_id, path))
# Unified route first; the harness-named route is the fallback for
# an older runner (deprecated aliases, removed in 0.11.0).
task = asyncio.create_task(
_load_model_options(
runner_client,
session_id,
f"/v1/sessions/{session_id}/model-options",
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
)
)
_model_options_inflight[session_id] = task
def _clear_runner_options_inflight(_task: asyncio.Task[None]) -> None:
@@ -9209,6 +9228,12 @@ async def _get_session_snapshot(
)
except Exception: # noqa: BLE001
pass
# The harness's own report is the display authority: when a session has
# a verbatim ``reported_model``, it supersedes the spec-derived value on
# the wire's ``llm_model`` field (the web renders and highlights only
# from this).
if conv.reported_model:
llm_model = conv.reported_model
# Skills are runner-owned: the bound runner discovers them against its
# own filesystem (bundled skills + host skills under the session's
# workspace and ``~/.claude/skills/``) — the host where the harness
+5 -1
View File
@@ -306,11 +306,15 @@ def create_accounts_auth_router(
# against a row that exists. Defensive-coding the dereference
# would only mask a SqlAlchemy bug, which we want to surface.
assert user is not None
body_payload = {
body_payload: dict[str, object] = {
"token": session_jwt,
"expires_in": _session_max_age,
"user": {"id": user.id, "is_admin": user.is_admin},
}
# Browser login must never receive refresh material — only CLI/device
# flows do (via /auth/cli-poll or device-grant callback). This is
# enforced server-side so an XSS or form-hijack cannot obtain
# long-lived unattended credentials.
resp = JSONResponse(status_code=200, content=body_payload)
_set_session_cookie(
resp,
+38 -8
View File
@@ -30,6 +30,7 @@ from omnigent.server.auth import (
_RESERVED_USERS,
UnifiedAuthProvider,
)
from omnigent.server.device_grant_store import DeviceGrantStore
from omnigent.server.oidc import (
_GITHUB_EMAILS_ENDPOINT,
derive_code_challenge,
@@ -37,6 +38,7 @@ from omnigent.server.oidc import (
mint_session_cookie,
)
from omnigent.server.oidc_access import OidcAdmissionPolicy, resolve_allowed_domains_path
from omnigent.server.routes.device_auth import issue_login_grant
from omnigent.stores.permission_store import PermissionStore
_logger = logging.getLogger(__name__)
@@ -67,11 +69,16 @@ class _CliTicket:
fulfills the ticket. ``None`` while pending.
:param user_id: The authenticated user's email, set when
fulfilled. ``None`` while pending.
:param refresh_token: Login-issued refresh grant material, set at
fulfillment when a grant store is wired. ``None`` while pending
or when grants are unavailable. Handed to the CLI exactly once
by the poll response.
"""
created_at: float = field(default_factory=time.time)
token: str | None = None
user_id: str | None = None
refresh_token: str | None = None
def create_auth_router(
@@ -80,6 +87,7 @@ def create_auth_router(
admin_list: AdminList,
account_store: SqlAlchemyAccountStore | None = None,
allowed_domains: frozenset[str] | None = None,
device_grant_store: DeviceGrantStore | None = None,
) -> APIRouter:
"""Create an :class:`APIRouter` with OIDC login/callback/logout routes.
@@ -100,6 +108,12 @@ def create_auth_router(
``allowed_domains:`` key, union'd with
``OMNIGENT_OIDC_ALLOWED_DOMAINS`` and the runtime-editable file
in the admission policy.
:param device_grant_store: When set, a CLI-ticket login also issues
a refresh grant (see
:func:`omnigent.server.routes.device_auth.issue_login_grant`)
so hosts and CLIs can renew without a human re-running
``omnigent login``. ``None`` keeps the legacy
session-JWT-only response.
:returns: A FastAPI router with ``/login``, ``/callback``,
``/logout`` (and ``/invite`` when invites are enabled).
"""
@@ -367,6 +381,19 @@ def create_auth_router(
ticket = _cli_tickets[ticket_id]
ticket.token = session_jwt
ticket.user_id = email
# A CLI login is a long-lived unattended credential holder
# (hosts especially) — issue a refresh grant so it can renew
# instead of dying at session-JWT expiry. Best-effort: a
# grant-store failure must not break login itself.
if device_grant_store is not None:
try:
ticket.refresh_token = issue_login_grant(
device_grant_store,
user_id=email,
cookie_secret=config.cookie_secret,
)
except Exception:
_logger.exception("cli-login: refresh grant issuance failed")
# Return a simple HTML page — the CLI is polling
# /auth/cli-poll and will pick up the token.
import html as _html
@@ -560,15 +587,18 @@ def create_auth_router(
# Fulfilled — return the token and clean up.
token = ticket.token
user_id = ticket.user_id
refresh_token = ticket.refresh_token
del _cli_tickets[ticket_id]
return JSONResponse(
status_code=200,
content={
"token": token,
"user_id": user_id,
"expires_in": config.session_ttl_hours * 3600,
},
)
content: dict[str, object] = {
"token": token,
"user_id": user_id,
"expires_in": config.session_ttl_hours * 3600,
}
# Only present when a grant store is wired — old CLIs ignore the
# extra key, new CLIs against old servers see it absent.
if refresh_token is not None:
content["refresh_token"] = refresh_token
return JSONResponse(status_code=200, content=content)
# ── Admin: read-only user list ────────────────────────────────
+410 -156
View File
@@ -53,6 +53,7 @@ import logging
import os
import secrets
import time
from collections.abc import Callable
import jwt
from fastapi import APIRouter, HTTPException, Request
@@ -80,6 +81,26 @@ _CLIENT_SECRET_HEADER = "X-Omnigent-Client-Secret"
# refuses admin / user-management paths for a token carrying this scope.
DELEGATED_SCOPE = "sessions"
# Reserved ``client_id`` for a first-party login grant (issued by
# ``omnigent login``, not the RFC 8628 device flow). It is a
# security-decision key here, so the device-authorize endpoint REFUSES a
# request that names it — a third-party device client can never obtain a
# grant tagged this way, and a refresh of such a grant is therefore safe
# to treat as first-party. See :func:`issue_login_grant`,
# ``_is_login_grant``, and the reservation guard in ``device_authorize``.
LOGIN_GRANT_CLIENT_ID = "omnigent-cli"
def _is_login_grant(client_id: str | None) -> bool:
"""Return True for a first-party login grant (vs. a device grant).
Trustworthy because :data:`LOGIN_GRANT_CLIENT_ID` is reserved: the
device-authorize path rejects it, so only the server-side login flow
can create a grant carrying it.
"""
return client_id == LOGIN_GRANT_CLIENT_ID
# RFC 8628 timings.
_DEVICE_CODE_TTL_SECONDS = 600 # 10 min — bounds the unapproved window.
_POLL_INTERVAL_SECONDS = 5 # minimum client poll interval.
@@ -91,6 +112,41 @@ _ACCESS_TOKEN_TTL_SECONDS = 3600
# re-consent through the flow. Bounds the blast radius of a leaked/phished
# grant to this window even if revocation is never called.
_GRANT_MAX_LIFETIME_SECONDS = 30 * 24 * 3600 # 30 days
# Operator override for the grant lifetime, in whole days. Deployments
# running unattended hosts can extend the re-consent window deliberately;
# the 30-day default stays the safe posture.
_GRANT_MAX_LIFETIME_ENV = "OMNIGENT_GRANT_MAX_LIFETIME_DAYS"
def _grant_max_lifetime_seconds() -> int:
"""Return the grant's absolute lifetime, honoring the env override.
Invalid or non-positive values fall back to the default auth
lifetimes must never fail open to "unbounded" on a typo.
"""
raw = os.environ.get(_GRANT_MAX_LIFETIME_ENV, "").strip()
if raw:
try:
days = int(raw)
except ValueError:
_logger.warning(
"%s=%r is not an integer — using the %d-day default",
_GRANT_MAX_LIFETIME_ENV,
raw,
_GRANT_MAX_LIFETIME_SECONDS // 86400,
)
else:
if days > 0:
return days * 86400
_logger.warning(
"%s=%r must be positive — using the %d-day default",
_GRANT_MAX_LIFETIME_ENV,
raw,
_GRANT_MAX_LIFETIME_SECONDS // 86400,
)
return _GRANT_MAX_LIFETIME_SECONDS
# user_code alphabet excludes easily-confused chars (0/O, 1/I/L).
_USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
@@ -106,10 +162,16 @@ def _client_id(body: dict[str, object]) -> str | None:
A public string naming the requesting application (e.g. Slack passes
``"slack"``), the same for every grant that application initiates.
Display + audit only never an authorization key.
Display + audit only for device grants but see
:data:`LOGIN_GRANT_CLIENT_ID`, which is reserved and refused here.
Non-string values (``{"client_id": 123}``) read as absent rather than
raising, so a malformed body is a clean 400 and not a 500.
"""
value = body.get("client_id")
return value.strip() or None if isinstance(value, str) else None
raw = body.get("client_id")
if not isinstance(raw, str):
return None
return raw.strip() or None
def _mint_refresh_token() -> str:
@@ -126,34 +188,38 @@ def mint_delegated_token(
grant_id: str,
client_id: str,
jti: str,
scope: str = DELEGATED_SCOPE,
scope: str | None = DELEGATED_SCOPE,
) -> str:
"""Mint a delegated access token for a device-authorization grant.
"""Mint a grant-derived access token.
Same HS256 shape as
:func:`omnigent.server.oidc.mint_session_token` (so
:meth:`UnifiedAuthProvider._check_cookie` validates it unchanged),
plus four delegated-only claims:
plus grant claims:
- ``scope`` restricts the token to the session APIs; the auth
layer rejects admin endpoints when this claim is present.
- ``grant_id`` the device grant this token was issued from,
checked against the revocation denylist so revoking the grant
immediately kills the token.
- ``scope`` present the auth layer restricts the token to the
session APIs and refuses admin endpoints. ``None`` omits the claim,
giving the token the SAME authority as the session JWT it renews
used ONLY for first-party login grants, whose bearer is the
authenticated user's own CLI/host, not a third-party client.
- ``grant_id`` the grant this token was issued from, checked against
the revocation denylist so revoking the grant immediately kills the
token. Carried regardless of scope.
- ``jti`` unique token id, for audit/log correlation.
- ``act`` provenance (RFC 8693 style), ``{"client_id": "<app>"}``,
naming the application that obtained the grant so every delegated
action is attributable to it.
naming the application that obtained the grant so every action is
attributable to it.
:param user_id: The Omnigent identity the token acts as (``sub``).
:param cookie_secret: HMAC key for HS256 signing.
:param ttl_seconds: Token lifetime in seconds (kept short 1 h).
:param provider: Identity provider name (informational claim).
:param grant_id: The device grant id.
:param client_id: The RFC 8628 client id (the requesting application,
:param grant_id: The grant id.
:param client_id: The client id (the requesting application,
e.g. ``"slack"``); recorded in the ``act`` claim for audit.
:param jti: Unique token id.
:param scope: Granted scope; defaults to :data:`DELEGATED_SCOPE`.
:param scope: Granted scope, or ``None`` for a full-authority
(login-grant) token. Defaults to :data:`DELEGATED_SCOPE`.
:returns: An HS256-signed JWT string.
"""
now = int(time.time())
@@ -162,11 +228,12 @@ def mint_delegated_token(
"iat": now,
"exp": now + ttl_seconds,
"provider": provider,
"scope": scope,
"grant_id": grant_id,
"jti": jti,
"act": {"client_id": client_id},
}
if scope is not None:
payload["scope"] = scope
return jwt.encode(payload, cookie_secret, algorithm="HS256")
@@ -257,6 +324,308 @@ class _SlidingWindowRateLimiter:
return True
def _resolve_signing_config(auth_provider: UnifiedAuthProvider) -> tuple[bytes, str]:
"""Return ``(cookie_secret, provider_name)`` for token minting.
Works for both server-mintable providers: ``accounts`` and ``oidc``.
Header mode has no server-held signing identity, so grant routes
cannot be built for it.
"""
if auth_provider._source == "accounts":
config = auth_provider._accounts_config
assert config is not None, "accounts mode must have an accounts config"
return config.cookie_secret, auth_provider._source
if auth_provider._source == "oidc":
oidc_config = auth_provider._oidc_config
assert oidc_config is not None, "oidc mode must have an oidc config"
return oidc_config.cookie_secret, auth_provider._source
raise RuntimeError(
f"grant routes require accounts or oidc auth (got {auth_provider._source!r})"
)
def _make_client_secret_gate() -> Callable[[Request], bool]:
"""Build the optional shared-secret check for client-facing endpoints.
Reads the env once at mount (toggling requires a restart, consistent
with the other auth env vars). Open when no secret is configured.
"""
client_secret = os.environ.get(_CLIENT_SECRET_ENV, "").strip() or None
if client_secret is not None:
_logger.info("device-auth: client-secret enforcement enabled")
def _client_secret_ok(request: Request) -> bool:
if client_secret is None:
return True
# Compare on bytes: compare_digest raises TypeError on non-ASCII str
# operands, and ASGI decodes header bytes as latin-1, so a crafted
# non-ASCII header would otherwise 500 instead of cleanly failing.
presented = request.headers.get(_CLIENT_SECRET_HEADER, "")
return hmac.compare_digest(presented.encode("utf-8"), client_secret.encode("utf-8"))
return _client_secret_ok
def issue_login_grant(
device_grant_store: DeviceGrantStore,
*,
user_id: str,
cookie_secret: bytes,
) -> str:
"""Create a redeemed refresh grant for an interactive login.
Called by the login flows (OIDC cli-ticket fulfillment, accounts
``/auth/login``) so the CLI walks away with refresh material and can
renew its access without a human re-running ``omnigent login``. The
interactive login *is* the consent step, so the grant is born
``redeemed`` and tagged with the reserved
:data:`LOGIN_GRANT_CLIENT_ID` which the device-authorize path
refuses, so this authority class can only originate here.
:param device_grant_store: Grant persistence.
:param user_id: The just-authenticated identity.
:param cookie_secret: HMAC key for hashing the refresh token.
:returns: The raw refresh token to hand to the client (stored hashed).
"""
refresh_token = _mint_refresh_token()
device_grant_store.create_redeemed_grant(
secrets.token_urlsafe(24),
user_id=user_id,
client_id=LOGIN_GRANT_CLIENT_ID,
refresh_token_hash=hash_secret(refresh_token, cookie_secret),
created_at=int(time.time()),
)
return refresh_token
def create_oauth_token_router(
auth_provider: UnifiedAuthProvider,
device_grant_store: DeviceGrantStore,
*,
handle_device_code: Callable[[str], Response] | None = None,
client_secret_ok: Callable[[Request], bool] | None = None,
) -> APIRouter:
"""Build the ``/oauth/token`` + ``/oauth/revoke`` router.
The refresh/revoke half of the grant machinery, mountable on its own:
login-issued refresh grants (see :func:`issue_login_grant`) need these
endpoints in **both** accounts and OIDC modes, independent of the
RFC 8628 device-code consent flow (which stays accounts-only behind
``OMNIGENT_DEVICE_GRANT_ENABLED``).
:param auth_provider: The active provider ``accounts`` or ``oidc``.
:param device_grant_store: Persistence for grants.
:param handle_device_code: Optional device-code grant handler.
:func:`create_device_auth_router` passes its polling closure so
the full flow keeps one token endpoint; standalone mounts leave
it ``None`` and ``device_code`` exchanges get
``unsupported_grant_type``.
:param client_secret_ok: Optional client-secret gate (callable that validates
the request). When ``None`` (standalone mounts), builds the gate from
the ``OMNIGENT_DEVICE_CLIENT_SECRET`` env var. When provided
(from :func:`create_device_auth_router`), reuses the gate to avoid
duplication.
:returns: APIRouter to mount at the app root.
"""
cookie_secret, provider_name = _resolve_signing_config(auth_provider)
_client_secret_ok = client_secret_ok or _make_client_secret_gate()
# Resolve grant max lifetime once at mount time (not on every refresh).
_grant_max_lifetime = _grant_max_lifetime_seconds()
router = APIRouter()
# Throttle for the opportunistic grant purge (bounds the table without a
# separate scheduler). Mutable one-field dict so the closures can update
# it. ``0.0`` forces a purge on the first refresh after boot.
_last_purge = {"at": 0.0}
def _maybe_purge() -> None:
"""Purge expired/aged grants at most once per interval.
The device flow purges on ``/oauth/device/authorize``, but the
standalone token router (OIDC, or accounts without the device flow)
has no authorize route so login grants would otherwise accumulate
one row per login. Piggyback the purge on refresh instead.
"""
now_wall = time.time()
if now_wall - _last_purge["at"] < _PURGE_MIN_INTERVAL_SECONDS:
return
_last_purge["at"] = now_wall
try:
device_grant_store.purge_expired(
int(now_wall), max_lifetime_seconds=_grant_max_lifetime
)
except Exception: # noqa: BLE001 — housekeeping must never fail a refresh
_logger.debug("oauth/token: opportunistic grant purge failed", exc_info=True)
def _issue_access_token(grant_id: str, user_id: str, client_id: str) -> str:
# A first-party login grant renews with the SAME authority as the
# session JWT it replaces (scope=None); a third-party device grant
# stays restricted to the delegated allowlist.
scope = None if _is_login_grant(client_id) else DELEGATED_SCOPE
return mint_delegated_token(
user_id,
cookie_secret,
_ACCESS_TOKEN_TTL_SECONDS,
provider_name,
grant_id=grant_id,
client_id=client_id or "",
jti=secrets.token_urlsafe(16),
scope=scope,
)
@router.post("/oauth/token", dependencies=[])
async def token(request: Request) -> Response:
"""Exchange a device_code or refresh_token for an access token.
RFC 8628 / 6749 error shapes: ``authorization_pending``,
``slow_down``, ``expired_token``, ``access_denied``,
``invalid_grant``, ``unsupported_grant_type``.
"""
form = await request.form()
grant_type = str(form.get("grant_type") or "")
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
# The device-code exchange mints from the ephemeral device_code
# alone, so it stays behind the client-secret gate. Refresh
# presents the refresh token itself as the credential, so it is
# not additionally gated (a CLI/host renewing its own login has
# no way to carry the device client secret).
if not _client_secret_ok(request):
return _oauth_error("invalid_client", status_code=401)
if handle_device_code is None:
return _oauth_error("unsupported_grant_type")
return handle_device_code(str(form.get("device_code") or ""))
if grant_type == "refresh_token":
return _handle_refresh_grant(str(form.get("refresh_token") or ""))
return _oauth_error("unsupported_grant_type")
def _handle_refresh_grant(refresh_token: str) -> Response:
if not refresh_token:
return _oauth_error("invalid_request")
# Opportunistic housekeeping so login-grant rows (one per login)
# don't accumulate where no device-authorize purge runs.
_maybe_purge()
presented_hash = hash_secret(refresh_token, cookie_secret)
# A refresh token doesn't name its grant, so locate it by digest.
# Only a live (redeemed, non-revoked) grant holds a matching hash.
grant = device_grant_store.get_by_refresh_hash(presented_hash)
if grant is None:
# Not the current token. If it matches a grant's *previous*
# token, a stale token was replayed — reuse/theft. Revoke the
# whole grant so the attacker's freshly-rotated token dies too.
# (Login grants don't rotate, so they never populate the prev
# hash and can't reach this revoke.)
stale = device_grant_store.get_by_prev_refresh_hash(presented_hash)
if stale is not None:
device_grant_store.revoke(stale.id)
_logger.warning(
"oauth/token: refresh reuse detected on grant %s — revoked", stale.id
)
return _oauth_error("invalid_grant")
# Refuse to refresh a grant past its absolute lifetime — the user
# must re-consent. Checked before rotating so an aged grant simply
# stops working (it is NOT reuse, so it must not revoke/oscillate).
if grant.approved_at is not None and (
int(time.time()) - grant.approved_at >= _grant_max_lifetime
):
return _oauth_error("expired_token")
if grant.user_id is None:
return _oauth_error("invalid_grant")
if _is_login_grant(grant.client_id):
# First-party login grants do NOT rotate. The bearer is an
# unattended host/CLI, so a lost refresh response (network blip,
# crash between the server committing and the client persisting)
# must not brick the grant via reuse detection. The same refresh
# token stays valid for the grant lifetime; only the short-lived
# access token is renewed. Revocation + the absolute lifetime cap
# bound the exposure.
access_token = _issue_access_token(grant.id, grant.user_id, grant.client_id or "")
return JSONResponse(
status_code=200,
content={
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
},
)
new_refresh = _mint_refresh_token()
rotated = device_grant_store.rotate_refresh_token(
grant.id,
expected_hash=presented_hash,
new_hash=hash_secret(new_refresh, cookie_secret),
now_epoch_seconds=int(time.time()),
max_lifetime_seconds=_grant_max_lifetime,
)
if rotated is None:
# Lost a concurrent rotation race, or the grant aged out between
# the check above and here — reject without revoking (this is not
# a reuse signal, so the grant must not be killed/oscillate).
return _oauth_error("invalid_grant")
if rotated.user_id is None:
return _oauth_error("invalid_grant")
access_token = _issue_access_token(
rotated.id,
rotated.user_id,
rotated.client_id or "",
)
return JSONResponse(
status_code=200,
content={
"access_token": access_token,
"refresh_token": new_refresh,
"token_type": "Bearer",
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
},
)
# ── Revocation ────────────────────────────────────────────────
@router.post("/oauth/revoke", dependencies=[])
async def revoke(request: Request) -> Response:
"""Revoke a grant by refresh token or by the caller's access token.
Backs ``/omnigent logout``. Accepts a ``refresh_token`` form
field; falls back to the ``grant_id`` on the caller's own
delegated access token so a client with only its access token
can still log out. Not behind the device client-secret gate: the
presented refresh/access token IS the credential, and a CLI
logging out its own login grant cannot carry that secret.
"""
form = await request.form()
refresh_token = str(form.get("refresh_token") or "")
grant = None
if refresh_token:
grant = device_grant_store.get_by_refresh_hash(
hash_secret(refresh_token, cookie_secret)
)
if grant is None:
grant_id = _grant_id_from_bearer(request)
if grant_id is not None:
grant = device_grant_store.get_by_id(grant_id)
if grant is None:
# Idempotent: nothing to revoke is still "revoked" from the
# caller's perspective. Don't leak which tokens exist.
return JSONResponse(status_code=200, content={"revoked": True})
device_grant_store.revoke(grant.id)
_logger.info("oauth/revoke: revoked grant %s", grant.id)
return JSONResponse(status_code=200, content={"revoked": True})
def _grant_id_from_bearer(request: Request) -> str | None:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
try:
payload = jwt.decode(auth_header[7:], cookie_secret, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None
grant_id = payload.get("grant_id")
return grant_id if isinstance(grant_id, str) else None
return router
def create_device_auth_router(
auth_provider: UnifiedAuthProvider,
device_grant_store: DeviceGrantStore,
@@ -278,28 +647,9 @@ def create_device_auth_router(
base_url = cookie_config.base_url
provider_name = auth_provider._source
# Read the optional client secret once at mount. When set, the
# client-facing endpoints require a matching header; when unset they
# stay public. Captured here (not per-request) so toggling it needs a
# restart — consistent with the other auth env vars.
client_secret = os.environ.get(_CLIENT_SECRET_ENV, "").strip() or None
if client_secret is not None:
_logger.info("device-auth: client-secret enforcement enabled")
def _client_secret_ok(request: Request) -> bool:
"""Return True if the request may use the client-facing endpoints.
Open when no secret is configured; otherwise requires the presented
header to match, compared in constant time to avoid leaking the
secret through timing.
"""
if client_secret is None:
return True
# Compare on bytes: compare_digest raises TypeError on non-ASCII str
# operands, and ASGI decodes header bytes as latin-1, so a crafted
# non-ASCII header would otherwise 500 instead of cleanly failing.
presented = request.headers.get(_CLIENT_SECRET_HEADER, "")
return hmac.compare_digest(presented.encode("utf-8"), client_secret.encode("utf-8"))
_client_secret_ok = _make_client_secret_gate()
# Resolve grant max lifetime once at mount (not on every purge).
_grant_max_lifetime = _grant_max_lifetime_seconds()
router = APIRouter()
_rate_limiter = _SlidingWindowRateLimiter(
@@ -347,7 +697,7 @@ def create_device_auth_router(
_last_purge["at"] = now_wall
try:
device_grant_store.purge_expired(
int(now_wall), max_lifetime_seconds=_GRANT_MAX_LIFETIME_SECONDS
int(now_wall), max_lifetime_seconds=_grant_max_lifetime
)
except Exception: # housekeeping must never fail a request
_logger.exception("device grant purge failed")
@@ -359,6 +709,15 @@ def create_device_auth_router(
if not isinstance(body, dict):
body = {}
client_id = _client_id(body)
# LOGIN_GRANT_CLIENT_ID marks a first-party login grant, whose
# refreshed tokens carry full session authority. It is reserved:
# a device client must never be able to self-declare into that
# class by naming it here.
if _is_login_grant(client_id):
_logger.warning(
"device/authorize: refused reserved client_id %r", LOGIN_GRANT_CLIENT_ID
)
return _oauth_error("invalid_request")
device_code = secrets.token_urlsafe(32)
grant_id = secrets.token_urlsafe(16)
@@ -543,26 +902,10 @@ def create_device_auth_router(
device_grant_store.deny(grant.id)
return HTMLResponse(_consent_html(denied=True), status_code=200)
# ── Token endpoint (client polling + refresh) ─────────────────
@router.post("/oauth/token", dependencies=[])
async def token(request: Request) -> Response:
"""Exchange a device_code or refresh_token for an access token.
RFC 8628 / 6749 error shapes: ``authorization_pending``,
``slow_down``, ``expired_token``, ``access_denied``,
``invalid_grant``, ``unsupported_grant_type``.
"""
if not _client_secret_ok(request):
return _oauth_error("invalid_client", status_code=401)
form = await request.form()
grant_type = str(form.get("grant_type") or "")
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
return _handle_device_code_grant(str(form.get("device_code") or ""))
if grant_type == "refresh_token":
return _handle_refresh_grant(str(form.get("refresh_token") or ""))
return _oauth_error("unsupported_grant_type")
# ── Token + revocation endpoints ──────────────────────────────
# Shared with the standalone login-grant mount: the device flow's
# only addition is the device_code grant handler, injected here so
# /oauth/token stays a single registration either way.
def _handle_device_code_grant(device_code: str) -> Response:
if not device_code:
@@ -613,103 +956,14 @@ def create_device_auth_router(
},
)
def _handle_refresh_grant(refresh_token: str) -> Response:
if not refresh_token:
return _oauth_error("invalid_request")
presented_hash = hash_secret(refresh_token, cookie_secret)
# A refresh token doesn't name its grant, so locate it by digest.
# Only a live (redeemed, non-revoked) grant holds a matching hash.
grant = device_grant_store.get_by_refresh_hash(presented_hash)
if grant is None:
# Not the current token. If it matches a grant's *previous*
# token, a stale token was replayed — reuse/theft. Revoke the
# whole grant so the attacker's freshly-rotated token dies too.
stale = device_grant_store.get_by_prev_refresh_hash(presented_hash)
if stale is not None:
device_grant_store.revoke(stale.id)
_logger.warning(
"oauth/token: refresh reuse detected on grant %s — revoked", stale.id
)
return _oauth_error("invalid_grant")
# Refuse to refresh a grant past its absolute lifetime — the user
# must re-consent. Checked before rotating so an aged grant simply
# stops working (it is NOT reuse, so it must not revoke/oscillate).
if grant.approved_at is not None and (
int(time.time()) - grant.approved_at >= _GRANT_MAX_LIFETIME_SECONDS
):
return _oauth_error("expired_token")
new_refresh = _mint_refresh_token()
rotated = device_grant_store.rotate_refresh_token(
grant.id,
expected_hash=presented_hash,
new_hash=hash_secret(new_refresh, cookie_secret),
now_epoch_seconds=int(time.time()),
max_lifetime_seconds=_GRANT_MAX_LIFETIME_SECONDS,
router.include_router(
create_oauth_token_router(
auth_provider,
device_grant_store,
handle_device_code=_handle_device_code_grant,
client_secret_ok=_client_secret_ok,
)
if rotated is None:
# Lost a concurrent rotation race, or the grant aged out between
# the check above and here — reject without revoking (this is not
# a reuse signal, so the grant must not be killed/oscillate).
return _oauth_error("invalid_grant")
if rotated.user_id is None:
return _oauth_error("invalid_grant")
access_token = _issue_access_token(
rotated.id,
rotated.user_id,
rotated.client_id or "",
)
return JSONResponse(
status_code=200,
content={
"access_token": access_token,
"refresh_token": new_refresh,
"token_type": "Bearer",
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
},
)
# ── Revocation ────────────────────────────────────────────────
@router.post("/oauth/revoke", dependencies=[])
async def revoke(request: Request) -> Response:
"""Revoke a grant by refresh token or by the caller's access token.
Backs ``/omnigent logout``. Accepts a ``refresh_token`` form
field; falls back to the ``grant_id`` on the caller's own
delegated access token so a client with only its access token
can still log out.
"""
if not _client_secret_ok(request):
return _oauth_error("invalid_client", status_code=401)
form = await request.form()
refresh_token = str(form.get("refresh_token") or "")
grant = None
if refresh_token:
grant = device_grant_store.get_by_refresh_hash(
hash_secret(refresh_token, cookie_secret)
)
if grant is None:
grant_id = _grant_id_from_bearer(request)
if grant_id is not None:
grant = device_grant_store.get_by_id(grant_id)
if grant is None:
# Idempotent: nothing to revoke is still "revoked" from the
# caller's perspective. Don't leak which tokens exist.
return JSONResponse(status_code=200, content={"revoked": True})
device_grant_store.revoke(grant.id)
_logger.info("oauth/revoke: revoked grant %s", grant.id)
return JSONResponse(status_code=200, content={"revoked": True})
def _grant_id_from_bearer(request: Request) -> str | None:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
try:
payload = jwt.decode(auth_header[7:], cookie_secret, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None
grant_id = payload.get("grant_id")
return grant_id if isinstance(grant_id, str) else None
)
return router
+17 -2
View File
@@ -681,7 +681,7 @@ def create_hosts_router(
request: Request,
host_id: str,
harness: str,
) -> dict[str, list[dict[str, Any]]]:
) -> dict[str, list[Any]]:
"""Return pre-launch model choices resolved by the selected host.
A preview of the host's ambient default catalog, not a binding
@@ -709,7 +709,22 @@ def create_hosts_router(
detail=str(result.get("error") or "host model-options lookup failed"),
)
models = result.get("models")
return {"models": models if isinstance(models, list) else []}
routable = result.get("routable_models")
payload: dict[str, Any] = {
"models": models if isinstance(models, list) else [],
# Every id the harness's endpoint routes: the picker names one
# row per model, while a launch takes an exact id.
"routable_models": (
[m for m in routable if isinstance(m, str)] if isinstance(routable, list) else []
),
}
# An honest empty answer carries the reason (e.g. "the codex model
# probe failed — see the host log") so the picker can say WHY it is
# empty instead of a generic "Models unavailable".
error = result.get("error")
if isinstance(error, str) and error:
payload["error"] = error
return payload
@router.post("/hosts/{host_id}/runners")
async def launch_runner(
@@ -52,6 +52,7 @@ class CreateScheduledTaskRequest(BaseModel):
timezone: str = "UTC"
model_override: str | None = None
reasoning_effort: str | None = None
max_cost_usd: float | None = Field(default=None, gt=0)
# Optional: no PINNED host/workspace. When both are unset the fire path
# resolves the owner's online host at fire time and defaults the workspace to
# that host's home directory (a failed run is recorded if none is online) —
@@ -74,6 +75,7 @@ class UpdateScheduledTaskRequest(BaseModel):
timezone: str | None = None
model_override: str | None = None
reasoning_effort: str | None = None
max_cost_usd: float | None = Field(default=None, gt=0) # null clears the cap
workspace: str | None = Field(default=None, min_length=1)
host_id: str | None = Field(default=None, min_length=1)
state: str | None = None
@@ -121,6 +123,7 @@ def _to_response(
"created_at": task.created_at,
"model_override": task.model_override,
"reasoning_effort": task.reasoning_effort,
"max_cost_usd": task.max_cost_usd,
"workspace": task.workspace,
"host_id": task.host_id,
"state": task.state,
@@ -311,6 +314,7 @@ def create_scheduled_tasks_router(
timezone=body.timezone,
model_override=model_override,
reasoning_effort=reasoning_effort,
max_cost_usd=body.max_cost_usd,
workspace=workspace,
host_id=body.host_id,
)
@@ -187,6 +187,8 @@ from omnigent.server.routes._sessions.common import (
_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S as _CLAUDE_NATIVE_MESSAGE_TIMEOUT_S,
_CLAUDE_NATIVE_MODEL as _CLAUDE_NATIVE_MODEL,
_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S as _CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S,
_CLAUDE_NATIVE_PERMISSION_MODES as _CLAUDE_NATIVE_PERMISSION_MODES,
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY as _CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS as _CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS,
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY as _CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY,
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE as _CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
@@ -495,6 +497,7 @@ from omnigent.server.routes._sessions.helpers import (
_require_declared_subagent as _require_declared_subagent,
_require_external_status_forward as _require_external_status_forward,
_require_host_conn_for_worktree as _require_host_conn_for_worktree,
_require_permission_mode_forward as _require_permission_mode_forward,
_reset_runner_resources_after_switch_impl as _reset_runner_resources_after_switch_impl,
_resolve_llm_model as _resolve_llm_model,
_resolve_skill_meta_text_via_runner as _resolve_skill_meta_text_via_runner,
@@ -94,9 +94,12 @@ from omnigent.server.routes._content_type import (
from omnigent.server.routes._errors import session_not_found as _session_not_found
from omnigent.server.routes._origin import require_trusted_origin
from omnigent.server.routes._sessions.common import (
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
_CLAUDE_NATIVE_PERMISSION_MODES,
_CLAUDE_NATIVE_UI_LABEL_KEY,
_CLAUDE_NATIVE_UI_LABEL_VALUE,
_CLAUDE_NATIVE_WRAPPER_LABEL_KEY,
_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE,
_CODEX_NATIVE_COLLABORATION_MODE_LABEL_KEY,
_CODEX_NATIVE_COLLABORATION_MODES,
_CODEX_NATIVE_WRAPPER_LABEL_VALUE,
@@ -125,12 +128,14 @@ from omnigent.server.routes._sessions.helpers import (
_presentation_labels_for_agent,
_prune_session_read_state,
_publish_collaboration_mode,
_publish_permission_mode,
_publish_sandbox_status,
_publish_terminal_pending,
_reject_reserved_cost_control_label_seed,
_reject_server_reserved_label_seed,
_require_collaboration_mode_forward,
_require_cost_control_label_authority,
_require_permission_mode_forward,
_reset_runner_resources_after_switch,
_same_provider_family,
_session_status_from_cache,
@@ -1598,6 +1603,34 @@ def register_core_routes(
code=ErrorCode.INVALID_INPUT,
)
requested_codex_collaboration_mode = body.collaboration_mode
permission_mode_requested = "permission_mode" in body.model_fields_set
requested_claude_permission_mode: str | None = None
if permission_mode_requested:
if body.permission_mode is None:
raise OmnigentError(
"permission_mode must be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
if body.permission_mode not in _CLAUDE_NATIVE_PERMISSION_MODES:
raise OmnigentError(
f"permission_mode must be one of {sorted(_CLAUDE_NATIVE_PERMISSION_MODES)}",
code=ErrorCode.INVALID_INPUT,
)
conv_for_permission_mode = await asyncio.to_thread(
conversation_store.get_conversation,
session_id,
)
if conv_for_permission_mode is None:
raise _session_not_found()
if (
conv_for_permission_mode.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
!= _CLAUDE_NATIVE_WRAPPER_LABEL_VALUE
):
raise OmnigentError(
"permission_mode is only supported for claude-native sessions",
code=ErrorCode.INVALID_INPUT,
)
requested_claude_permission_mode = body.permission_mode
labels_to_set = dict(body.labels or {})
# Pins are per-user. The client writes the canonical ``omnigent.pinned``
# key; rewrite it to the caller's per-user key so one user's pin doesn't
@@ -1876,6 +1909,24 @@ def register_core_routes(
_codex_plan_enabled,
_runner_result,
)
if requested_claude_permission_mode is not None and live_forward:
_mode_result = await _forward_session_change_to_runner(
session_id,
runner_router,
{
"type": "permission_mode_change",
"permission_mode": requested_claude_permission_mode,
},
)
# Raises unless the runner confirms the switch, so the label can
# never claim a mode Claude isn't in. Stores the mode it reached.
labels_to_set[_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY] = (
_require_permission_mode_forward(
session_id,
requested_claude_permission_mode,
_mode_result,
)
)
# Some labels are cleared by DELETE, not by upserting an empty value:
# the project membership (empty = "remove from project") and the pinned
# flag (empty = "unpin"). Split any empty-valued clear keys out before
@@ -1889,6 +1940,13 @@ def register_core_routes(
await asyncio.to_thread(conversation_store.delete_label, session_id, _clear_key)
if labels_to_set:
await asyncio.to_thread(conversation_store.set_labels, session_id, labels_to_set)
# Only when the switch was forwarded: a silent PATCH writes no label,
# and an unconfirmed mode must not reach the picker.
if _CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY in labels_to_set:
_publish_permission_mode(
session_id,
labels_to_set[_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY],
)
# Archiving means "get this out of my way", which contradicts a pin
# ("keep it at the top"), so drop the archiver's own pin — otherwise the
# session lingers as a pinned row if later unarchived. Runs after the
@@ -93,6 +93,7 @@ from omnigent.server.routes._sessions.common import (
_EXTERNAL_MODEL_OPTIONS_TYPE,
_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE,
_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE,
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
_EXTERNAL_SESSION_INTERRUPTED_TYPE,
_EXTERNAL_SESSION_STATUS_TYPE,
@@ -140,6 +141,7 @@ from omnigent.server.routes._sessions.helpers import (
_persist_external_codex_collaboration_mode_change,
_persist_external_model_change,
_persist_external_model_options,
_persist_external_permission_mode_change,
_persist_external_reasoning_effort_change,
_persist_external_session_title,
_persist_external_subagent_start,
@@ -538,6 +540,7 @@ def register_events_routes(
_EXTERNAL_MCP_STARTUP_TYPE,
_EXTERNAL_MODEL_CHANGE_TYPE,
_EXTERNAL_MODEL_OPTIONS_TYPE,
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
_EXTERNAL_SESSION_TITLE_TYPE,
_EXTERNAL_SESSION_TODOS_TYPE,
@@ -1186,6 +1189,14 @@ def register_events_routes(
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_PERMISSION_MODE_CHANGE_TYPE:
await _persist_external_permission_mode_change(
session_id,
conv,
body,
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_TITLE_TYPE:
await _persist_external_session_title(
session_id,
+21 -15
View File
@@ -32,10 +32,12 @@ to resolving the terminal in the local registry.
Wire protocol on the WebSocket
------------------------------
- **Server client**: every PTY read is forwarded as a *binary*
WebSocket frame. xterm.js's ``term.write()`` accepts ``Uint8Array``
directly and runs it through its ANSI parser, so colors, cursor
motion, alternate screen, mouse modes all work transparently.
- **Server client**: terminal output is forwarded as *binary* WebSocket
frames. xterm.js's ``term.write()`` accepts ``Uint8Array`` directly and runs
it through its ANSI parser, so colors, cursor motion, alternate screen, and
mouse modes work transparently. Control-mode attaches may also send a text
``clipboard-write`` JSON frame after tmux reports a copy-mode selection; PTY
attaches advertise whether OSC 52 is safe before terminal output begins.
- **Client server**:
- **Text frames** are JSON control messages:
``{"type": "resize", "cols": N, "rows": M}``. Parsed and applied
@@ -274,17 +276,21 @@ def create_terminal_attach_router(
"terminal.transport": resolved_transport,
},
):
bridge = (
bridge_tmux_control_to_websocket
if resolved_transport == TERMINAL_TRANSPORT_CONTROL
else bridge_tmux_pty_to_websocket
)
await bridge(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
)
if resolved_transport == TERMINAL_TRANSPORT_CONTROL:
await bridge_tmux_control_to_websocket(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
)
else:
await bridge_tmux_pty_to_websocket(
websocket,
socket_path=str(entry.instance.socket_path),
tmux_target=entry.instance.tmux_target,
read_only=read_only,
allow_osc52_clipboard=not entry.instance.tmux_allow_passthrough,
)
return router
+35
View File
@@ -120,6 +120,7 @@ class FireDeps:
permission_store: Any | None
host_store: Any | None
host_registry: Any | None
policy_store: Any | None = None
agent_cache: Any | None = None
runner_router: Any | None = None
tunnel_registry: Any | None = None
@@ -425,6 +426,8 @@ async def _run_fire_for_task(
)
return
await _attach_cost_budget(deps, task, conv.id)
try:
await _grant_owner(deps, task, conv.id)
except Exception:
@@ -607,6 +610,38 @@ async def _create_session(deps: FireDeps, task: ScheduledTask) -> Conversation:
return conv
_COST_BUDGET_HANDLER = "omnigent.policies.builtins.cost.cost_budget"
_COST_BUDGET_POLICY_NAME = "__scheduled_task_cost_budget"
async def _attach_cost_budget(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None:
"""Attach a cost_budget policy to a session spawned by a scheduled task.
Non-fatal: a failure logs a warning but does not fail the fire an
uncapped session is better than a dead run.
"""
if task.max_cost_usd is None or deps.policy_store is None:
return
try:
await asyncio.to_thread(
deps.policy_store.create,
policy_id=_new_id(),
session_id=conversation_id,
name=_COST_BUDGET_POLICY_NAME,
type="python",
handler=_COST_BUDGET_HANDLER,
factory_params={"max_cost_usd": task.max_cost_usd},
enabled=True,
)
except Exception: # noqa: BLE001
_logger.warning(
"scheduled fire: failed to attach cost budget for task %s (session %s)",
task.id,
conversation_id,
exc_info=True,
)
async def _grant_owner(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None:
"""Write the LEVEL_OWNER grant so the run is visible to its owner.
+54 -17
View File
@@ -1485,7 +1485,7 @@ class SessionCreateMetadata(BaseModel):
:param reasoning_effort: Optional per-session reasoning-effort
hint. Accepted metadata values are ``"none"``,
``"minimal"``, ``"low"``, ``"medium"``, ``"high"``,
``"xhigh"``, and ``"max"``. Provider-specific support is
``"xhigh"``, ``"max"``, and ``"ultra"``. Provider-specific support is
validated when a turn executes. ``None`` means use the agent
default.
:param host_id: Optional host to launch the runner on, e.g.
@@ -1720,10 +1720,13 @@ class SessionResponse(BaseModel):
permission level on this session: ``1`` = read, ``2`` =
edit, ``3`` = manage. ``None`` when permissions are
disabled (single-user mode without a permission store).
:param llm_model: The LLM model identifier from the bound
agent's spec, e.g. ``"anthropic/claude-sonnet-4-6"``.
``None`` when the agent has no explicit ``llm:`` block or
the agent cannot be looked up.
:param llm_model: The model this session is actually on. When the
harness has reported one (``reported_model``, written by
``external_model_change``), that verbatim value serves here
and is the only model value clients display; otherwise the
bound agent spec's model, e.g.
``"anthropic/claude-sonnet-4-6"``. ``None`` when neither
exists.
:param harness: The bound agent's canonical harness, e.g.
``"claude-sdk"`` or ``"openai-agents"``. Lets the client
render the active credential for the correct provider
@@ -1977,6 +1980,15 @@ class UpdateSessionRequest(BaseModel):
``"plan"`` enters Plan mode and ``"default"`` returns to Default
mode for subsequent Codex turns. Only valid for sessions stamped
with the codex-native wrapper label. Omitted leaves unchanged.
:param permission_mode: Claude-native permission mode to switch a
running session to, e.g. ``"auto"``. Only the modes Claude Code's
shift+tab cycle can reach are accepted (``default``,
``acceptEdits``, ``plan``, ``auto``) ``dontAsk`` and
``bypassPermissions`` are launch-only. Only valid for sessions
stamped with the claude-native wrapper label. Unlike the other
fields here the switch is applied by the live TUI, so a failure
to reach the mode is surfaced as an error rather than persisted.
Omitted leaves unchanged.
:param cost_control_mode_override: Per-session cost-control
switch: ``"on"`` activates the spec's configured cost-control
mode, ``"off"`` disables cost control for this session.
@@ -2034,6 +2046,7 @@ class UpdateSessionRequest(BaseModel):
reasoning_effort: str | None = None
model_override: str | None = None
collaboration_mode: str | None = None
permission_mode: str | None = None
cost_control_mode_override: str | None = None
subagent_routing_override: str | None = None
external_session_id: str | None = None
@@ -2725,24 +2738,24 @@ class SessionUsageEvent(_SSEEventBase):
class SessionModelEvent(_SSEEventBase):
"""
Active-model update from a terminal-backed integration.
Active-model report from a terminal-backed integration.
Emitted after an ``external_model_change`` POST from the
``omnigent claude`` transcript forwarder when the model is
switched inside the Claude Code terminal (a ``/model`` command or
the in-TUI picker). Lets the web model picker reflect a TUI-side
switch without a reload.
Emitted after an ``external_model_change`` POST from a native
forwarder the launch's own model report, or a switch made inside
the pane (a ``/model`` command or the in-TUI picker). Every surface
re-renders its model display from this.
:param type: Always ``"session.model"``.
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
:param model: Tier alias the session is now on, e.g. ``"opus"``
Claude Code's version-agnostic alias, matching the picker's
vocabulary (not a pinned ``"claude-opus-4-8"`` id).
:param model: The model the harness reports the session is on,
VERBATIM in the harness's own spelling, e.g.
``"claude-opus-4-8[1m]"`` or ``"gpt-5.6-luna"`` never
collapsed to a picker alias.
Category: **transient** (SSE-only). The server also writes
``model_override`` on the conversation, so on reconnect clients
restore the selection from the snapshot's ``model_override`` rather
than from a replayed event.
``reported_model`` on the conversation (served on the snapshot's
``llm_model``), so on reconnect clients restore the display from
the snapshot rather than from a replayed event.
"""
type: Literal["session.model"]
@@ -2821,6 +2834,29 @@ class SessionCollaborationModeEvent(_SSEEventBase):
mode: str
class SessionPermissionModeEvent(_SSEEventBase):
"""
Active permission-mode update from a claude-native session.
Emitted after the web UI switches the mode, and after the Claude forwarder
observes a different mode in the pane footer a shift+tab pressed inside
the TUI, which Omnigent has no other way to see. Lets the composer's mode
picker track the pane without a reload.
:param type: Always ``"session.permission_mode"``.
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
:param permission_mode: The active mode, e.g. ``"auto"`` or ``"plan"``.
Category: **transient** (SSE-only). The server also writes
``omnigent.claude_native.permission_mode`` on the conversation labels, so
reconnecting clients restore the same state from the session snapshot.
"""
type: Literal["session.permission_mode"]
conversation_id: str
permission_mode: str
class SessionAgentChangedEvent(_SSEEventBase):
"""
Bound-agent change on a live session.
@@ -4213,6 +4249,7 @@ ServerStreamEvent = Annotated[
| SessionTitleEvent
| SessionReasoningEffortEvent
| SessionCollaborationModeEvent
| SessionPermissionModeEvent
| SessionAgentChangedEvent
| SessionTodosEvent
| SessionTerminalPendingEvent
@@ -776,6 +776,7 @@ class ConversationStore(ABC):
_unset_harness_override: bool = False,
terminal_launch_args: list[str] | None = None,
archived: bool | None = None,
reported_model: str | None = None,
) -> Conversation | None:
"""
Update mutable fields on a conversation.
@@ -785,7 +786,9 @@ class ConversationStore(ABC):
and ``harness_override``,
``None`` means "leave unchanged". To explicitly clear them
back to ``None``, pass
the matching ``_unset_*`` flag.
the matching ``_unset_*`` flag. ``reported_model`` (the model
the harness last reported, verbatim) has no ``_unset`` variant:
reports only ever move forward.
:param conversation_id: Unique conversation identifier,
e.g. ``"conv_abc123"``.
@@ -118,6 +118,7 @@ class _RowCountResult(Protocol):
_SESSION_OVERRIDE_KEYS = (
"reasoning_effort",
"model_override",
"reported_model",
"cost_control_mode_override",
"subagent_routing_override",
"harness_override",
@@ -209,6 +210,7 @@ def _to_conversation(
session_usage=session_usage,
reasoning_effort=overrides["reasoning_effort"],
model_override=overrides["model_override"],
reported_model=overrides["reported_model"],
cost_control_mode_override=overrides["cost_control_mode_override"],
subagent_routing_override=overrides["subagent_routing_override"],
harness_override=overrides["harness_override"],
@@ -2697,6 +2699,7 @@ class SqlAlchemyConversationStore(ConversationStore):
_unset_harness_override: bool = False,
terminal_launch_args: list[str] | None = None,
archived: bool | None = None,
reported_model: str | None = None,
) -> Conversation | None:
"""
Update mutable fields on a conversation.
@@ -2708,10 +2711,15 @@ class SqlAlchemyConversationStore(ConversationStore):
e.g. ``"high"``. ``None`` leaves unchanged.
:param _unset_reasoning_effort: When ``True``, clear
``reasoning_effort`` to ``None``.
:param model_override: Per-session LLM model override,
e.g. ``"claude-opus-4-7"``. ``None`` leaves unchanged.
:param model_override: Per-session LLM model override the
user's request, e.g. ``"claude-opus-4-7"``. ``None``
leaves unchanged.
:param _unset_model_override: When ``True``, clear
``model_override`` to ``None``.
:param reported_model: The model the harness last reported the
session is actually on, verbatim, e.g.
``"claude-opus-4-8[1m]"``. ``None`` leaves unchanged.
No ``_unset`` variant reports only ever move forward.
:param cost_control_mode_override: Per-session cost-control
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
:param _unset_cost_control_mode_override: When ``True``, clear
@@ -2764,6 +2772,9 @@ class SqlAlchemyConversationStore(ConversationStore):
elif model_override is not None:
overrides["model_override"] = model_override
overrides_changed = True
if reported_model is not None:
overrides["reported_model"] = reported_model
overrides_changed = True
if _unset_cost_control_mode_override:
overrides["cost_control_mode_override"] = None
overrides_changed = True
@@ -50,6 +50,7 @@ class ScheduledTaskStore(ABC):
*,
model_override: str | None = None,
reasoning_effort: str | None = None,
max_cost_usd: float | None = None,
workspace: str | None = None,
host_id: str | None = None,
state: str = "active",
@@ -68,6 +69,7 @@ class ScheduledTaskStore(ABC):
:param timezone: IANA timezone the trigger is evaluated in.
:param model_override: Optional LLM model override.
:param reasoning_effort: Optional reasoning-effort hint.
:param max_cost_usd: Optional per-firing cost budget in USD.
:param workspace: Runner start path (source repo / working dir).
:param host_id: The connected host to pin the run to.
:param state: Lifecycle state ``active``/``paused``/``deleted``.
@@ -131,6 +133,7 @@ class ScheduledTaskStore(ABC):
timezone: str | None = None,
model_override: str | None = None,
reasoning_effort: str | None = None,
max_cost_usd: float | None = _UNSET,
workspace: str | None = None,
host_id: str | None = _UNSET,
state: str | None = None,
@@ -53,6 +53,7 @@ def _to_entity(row: SqlScheduledTask) -> ScheduledTask:
rrule=row.rrule,
model_override=row.model_override,
reasoning_effort=row.reasoning_effort,
max_cost_usd=row.max_cost_usd,
workspace=row.workspace,
base_branch=row.base_branch,
execution_target=decode_scheduled_task_execution_target(row.execution_target),
@@ -127,6 +128,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
*,
model_override: str | None = None,
reasoning_effort: str | None = None,
max_cost_usd: float | None = None,
workspace: str | None = None,
host_id: str | None = None,
state: str = "active",
@@ -142,6 +144,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
timezone=timezone,
model_override=model_override,
reasoning_effort=reasoning_effort,
max_cost_usd=max_cost_usd,
workspace=workspace,
base_branch=None,
execution_target=encode_scheduled_task_execution_target("connected_host"),
@@ -246,6 +249,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
timezone: str | None = None,
model_override: str | None = None,
reasoning_effort: str | None = None,
max_cost_usd: float | None = _UNSET,
workspace: str | None = None,
host_id: str | None = _UNSET,
state: str | None = None,
@@ -254,11 +258,11 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
) -> ScheduledTask | None:
"""Update mutable fields.
``None`` leaves most fields unchanged. For ``host_id`` and
``last_run_conversation_id``, the sentinel default means "not provided
/ leave unchanged"; passing ``None`` explicitly sets the column to NULL.
Passing ``rrule`` updates the recurring trigger; ``None``
leaves it unchanged.
``None`` leaves most fields unchanged. For ``host_id``,
``max_cost_usd``, and ``last_run_conversation_id``, the sentinel
default means "not provided / leave unchanged"; passing ``None``
explicitly sets the column to NULL. Passing ``rrule`` updates the
recurring trigger; ``None`` leaves it unchanged.
"""
with self._session("update_task") as session:
row = session.get(SqlScheduledTask, (current_workspace_id(), scheduled_task_id))
@@ -283,6 +287,9 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
if reasoning_effort is not None and row.reasoning_effort != reasoning_effort:
row.reasoning_effort = reasoning_effort
changed = True
if max_cost_usd is not _UNSET and row.max_cost_usd != max_cost_usd:
row.max_cost_usd = max_cost_usd
changed = True
if workspace is not None and row.workspace != workspace:
row.workspace = workspace
changed = True
+170 -9
View File
@@ -28,10 +28,11 @@ Design notes learned from the protocol (see ``control_bridge`` spike):
the client exits; the hex channel is byte-exact for ESC sequences, control
chars, and UTF-8 multibyte alike.
The browser-facing wire protocol is identical to the PTY bridge (binary frames
out = raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary
frames in = input bytes), so the two transports are interchangeable behind the
same ``/attach`` WebSocket and a client cannot tell which one served it.
The browser-facing terminal stream matches the PTY bridge (binary frames out =
raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary frames
in = input bytes). Control mode additionally sends a typed text JSON frame when
tmux reports a copied paste buffer, because its outer-client OSC 52 is not part
of ``%output``. Both remain interchangeable behind the same ``/attach`` URL.
Known limitation vs the PTY bridge: tmux's own overlays (``display-popup``,
copy-mode, status line) are NOT delivered to a control-mode client, so the
@@ -47,6 +48,7 @@ attach transport, so they behave identically under either bridge.
from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import logging
@@ -117,6 +119,21 @@ _CONTROL_STDOUT_BUFFER_LIMIT: Final[int] = 16 * 1024 * 1024
# stuck-slow client can't hang the close; a normal drain completes well within.
_FORWARD_DRAIN_TIMEOUT_S: Final[float] = 5.0
# tmux emits this control notification after copy-mode stores a selection in a
# paste buffer. Only default-style, shell-safe names are accepted; copy-mode's
# generated names (for example ``buffer0``) are covered without letting an
# untrusted protocol line select an arbitrary command target.
_CLIPBOARD_BUFFER_CHANGED_PREFIX: Final = b"%paste-buffer-changed "
_CLIPBOARD_BUFFER_NAME_RE: Final = re.compile(rb"[A-Za-z0-9_.:-]{1,128}\Z")
# Browser clipboard writes should stay text-sized. Bound the raw buffer before
# base64/JSON expansion so a huge tmux buffer cannot become a websocket DoS.
_CLIPBOARD_MAX_BYTES: Final[int] = 1024 * 1024
_CLIPBOARD_READ_TIMEOUT_S: Final[float] = 2.0
# A copy-mode commit follows the initiating key or mouse release immediately.
# Correlating the notification with this client's recent input prevents one
# attached browser from overwriting every other viewer's local clipboard.
_CLIPBOARD_RECENT_INPUT_WINDOW_S: Final[float] = 5.0
def unescape_control_output(value: bytes) -> bytes:
"""Un-escape a ``%output`` value back to raw pane bytes.
@@ -133,6 +150,79 @@ def unescape_control_output(value: bytes) -> bytes:
return _OCTAL_ESCAPE_RE.sub(lambda m: bytes([int(m.group(1), 8)]), value)
async def _read_tmux_buffer(
tmux: str,
socket_path: str,
buffer_name: str,
) -> bytes | None:
"""Read one named tmux buffer exactly, rejecting failures and oversized data.
``save-buffer ... -`` writes the raw bytes without ``show-buffer``'s display
formatting. ``readexactly(limit + 1)`` distinguishes an in-range buffer
(EOF with a partial result) from an oversized one without first buffering
an unbounded subprocess result in Python.
:param tmux: Absolute tmux executable path.
:param socket_path: Private tmux server socket.
:param buffer_name: Validated tmux buffer name, e.g. ``"buffer0"``.
:returns: Raw buffer bytes, or ``None`` when unavailable/oversized.
"""
try:
proc = await asyncio.create_subprocess_exec(
tmux,
"-S",
socket_path,
"save-buffer",
"-b",
buffer_name,
"-",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except (OSError, ValueError):
return None
assert proc.stdout is not None
try:
try:
data = await asyncio.wait_for(
proc.stdout.readexactly(_CLIPBOARD_MAX_BYTES + 1),
timeout=_CLIPBOARD_READ_TIMEOUT_S,
)
oversized = True
except asyncio.IncompleteReadError as exc:
data = exc.partial
oversized = False
if oversized:
with contextlib.suppress(ProcessLookupError):
proc.kill()
await asyncio.wait_for(proc.wait(), timeout=_CLIPBOARD_READ_TIMEOUT_S)
except asyncio.CancelledError:
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await asyncio.shield(proc.wait())
raise
except (asyncio.TimeoutError, OSError):
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
return None
if oversized or proc.returncode != 0:
return None
return data
def _clipboard_buffer_name(line: bytes) -> str | None:
"""Extract a safe buffer name from a tmux clipboard notification."""
if not line.startswith(_CLIPBOARD_BUFFER_CHANGED_PREFIX):
return None
raw_name = line[len(_CLIPBOARD_BUFFER_CHANGED_PREFIX) :]
if _CLIPBOARD_BUFFER_NAME_RE.fullmatch(raw_name) is None:
return None
return raw_name.decode("ascii")
def _hex_send_keys_commands(target: str, data: bytes) -> list[bytes]:
"""Build ``send-keys -H`` control-mode command line(s) for raw input bytes.
@@ -404,7 +494,8 @@ async def bridge_tmux_control_to_websocket(
Drop-in alternative to
:func:`omnigent.terminals.ws_bridge.bridge_tmux_pty_to_websocket` with the
same signature and browser wire protocol. Caller must have called
same signature and terminal byte stream. Control mode additionally emits
server-to-browser clipboard JSON frames. Caller must have called
``websocket.accept()``. On exit (any branch) the control client is torn
down and the websocket closed best-effort with the shared 4404/4405 codes.
@@ -476,9 +567,16 @@ async def bridge_tmux_control_to_websocket(
# one bounded ``send_bytes``, so when the browser send lags tmux's firehose
# a backlog of tiny per-line payloads collapses into a few large frames.
output_chunks: asyncio.Queue[bytes | None] = asyncio.Queue()
# Clipboard notifications are handled outside the raw output hot path: each
# item names the immutable tmux buffer created by copy-mode; None is EOF.
clipboard_buffers: asyncio.Queue[str | None] = asyncio.Queue()
# Terminal bytes and clipboard JSON have separate producer tasks but one
# websocket. Serialize sends so ASGI never sees concurrent send calls.
ws_send_lock = asyncio.Lock()
# Monotonic stamp of the last forwarded browser input; the forwarder reads
# it to shrink the frame cap right after a keystroke (keeps the echo on
# xterm's synchronous paint path — see the PTY bridge).
# xterm's synchronous paint path — see the PTY bridge) and clipboard
# forwarding uses it to identify which attached client initiated a copy.
last_client_input_at: float | None = None
def _current_ws_coalesce_limit() -> int:
@@ -512,6 +610,15 @@ async def bridge_tmux_control_to_websocket(
if len(parts) == 3:
output_chunks.put_nowait(unescape_control_output(parts[2]))
return True
buffer_name = _clipboard_buffer_name(line)
if buffer_name is not None:
if (
not read_only
and last_client_input_at is not None
and _monotonic() - last_client_input_at <= _CLIPBOARD_RECENT_INPUT_WINDOW_S
):
clipboard_buffers.put_nowait(buffer_name)
return True
if line.startswith(b"%exit"):
return False
if line.startswith(b"%window-close"):
@@ -549,9 +656,49 @@ async def bridge_tmux_control_to_websocket(
return
finally:
output_chunks.put_nowait(None)
clipboard_buffers.put_nowait(None)
if reader_done is not None:
reader_done.set()
async def _forward_clipboard_updates() -> None:
"""Read copied tmux buffers and send bounded clipboard control frames."""
while True:
buffer_name = await clipboard_buffers.get()
if buffer_name is None:
return
# When several copies arrive before the subprocess starts, only the
# newest clipboard value matters. Preserve an EOF sentinel so the
# task exits after forwarding that final value.
eof_seen = False
while True:
try:
next_name = clipboard_buffers.get_nowait()
except asyncio.QueueEmpty:
break
if next_name is None:
eof_seen = True
break
buffer_name = next_name
data = await _read_tmux_buffer(tmux, socket_path, buffer_name)
if data is not None:
message = json.dumps(
{
"type": "clipboard-write",
"encoding": "base64",
"data": base64.b64encode(data).decode("ascii"),
},
separators=(",", ":"),
)
try:
async with ws_send_lock:
await websocket.send_text(message)
except (RuntimeError, WebSocketDisconnect):
return
if eof_seen:
return
async def _ws_to_control() -> None:
"""Read browser frames; resize via refresh-client -C, input via -H hex."""
nonlocal last_client_input_at
@@ -600,10 +747,16 @@ async def bridge_tmux_control_to_websocket(
read_task = asyncio.create_task(_read_control(), name="tmux-control-read")
forward_task = asyncio.create_task(
_forward_pty_to_ws(
websocket, output_chunks, max_coalesce_bytes=_current_ws_coalesce_limit
websocket,
output_chunks,
max_coalesce_bytes=_current_ws_coalesce_limit,
send_lock=ws_send_lock,
),
name="tmux-control-forward",
)
clipboard_task = asyncio.create_task(
_forward_clipboard_updates(), name="tmux-control-clipboard"
)
if forward_done is not None:
forward_task.add_done_callback(lambda _task: forward_done.set())
ws_task = asyncio.create_task(_ws_to_control(), name="tmux-ws-to-control")
@@ -612,6 +765,9 @@ async def bridge_tmux_control_to_websocket(
# finishing is downstream (it drains, then sees the EOF sentinel).
control_ended_first = False
try:
# The clipboard task is intentionally not a FIRST_COMPLETED trigger: it
# may finish after the reader's EOF sentinel, but the reader itself is
# the authoritative control-side completion signal.
done, pending = await asyncio.wait(
{read_task, forward_task, ws_task}, return_when=asyncio.FIRST_COMPLETED
)
@@ -636,13 +792,18 @@ async def bridge_tmux_control_to_websocket(
await asyncio.wait_for(
asyncio.shield(forward_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
)
for task in pending:
if control_ended_first and not clipboard_task.done():
with contextlib.suppress(Exception):
await asyncio.wait_for(
asyncio.shield(clipboard_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
)
for task in {*pending, clipboard_task}:
if task.done():
continue
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
for task in {read_task, forward_task, ws_task}:
for task in {read_task, forward_task, clipboard_task, ws_task}:
if task.done() and not task.cancelled():
exc = task.exception()
if exc is not None:
+32 -2
View File
@@ -16,7 +16,9 @@ Used by:
Wire protocol (same as the original server route):
- **Server client**: every PTY read becomes a *binary* WS frame.
- **Server client**: every PTY read becomes a *binary* WS frame. Production
attaches first send a text OSC 52 capability frame so the browser can reject
clipboard escapes when tmux passthrough weakens the normal trust boundary.
- **Client server**:
- **Text frames** are JSON control messages. Currently only
``{"type": "resize", "cols": N, "rows": M}`` (applied via
@@ -417,6 +419,7 @@ async def _forward_pty_to_ws(
pty_chunks: asyncio.Queue[bytes | None],
*,
max_coalesce_bytes: int | Callable[[], int] = _WS_COALESCE_MAX_BYTES,
send_lock: asyncio.Lock | None = None,
) -> None:
"""
Forward queued PTY output to *websocket*, coalescing ready chunks.
@@ -440,6 +443,9 @@ async def _forward_pty_to_ws(
:data:`_WS_COALESCE_MAX_BYTES`; ``bridge_tmux_pty_to_websocket``
supplies a callable so recently-typed redraws can use the smaller
interactive cap while normal output keeps the larger flood cap.
:param send_lock: Optional lock shared with another server-to-browser
sender. Control mode uses it to serialize terminal bytes with clipboard
control frames; PTY mode has only this sender and leaves it unset.
:returns: None on EOF or websocket disconnect.
"""
pending = bytearray()
@@ -468,7 +474,11 @@ async def _forward_pty_to_ws(
frame = bytes(pending[:limit])
del pending[:limit]
try:
await websocket.send_bytes(frame)
if send_lock is None:
await websocket.send_bytes(frame)
else:
async with send_lock:
await websocket.send_bytes(frame)
except (RuntimeError, WebSocketDisconnect):
return
if eof_seen:
@@ -517,6 +527,7 @@ async def bridge_tmux_pty_to_websocket(
tmux_target: str,
read_only: bool,
on_client_interaction: Callable[[], None] | None = None,
allow_osc52_clipboard: bool | None = None,
) -> None:
"""
Bridge a tmux attach PTY to an already-accepted *websocket*.
@@ -545,12 +556,31 @@ async def bridge_tmux_pty_to_websocket(
mis-reading them as agent activity. ``None`` (e.g. the
server-direct attach path, which is out-of-process from the
watcher) disables that attribution.
:param allow_osc52_clipboard: Whether the browser may honor OSC 52 from this
PTY. Production passes ``False`` when tmux passthrough is enabled, since
pane output could otherwise bypass ``set-clipboard external``. ``None``
omits the capability frame for low-level test compatibility.
"""
# Attaching is itself a client interaction: tmux resizes the window to
# the new client, which reflows the pane. Stamp it before the bridge
# starts so that reflow is discounted.
if on_client_interaction is not None:
on_client_interaction()
if allow_osc52_clipboard is not None:
try:
await websocket.send_text(
json.dumps(
{
"type": "osc52-clipboard-capability",
"enabled": allow_osc52_clipboard and not read_only,
},
separators=(",", ":"),
)
)
except (RuntimeError, WebSocketDisconnect):
return
argv = ["tmux", "-S", socket_path, "attach"]
if read_only:
argv.append("-r")
@@ -93,6 +93,14 @@ class SysScheduledTaskCreateTool(Tool):
"type": "string",
"description": "Optional per-run reasoning-effort hint, e.g. 'high'.",
},
"max_cost_usd": {
"type": "number",
"description": (
"Optional per-firing cost budget in USD. When set, each "
"fired session is capped at this spend — all models are "
"blocked once the limit is reached. Omit for no cap."
),
},
"workspace": {
"type": "string",
"description": (
@@ -188,6 +196,12 @@ class SysScheduledTaskUpdateTool(Tool):
"type": "string",
"description": "New reasoning-effort hint.",
},
"max_cost_usd": {
"type": "number",
"description": (
"New per-firing cost budget in USD. Null clears the cap."
),
},
"workspace": {
"type": "string",
"description": "New existing absolute runner start path.",
+60 -7
View File
@@ -3695,6 +3695,7 @@
"session.mcp_startup": "#/components/schemas/SessionMcpStartupEvent",
"session.model": "#/components/schemas/SessionModelEvent",
"session.model_options": "#/components/schemas/SessionModelOptionsEvent",
"session.permission_mode": "#/components/schemas/SessionPermissionModeEvent",
"session.presence": "#/components/schemas/SessionPresenceEvent",
"session.reasoning_effort": "#/components/schemas/SessionReasoningEffortEvent",
"session.resource.created": "#/components/schemas/SessionResourceCreatedEvent",
@@ -3734,6 +3735,9 @@
{
"$ref": "#/components/schemas/SessionCollaborationModeEvent"
},
{
"$ref": "#/components/schemas/SessionPermissionModeEvent"
},
{
"$ref": "#/components/schemas/SessionAgentChangedEvent"
},
@@ -4775,7 +4779,7 @@
"type": "object"
},
"SessionModelEvent": {
"description": "Active-model update from a terminal-backed integration.\n\nEmitted after an `external_model_change` POST from the\n`omnigent claude` transcript forwarder when the model is\nswitched inside the Claude Code terminal (a `/model` command or\nthe in-TUI picker). Lets the web model picker reflect a TUI-side\nswitch without a reload.",
"description": "Active-model report from a terminal-backed integration.\n\nEmitted after an `external_model_change` POST from a native\nforwarder \u2014 the launch's own model report, or a switch made inside\nthe pane (a `/model` command or the in-TUI picker). Every surface\nre-renders its model display from this.",
"properties": {
"conversation_id": {
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
@@ -4783,7 +4787,7 @@
"type": "string"
},
"model": {
"description": "Tier alias the session is now on, e.g. `\"opus\"` \u2014 Claude Code's version-agnostic alias, matching the picker's vocabulary (not a pinned `\"claude-opus-4-8\"` id). Category: **transient** (SSE-only). The server also writes `model_override` on the conversation, so on reconnect clients restore the selection from the snapshot's `model_override` rather than from a replayed event.",
"description": "The model the harness reports the session is on, VERBATIM in the harness's own spelling, e.g. `\"claude-opus-4-8[1m]\"` or `\"gpt-5.6-luna\"` \u2014 never collapsed to a picker alias. Category: **transient** (SSE-only). The server also writes `reported_model` on the conversation (served on the snapshot's `llm_model`), so on reconnect clients restore the display from the snapshot rather than from a replayed event.",
"title": "Model",
"type": "string"
},
@@ -4848,6 +4852,46 @@
"title": "SessionModelOptionsEvent",
"type": "object"
},
"SessionPermissionModeEvent": {
"description": "Active permission-mode update from a claude-native session.\n\nEmitted after the web UI switches the mode, and after the Claude forwarder\nobserves a different mode in the pane footer \u2014 a shift+tab pressed inside\nthe TUI, which Omnigent has no other way to see. Lets the composer's mode\npicker track the pane without a reload.",
"properties": {
"conversation_id": {
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
"title": "Conversation Id",
"type": "string"
},
"permission_mode": {
"description": "The active mode, e.g. `\"auto\"` or `\"plan\"`. Category: **transient** (SSE-only). The server also writes `omnigent.claude_native.permission_mode` on the conversation labels, so reconnecting clients restore the same state from the session snapshot.",
"title": "Permission Mode",
"type": "string"
},
"sequence_number": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Sequence Number"
},
"type": {
"const": "session.permission_mode",
"description": "Always `\"session.permission_mode\"`.",
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"conversation_id",
"permission_mode"
],
"title": "SessionPermissionModeEvent",
"type": "object"
},
"SessionPresenceEvent": {
"description": "The session's viewer list changed \u2014 full state, not a delta.\n\nEmitted on `GET /v1/sessions/{id}/stream` whenever a user\njoins, leaves (after the server-side grace window absorbs\nreconnect churn), or flips their idle aggregate, and once to\neach newly-connected stream as a snapshot-on-connect. Every\nevent carries the COMPLETE viewer list so clients replace their\nstate wholesale \u2014 missed events self-heal on the next event or\nreconnect. Viewers are scoped to the session *tree* (the root\nconversation and every sub-agent conversation under it), so a\nuser on a sub-agent page and a user on the root page appear in\neach other's lists. See `omnigent/server/presence.py` and\n`designs/UI/PRESENCE.md`.",
"properties": {
@@ -5376,7 +5420,7 @@
"type": "null"
}
],
"description": "The LLM model identifier from the bound agent's spec, e.g. `\"anthropic/claude-sonnet-4-6\"`. `None` when the agent has no explicit `llm:` block or the agent cannot be looked up.",
"description": "The model this session is actually on. When the harness has reported one (`reported_model`, written by `external_model_change`), that verbatim value serves here and is the only model value clients display; otherwise the bound agent spec's model, e.g. `\"anthropic/claude-sonnet-4-6\"`. `None` when neither exists.",
"title": "Llm Model"
},
"mcp_startup": {
@@ -7005,6 +7049,18 @@
"description": "Per-session LLM model override, e.g. `\"claude-opus-4-7\"`. The value is forwarded as-is to the executor at turn start; the server does not enumerate valid models. Clear aliases such as `\"default\"`, `\"off\"`, or `\"reset\"` remove the override (matching the REPL's `/model` semantics). `None` leaves unchanged.",
"title": "Model Override"
},
"permission_mode": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Claude-native permission mode to switch a running session to, e.g. `\"auto\"`. Only the modes Claude Code's shift+tab cycle can reach are accepted (`default`, `acceptEdits`, `plan`, `auto`) \u2014 `dontAsk` and `bypassPermissions` are launch-only. Only valid for sessions stamped with the claude-native wrapper label. Unlike the other fields here the switch is applied by the live TUI, so a failure to reach the mode is surfaced as an error rather than persisted. Omitted leaves unchanged.",
"title": "Permission Mode"
},
"project_id": {
"anyOf": [
{
@@ -8207,10 +8263,7 @@
"application/json": {
"schema": {
"additionalProperties": {
"items": {
"additionalProperties": true,
"type": "object"
},
"items": {},
"type": "array"
},
"title": "Response Get Host Model Options V1 Hosts Host Id Harnesses Harness Model Options Get",
+1 -6
View File
@@ -246,12 +246,6 @@ cursor = ["cursor-sdk>=0.1.7"]
# _reflect). The tools import the client lazily, so only users who enable a
# Hindsight memory tool need this extra.
hindsight = ["hindsight-client>=0.4.0"]
# Backwards-compatibility alias: this extra was renamed to `hindsight` (see
# above) in #2605. Keep `memory` pulling the same client so existing
# `omnigent[memory]` / `--extra memory` invocations keep working.
# TODO(0.70): remove this `memory` alias extra — it exists only to keep the
# pre-rename install command working during the deprecation window.
memory = ["hindsight-client>=0.4.0"]
# Nimble research runs (the `nimble_research` builtin). The tool imports the
# nimble-python client lazily, so only users who enable nimble_research need
# this extra. nimble_extract talks raw httpx and needs no extra.
@@ -489,6 +483,7 @@ markers = [
"mock_only: tests/integration test that only works in mock-LLM mode (no --llm-api-key). Skipped by tests/integration/conftest.py when a real --llm-api-key is supplied (the real-LLM Integration jobs). Use for tests whose mock LLM is scripted with a fixed tool-call sequence — a real LLM cannot reproduce the scripted markers.",
"visual: UI diff visual-regression snapshot (pytest-playwright-visual-snapshot). Runs only in the pinned-runner gate (.github/workflows/ui-snapshot.yml); the main e2e_ui suite excludes it via -m 'not visual' since it runs on the unpinned ubuntu-latest.",
"smart_routing: end-to-end Smart Routing CUJ (tests/e2e/routing). Opt-in via OMNIGENT_E2E_SMART_ROUTING=1: launches a real omnigent host plus real claude/codex TUIs against a gateway. The routing service itself is an in-test mock, so no AI-Gateway task_v1 deployment is needed.",
"live_model_flows: end-to-end model-flow CUJs (tests/e2e/omnigent/test_model_flows_live.py). Opt-in via OMNIGENT_E2E_MODEL_FLOWS=1: boots a real omnigent server + host from a checkout (OMNIGENT_E2E_MODEL_FLOWS_REPO overrides which one, enabling the red-on-main matrix) with real claude/codex logins, drives the real SPA in a browser, and asserts pane truth via tmux. Never runs in CI by accident; each landing PR runs it locally per docs/model-flows test plan.",
"posix_only: test relies on POSIX-only behaviour (fork, PTY, tmux, Unix sockets, signals); auto-skipped on Windows by tests/conftest.py.",
"windows_only: test relies on Windows-only behaviour (Job Objects, cmd.exe); auto-skipped on POSIX by tests/conftest.py.",
]
+93 -1
View File
@@ -120,6 +120,39 @@ def _restore_logging_state() -> Iterator[None]:
logger.propagate = propagate
def test_global_profiling_writes_summary_and_timestamped_stats(tmp_path: Path) -> None:
"""The real entry point profiles any command selected after the root flag."""
repo_root = Path(__file__).resolve().parents[2]
pythonpath = os.pathsep.join(
part for part in (str(repo_root), os.environ.get("PYTHONPATH")) if part
)
result = subprocess.run(
[sys.executable, "-m", "omnigent", "--profiling", "version"],
capture_output=True,
text=True,
timeout=30,
cwd=tmp_path,
env={
**os.environ,
"PYTHONPATH": pythonpath,
"OMNIGENT_DATA_DIR": str(tmp_path / "data"),
},
)
assert result.returncode == 0, result.stderr
assert "CLI profile:" in result.stderr
assert "Top Omnigent call paths" in result.stderr
assert "Top Omnigent functions by self time" in result.stderr
self_time_table = result.stderr.split("Top Omnigent functions by self time", 1)[1]
assert "<built-in method" not in self_time_table
assert "Full profile data:" in result.stderr
[profile_path] = list((tmp_path / "data" / "profiles").glob("omnigent-cli-*.prof"))
import pstats
assert pstats.Stats(str(profile_path)).total_calls > 0
def test_python_module_entrypoint_uses_unified_click_cli() -> None:
"""
``python -m omnigent`` must dispatch through the same click CLI
@@ -191,6 +224,7 @@ def test_wrapper_guard_bypass_reaches_cli_end_to_end() -> None:
(["run", "tests/resources/examples/hello_world.yaml"], False),
(["attach", "tests/resources/examples/hello_world.yaml"], False),
(["--help"], False),
(["--profiling", "--help"], False),
(["what does this repo do?"], True),
(["--system-prompt", "You are terse"], True),
# A single command-shaped word is an unknown subcommand, not
@@ -1042,10 +1076,11 @@ def test_kiro_command_is_registered_in_click_help() -> None:
def test_help_groups_harnesses_and_other_commands() -> None:
"""``--help`` lists a ``Harnesses`` section separate from ``Commands``."""
"""``--help`` lists global options and separates command categories."""
result = CliRunner().invoke(cli, ["--help"])
assert result.exit_code == 0, result.output
assert "--profiling" in result.output
assert "Harnesses:" in result.output
assert "Commands:" in result.output
# A harness launcher lands under Harnesses; a management command
@@ -3458,6 +3493,63 @@ def test_run_profile_sets_databricks_config_profile_env(
assert seen["value"] == "my-sp"
def test_bare_run_profile_shorthand_still_selects_databricks_profile(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The new root flag must not consume the historical bare-run spelling."""
from omnigent.cli import main
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
seen = _capture_profile_env_at_dispatch(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
[
"omnigent",
"--profile",
"my-sp",
"--server",
"https://example.com",
"-p",
"hi",
],
)
main()
assert seen["value"] == "my-sp"
def test_global_profiling_coexists_with_run_databricks_profile(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""Root profiling and ``run --profile NAME`` keep distinct semantics."""
monkeypatch.setenv("OMNIGENT_DATA_DIR", str(tmp_path / "data"))
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
seen = _capture_profile_env_at_dispatch(monkeypatch)
result = CliRunner().invoke(
cli,
[
"--profiling",
"run",
"--server",
"https://example.com",
"--profile",
"my-sp",
"-p",
"hi",
],
)
assert result.exit_code == 0, result.output
assert seen["value"] == "my-sp"
assert "Top Omnigent call paths" in result.stderr
profiles = tmp_path / "data" / "profiles"
assert len(list(profiles.glob("omnigent-cli-*.prof"))) == 1
def test_run_profile_wins_over_preset_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
+259
View File
@@ -6,6 +6,7 @@ by ``omnigent login``.
from __future__ import annotations
import contextlib
import time
import pytest
@@ -526,3 +527,261 @@ def test_databricks_record_overwrites_jwt_record(token_dir) -> None:
load_databricks_workspace_host("https://server.example.com")
== "https://example.databricks.com"
)
# ── Login-issued refresh grants (client side) ─────────────────────
def test_store_token_persists_refresh_material(token_dir) -> None:
"""A refresh token stored at login survives the round trip."""
import json
from omnigent.cli_auth import store_token
store_token(
"http://localhost:6767",
token="jwt",
user_id="a@x",
expires_at=time.time() + 3600,
refresh_token="refresh-1",
)
data = json.loads((token_dir / "auth_tokens.json").read_text())
assert data["http://localhost:6767"]["refresh_token"] == "refresh-1"
def test_stored_token_status_classification(token_dir) -> None:
"""absent / expired / ok are distinguished — the host uses this to say
"your login expired" instead of dialing into a misleading 403."""
from omnigent.cli_auth import store_token, stored_token_status
assert stored_token_status("http://localhost:6767") == "absent"
store_token("http://localhost:6767", token="jwt", user_id="a@x", expires_at=time.time() - 10)
assert stored_token_status("http://localhost:6767") == "expired"
store_token("http://localhost:6767", token="jwt", user_id="a@x", expires_at=time.time() + 3600)
assert stored_token_status("http://localhost:6767") == "ok"
def test_refresh_stored_token_renews_and_rotates(token_dir, monkeypatch) -> None:
"""An expired entry with refresh material renews via /oauth/token and
persists the rotated pair."""
import json
import httpx
from omnigent.cli_auth import load_token, refresh_stored_token, store_token
store_token(
"http://localhost:6767",
token="stale",
user_id="a@x",
expires_at=time.time() - 10,
refresh_token="refresh-1",
)
assert load_token("http://localhost:6767") is None # expired
posted: dict[str, object] = {}
def _fake_post(url, *, data=None, timeout=None):
posted["url"] = url
posted["data"] = data
return httpx.Response(
200,
json={
"access_token": "fresh",
"refresh_token": "refresh-2",
"token_type": "Bearer",
"expires_in": 3600,
},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx, "post", _fake_post)
assert refresh_stored_token("http://localhost:6767") == "fresh"
assert posted["url"] == "http://localhost:6767/oauth/token"
assert posted["data"] == {"grant_type": "refresh_token", "refresh_token": "refresh-1"}
# Rotated pair persisted; the fresh token now loads normally.
data = json.loads((token_dir / "auth_tokens.json").read_text())
entry = data["http://localhost:6767"]
assert entry["token"] == "fresh" and entry["refresh_token"] == "refresh-2"
assert load_token("http://localhost:6767") == "fresh"
def test_refresh_stored_token_no_material_is_none(token_dir) -> None:
"""Nothing to refresh (no entry, or no refresh token) → None, no I/O."""
from omnigent.cli_auth import refresh_stored_token, store_token
assert refresh_stored_token("http://localhost:6767") is None
store_token("http://localhost:6767", token="jwt", user_id="a@x", expires_at=time.time() - 10)
assert refresh_stored_token("http://localhost:6767") is None
def test_refresh_stored_token_refused_leaves_entry(token_dir, monkeypatch) -> None:
"""A server refusal (revoked / aged-out grant / old server 404) returns
None and leaves the stored entry untouched."""
import json
import httpx
from omnigent.cli_auth import refresh_stored_token, store_token
store_token(
"http://localhost:6767",
token="stale",
user_id="a@x",
expires_at=time.time() - 10,
refresh_token="refresh-1",
)
def _fake_post(url, *, data=None, timeout=None):
return httpx.Response(
400, json={"error": "invalid_grant"}, request=httpx.Request("POST", url)
)
monkeypatch.setattr(httpx, "post", _fake_post)
assert refresh_stored_token("http://localhost:6767") is None
entry = json.loads((token_dir / "auth_tokens.json").read_text())["http://localhost:6767"]
assert entry["refresh_token"] == "refresh-1"
def test_refresh_stored_token_skips_when_already_fresh(token_dir, monkeypatch) -> None:
"""A concurrent refresher already renewed → return the valid token
without a network call (the lock-then-recheck path)."""
import httpx
from omnigent.cli_auth import refresh_stored_token, store_token
store_token(
"http://localhost:6767",
token="already-fresh",
user_id="a@x",
expires_at=time.time() + 3600,
refresh_token="refresh-1",
)
def _boom(*args, **kwargs):
raise AssertionError("no network call expected")
monkeypatch.setattr(httpx, "post", _boom)
assert refresh_stored_token("http://localhost:6767") == "already-fresh"
def test_refresh_no_material_does_not_touch_lock_file(token_dir, monkeypatch) -> None:
"""With no refresh material, the refresh must not create a lock file.
Regression: creating the lock before checking raised OSError on a
read-only state directory, which the runner's auth factory caught —
skipping its Databricks SDK fallback and leaving valid credentials
unused.
"""
from omnigent.cli_auth import refresh_stored_token, store_token
store_token("http://localhost:6767", token="jwt", user_id="a@x", expires_at=time.time() - 10)
def _boom(*_a, **_kw):
raise AssertionError("lock file must not be created when nothing to refresh")
monkeypatch.setattr("builtins.open", _boom)
assert refresh_stored_token("http://localhost:6767") is None
assert not (token_dir / "auth_tokens.lock").exists()
def test_refresh_survives_unwritable_state_dir(token_dir, monkeypatch) -> None:
"""A lock/persist failure degrades to None instead of raising, so the
caller can still fall back to its other credential sources."""
import omnigent.cli_auth as ca
ca.store_token(
"http://localhost:6767",
token="stale",
user_id="a@x",
expires_at=time.time() - 10,
refresh_token="refresh-1",
)
@contextlib.contextmanager
def _unwritable():
raise OSError("read-only file system")
yield # pragma: no cover
monkeypatch.setattr(ca, "_token_file_lock", _unwritable)
assert ca.refresh_stored_token("http://localhost:6767") is None
def test_load_token_min_remaining_declines_near_expiry(token_dir) -> None:
"""A token inside the renewal window reads as unusable so the caller
refreshes instead of sending one that lapses mid-handshake."""
from omnigent.cli_auth import REFRESH_MIN_REMAINING_SECONDS, load_token, store_token
store_token(
"http://localhost:6767",
token="jwt",
user_id="a@x",
expires_at=time.time() + (REFRESH_MIN_REMAINING_SECONDS / 2),
)
# Default (0) still accepts a not-yet-expired token — unchanged behaviour.
assert load_token("http://localhost:6767") == "jwt"
# The renewal-aware caller declines it.
assert (
load_token("http://localhost:6767", min_remaining_seconds=REFRESH_MIN_REMAINING_SECONDS)
is None
)
def test_load_entry_tolerates_wrong_shaped_json(token_dir) -> None:
"""A token file holding valid JSON of the wrong shape reads as empty
rather than raising AttributeError into the caller."""
from omnigent.cli_auth import load_token, stored_token_status
for bad in ("[]", "null", '"a string"'):
(token_dir / "auth_tokens.json").write_text(bad)
assert load_token("http://localhost:6767") is None
assert stored_token_status("http://localhost:6767") == "absent"
def test_refresh_rejects_unusable_response_fields(token_dir, monkeypatch) -> None:
"""A 200 with null/non-string tokens or a non-finite expires_in must not
clobber the working stored credential."""
import json
import httpx
from omnigent.cli_auth import refresh_stored_token, store_token
def _seed():
store_token(
"http://localhost:6767",
token="stale",
user_id="a@x",
expires_at=time.time() - 10,
refresh_token="refresh-1",
)
# null access_token → decline, stored pair untouched.
_seed()
monkeypatch.setattr(
httpx,
"post",
lambda url, **_kw: httpx.Response(
200,
json={"access_token": None, "refresh_token": "r2"},
request=httpx.Request("POST", url),
),
)
assert refresh_stored_token("http://localhost:6767") is None
entry = json.loads((token_dir / "auth_tokens.json").read_text())["http://localhost:6767"]
assert entry["token"] == "stale" and entry["refresh_token"] == "refresh-1"
# Non-finite expires_in → falls back to a sane lifetime, not "never expires".
_seed()
monkeypatch.setattr(
httpx,
"post",
lambda url, **_kw: httpx.Response(
200,
json={"access_token": "fresh", "refresh_token": "r2", "expires_in": "NaN"},
request=httpx.Request("POST", url),
),
)
assert refresh_stored_token("http://localhost:6767") == "fresh"
entry = json.loads((token_dir / "auth_tokens.json").read_text())["http://localhost:6767"]
assert entry["expires_at"] < time.time() + 4000
+15 -10
View File
@@ -28,6 +28,8 @@ cli_group = cli_mod.cli
_APPS_URL = "https://myapp-1234.aws.databricksapps.com"
_WORKSPACE = "https://example.databricks.com"
# The login pins ``--profile`` to the workspace's first DNS label.
_PROFILE = "example"
_APPS_REDIRECT = f"{_WORKSPACE}/oidc/oauth2/v2.0/authorize?client_id=abc&response_type=code"
_WORKSPACE_API_URL = f"{_WORKSPACE}/api/2.0/omnigent"
@@ -245,8 +247,11 @@ def test_login_runs_databricks_auth_login_when_no_cached_grant(
) -> None:
"""No cached host-keyed grant → ``databricks auth login --host <ws>`` runs.
The login is host-keyed (no ``--profile`` / profile name anywhere);
after it succeeds the token resolves and the record is stored.
The login pins ``--profile`` to the workspace's first DNS label so a
second workspace doesn't clobber the shared ``DEFAULT`` profile;
resolution stays host-matched (name-agnostic), so the profile name
isn't consulted anywhere. After login the token resolves and the
record is stored.
"""
from omnigent.cli_auth import load_databricks_workspace_host
@@ -266,10 +271,9 @@ def test_login_runs_databricks_auth_login_when_no_cached_grant(
result = CliRunner().invoke(cli_group, ["login", _APPS_URL])
assert result.exit_code == 0, result.output
# Exactly one browser login, host-keyed, with no profile flag — a
# `--profile` here would recreate the named-profile coupling this
# flow exists to remove.
assert login_calls == [f"auth login --host {_WORKSPACE}"]
# Exactly one browser login, pinned to the per-workspace profile so
# ``DEFAULT`` is left alone; ``?o=`` stays only on ``--host``.
assert login_calls == [f"auth login --host {_WORKSPACE} --profile {_PROFILE}"]
assert load_databricks_workspace_host(_APPS_URL) == _WORKSPACE
@@ -354,7 +358,7 @@ def test_login_stale_cached_grant_triggers_fresh_login_and_retry(
assert result.exit_code == 0, result.output
# Exactly one forced re-login — a second rejection must fail loud,
# not loop the browser flow.
assert login_calls == [f"auth login --host {_WORKSPACE}"]
assert login_calls == [f"auth login --host {_WORKSPACE} --profile {_PROFILE}"]
# The retry verify presented the freshly minted token, not the stale one.
assert fake.requests[-1]["authorization"] == "Bearer tok-fresh"
assert load_databricks_workspace_host(_APPS_URL) == _WORKSPACE
@@ -389,7 +393,7 @@ def test_foreign_subprocess_calls_stay_out_of_the_login_recorder(
result = CliRunner().invoke(cli_group, ["login", _APPS_URL])
assert result.exit_code == 0, result.output
assert login_calls == [f"auth login --host {_WORKSPACE}"]
assert login_calls == [f"auth login --host {_WORKSPACE} --profile {_PROFILE}"]
# Delegated to the real runner rather than stubbed out from under it.
assert probe.returncode == 0
assert b"git version" in probe.stdout
@@ -477,8 +481,9 @@ def test_login_threads_org_id_through_workspace_login_and_verify(
result = CliRunner().invoke(cli_group, ["login", _SELECTOR_URL])
assert result.exit_code == 0, result.output
# The browser login carries the selector so the profile is workspace-scoped.
assert login_calls == [f"auth login --host {_WORKSPACE}/?o={_ORG_ID}"]
# The browser login carries the selector on --host so the grant is
# workspace-scoped; the profile name stays the bare first DNS label.
assert login_calls == [f"auth login --host {_WORKSPACE}/?o={_ORG_ID} --profile {_PROFILE}"]
# The verify request routes to the workspace via ?o= (and used the token).
assert fake.requests[-1]["params"] == {"o": _ORG_ID}
assert fake.requests[-1]["authorization"] == "Bearer tok-fresh"
@@ -60,6 +60,7 @@ def test_scheduled_tasks_columns(db_engine: Engine) -> None:
"agent_id",
"model_override",
"reasoning_effort",
"max_cost_usd",
"workspace",
"base_branch",
"execution_target",
+946
View File
@@ -0,0 +1,946 @@
"""Sandbox rig for the live model-flows CUJs (design: model-flows-design.md §10.1).
Boots a real ``omnigent server`` + ``omnigent host`` from a chosen checkout
``OMNIGENT_E2E_MODEL_FLOWS_REPO`` selects which, so the identical tests can run
against unmodified main (the red-on-main matrix) and against this branch with
the developer's real ``$HOME`` (the claude/codex logins cannot be relocated) but
an isolated ``OMNIGENT_CONFIG_HOME`` / ``OMNIGENT_DATA_DIR``. Provider *shapes*
(which provider entry is the default for each model family) are rewritten into
the sandbox config per test group, mirroring how ``omnigent setup`` flips the
``default:`` claims.
Every test drives the product the way a person uses it: the browser drives the
rig server's real SPA (Playwright sync API), and harness truth is read where a
user reads it the tmux pane and the harness's own on-disk state. REST reads
are secondary probes only, and REST writes are reserved for the rows whose
actor is not the browser (a REPL ``/model``, a routing pin, an API client).
"""
from __future__ import annotations
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import httpx
import pytest
import yaml
from tests.e2e.routing._helpers import wait_for
#: Opt-in gate: this suite launches real claude/codex TUIs and does real
#: inference, so it never runs in CI by accident.
RUN_GATE_ENV = "OMNIGENT_E2E_MODEL_FLOWS"
#: Checkout to boot the rig from. Defaults to this test file's repo. Point it
#: at an unmodified main checkout to produce the red-on-main matrix; the rig
#: prefers that checkout's own ``.venv`` interpreter so its dependency set
#: matches its code.
RIG_REPO_ENV = "OMNIGENT_E2E_MODEL_FLOWS_REPO"
#: Optional prebuilt SPA dist for the rig server (``OMNIGENT_WEB_UI_DIST``
#: passthrough) — needed when the target checkout's packaged SPA is stale
#: relative to its web/ sources.
SPA_DIST_ENV = "OMNIGENT_E2E_MODEL_FLOWS_WEB_DIST"
_THIS_REPO = Path(__file__).resolve().parents[3]
_SERVER_HEALTH_TIMEOUT_S = 60.0
_HOST_REGISTER_TIMEOUT_S = 60.0
#: Session create → pane visible. Real CLI boots take a while on a cold disk.
PANE_TIMEOUT_S = 90.0
def require_opt_in() -> None:
"""Skip unless the live model-flows suite was asked for explicitly."""
if os.environ.get(RUN_GATE_ENV) != "1":
pytest.skip(
f"set {RUN_GATE_ENV}=1 to run the live model-flow CUJs "
"(they launch real claude/codex TUIs and a real host daemon)"
)
def require_clis(*clis: str) -> None:
"""Skip when a needed CLI is not on PATH.
:param clis: Binary names, e.g. ``"claude"``, ``"codex"``, ``"tmux"``.
"""
for cli in clis:
if shutil.which(cli) is None:
pytest.skip(f"{cli!r} is not on PATH; this CUJ cannot launch")
def rig_repo() -> Path:
"""Return the checkout the rig boots from.
:returns: Absolute repo root, e.g. ``~/omnigent`` for a red-on-main run.
"""
override = os.environ.get(RIG_REPO_ENV)
return Path(override).expanduser().resolve() if override else _THIS_REPO
def _rig_python(repo: Path) -> str:
"""Interpreter for rig subprocesses: the checkout's venv when it has one.
:param repo: The checkout to boot.
:returns: Path to a python executable.
"""
venv_python = repo / ".venv" / "bin" / "python3"
return str(venv_python) if venv_python.exists() else sys.executable
def developer_providers() -> dict[str, Any]:
"""Read the developer's real ``providers:`` block.
The live CUJs exercise the machine's actual provider entries (subscription
logins, the gateway entry, the databricks profile); the sandbox copies the
block and only flips ``default:`` claims per shape.
:returns: The ``providers`` mapping from ``~/.omnigent/config.yaml``.
"""
path = Path.home() / ".omnigent" / "config.yaml"
if not path.is_file():
pytest.skip("no ~/.omnigent/config.yaml; the live model-flow CUJs need real providers")
parsed = yaml.safe_load(path.read_text()) or {}
providers = parsed.get("providers")
if not isinstance(providers, dict) or not providers:
pytest.skip("~/.omnigent/config.yaml has no providers block")
return {name: dict(body) for name, body in providers.items() if isinstance(body, dict)}
def _entry_of_kind(providers: dict[str, Any], kind: str, *, cli: str | None = None) -> str | None:
"""Find a provider entry name by ``kind`` (and optional ``cli``).
:param providers: The providers mapping.
:param kind: e.g. ``"subscription"``, ``"gateway"``, ``"databricks"``.
:param cli: Restrict subscription entries to this CLI, e.g. ``"claude"``.
:returns: The entry name, or ``None``.
"""
for name, body in providers.items():
if body.get("kind") != kind:
continue
if cli is not None and body.get("cli") != cli:
continue
return name
return None
def shape_providers(shape: str) -> dict[str, Any]:
"""Return a providers block with ``default:`` claims flipped for *shape*.
Shapes mirror the analysis doc's live matrix:
- ``claude-subscription`` claude's subscription entry claims anthropic.
- ``claude-gateway`` a ``kind: gateway`` entry with an anthropic family
claims anthropic (the isaac-style hand-written gateway entry).
- ``codex-databricks`` the ``kind: databricks`` entry claims openai.
- ``codex-subscription`` codex's subscription entry claims openai.
Skips when the developer's config lacks the entry a shape needs.
:param shape: One of the four shape names above.
:returns: A deep-copied providers mapping with defaults rewritten.
"""
providers = developer_providers()
# Strip every existing default claim; each shape sets exactly what it needs.
for body in providers.values():
body.pop("default", None)
def _need(name: str | None, what: str) -> str:
if name is None:
pytest.skip(f"developer config has no {what}; shape {shape!r} cannot run")
return name
if shape == "claude-subscription":
name = _need(
_entry_of_kind(providers, "subscription", cli="claude"), "claude subscription entry"
)
providers[name]["default"] = True
elif shape == "claude-gateway":
gateway = next(
(
n
for n, b in providers.items()
if b.get("kind") == "gateway" and isinstance(b.get("anthropic"), dict)
),
None,
)
name = _need(gateway, "anthropic-family gateway entry")
providers[name]["default"] = True
elif shape == "codex-databricks":
name = _need(_entry_of_kind(providers, "databricks"), "databricks entry")
providers[name]["default"] = "openai"
elif shape == "codex-subscription":
name = _need(
_entry_of_kind(providers, "subscription", cli="codex"), "codex subscription entry"
)
providers[name]["default"] = "openai"
else: # pragma: no cover - test-authoring error
raise ValueError(f"unknown shape {shape!r}")
return providers
@dataclass
class ModelFlowsRig:
"""A booted sandbox server (+ optionally a host) from one checkout."""
repo: Path
root: Path
base_url: str = ""
_server: subprocess.Popen[bytes] | None = None
_host: subprocess.Popen[bytes] | None = None
_logs: list[Any] = field(default_factory=list)
host_id: str = ""
@property
def server_log(self) -> Path:
"""The rig server's log path."""
return self.root / "server.log"
@property
def host_log(self) -> Path:
"""The rig host's log path."""
return self.root / "host.log"
@property
def data_dir(self) -> Path:
"""The sandbox ``OMNIGENT_DATA_DIR``."""
return self.root / "data"
def _env(self) -> dict[str, str]:
env = {
**os.environ,
"OMNIGENT_CONFIG_HOME": str(self.root / "config-home"),
"OMNIGENT_DATA_DIR": str(self.data_dir),
"PYTHONPATH": str(self.repo),
"OMNIGENT_LOG_TO_STDERR": "1",
}
# Claude Code refuses nested sessions; the agent driving this suite
# may export the marker. And when this suite itself runs inside an
# omnigent-managed session, the inherited runner identity would point
# spawned runners at the WRONG server — scrub it.
env.pop("CLAUDECODE", None)
env.pop("RUNNER_SERVER_URL", None)
env.pop("OMNIGENT", None)
for key in [k for k in env if k.startswith(("OMNIGENT_RUNNER", "OMNIGENT_PROCESS"))]:
env.pop(key, None)
# tests/conftest.py exports OMNIGENT_DISABLE_CATALOG_LOOKUP=1 for the
# whole pytest process (hermetic suites must not hit the network). This
# suite is the OPPOSITE: a live rig whose databricks shapes need the
# real provider catalog, and the spawned server/host inherit our env —
# so drop the kill switch for them.
env.pop("OMNIGENT_DISABLE_CATALOG_LOOKUP", None)
spa_dist = os.environ.get(SPA_DIST_ENV)
if spa_dist:
env["OMNIGENT_WEB_UI_DIST"] = spa_dist
return env
def start_server(self) -> None:
"""Boot the rig server on a free port and wait for /health."""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
port = int(sock.getsockname()[1])
(self.root / "config-home").mkdir(parents=True, exist_ok=True)
self.data_dir.mkdir(parents=True, exist_ok=True)
log_handle = open(self.server_log, "w") # noqa: SIM115 — subprocess lifetime
self._logs.append(log_handle)
self._server = subprocess.Popen(
[
_rig_python(self.repo),
"-m",
"omnigent.cli",
"server",
"--host",
"127.0.0.1",
"--port",
str(port),
"--database-uri",
f"sqlite:///{self.root / 'rig.db'}",
"--artifact-location",
str(self.root / "artifacts"),
],
env=self._env(),
cwd=str(self.repo),
stdout=log_handle,
stderr=subprocess.STDOUT,
)
self.base_url = f"http://127.0.0.1:{port}"
def _healthy() -> bool | None:
if self._server is not None and self._server.poll() is not None:
raise RuntimeError(
f"rig server exited early; log tail:\n{self._tail(self.server_log)}"
)
try:
return httpx.get(f"{self.base_url}/health", timeout=2).status_code == 200 or None
except httpx.HTTPError:
return None
wait_for(_healthy, timeout=_SERVER_HEALTH_TIMEOUT_S, what="the rig server /health")
def start_host(self, providers: dict[str, Any]) -> str:
"""Boot (or reboot) the rig host with *providers*; return its host id.
:param providers: The sandbox ``providers:`` block for this shape.
:returns: The registered host id.
"""
self.stop_host()
config_home = self.root / "config-home"
config_home.mkdir(parents=True, exist_ok=True)
(config_home / "config.yaml").write_text(
yaml.safe_dump({"providers": providers}, sort_keys=False)
)
log_handle = open(self.host_log, "w") # noqa: SIM115 — subprocess lifetime
self._logs.append(log_handle)
self._host = subprocess.Popen(
[
_rig_python(self.repo),
"-m",
"omnigent.host._daemon_entry",
"--server",
self.base_url,
],
env=self._env(),
cwd=str(self.repo),
stdout=subprocess.DEVNULL,
stderr=log_handle,
)
def _online() -> str | None:
if self._host is not None and self._host.poll() is not None:
raise RuntimeError(
f"rig host exited early; log tail:\n{self._tail(self.host_log)}"
)
try:
resp = httpx.get(f"{self.base_url}/v1/hosts", timeout=5)
except httpx.HTTPError:
return None
if resp.status_code != 200:
return None
online = [h for h in resp.json().get("hosts", []) if h.get("status") == "online"]
return str(online[0]["host_id"]) if online else None
self.host_id = wait_for(
_online, timeout=_HOST_REGISTER_TIMEOUT_S, what="the rig host to register"
)
return self.host_id
def _tail(self, path: Path, limit: int = 3000) -> str:
return path.read_text(errors="replace")[-limit:] if path.exists() else ""
def stop_host(self) -> None:
"""Stop the host subprocess if one is running."""
self._stop(self._host)
self._host = None
def stop(self) -> None:
"""Tear the whole rig down."""
self.stop_host()
self._stop(self._server)
self._server = None
for handle in self._logs:
handle.close()
self._logs.clear()
@staticmethod
def _stop(proc: subprocess.Popen[bytes] | None) -> None:
if proc is None or proc.poll() is not None:
return
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
@contextmanager
def booted_rig(tmp_root: Path) -> Iterator[ModelFlowsRig]:
"""Context manager: a rig with its server booted (no host yet).
Snapshots the developer's ``~/.claude/settings.json`` and restores it on
exit: the suite's real ``/model`` switches run under the real ``$HOME``
and Claude Code persists every switch as the person's global default —
without the restore, a test run would rewrite the developer's model.
:param tmp_root: Directory for the sandbox (config home, data, logs, db).
:yields: The rig; call :meth:`ModelFlowsRig.start_host` per shape.
"""
settings_path = Path.home() / ".claude" / "settings.json"
settings_before: bytes | None
try:
settings_before = settings_path.read_bytes()
except OSError:
settings_before = None
rig = ModelFlowsRig(repo=rig_repo(), root=tmp_root)
rig.start_server()
try:
yield rig
finally:
rig.stop()
if settings_before is not None:
try:
if settings_path.read_bytes() != settings_before:
settings_path.write_bytes(settings_before)
except OSError:
pass
# ---------------------------------------------------------------------------
# Pane truth
# ---------------------------------------------------------------------------
def _terminal_socket_dirs() -> set[Path]:
"""Return the omnigent terminal tmux socket dirs currently on disk."""
tmp = Path(tempfile.gettempdir())
return {p for p in tmp.glob("omnigent-terminal-*") if (p / "tmux.sock").exists()}
@dataclass
class PaneWatcher:
"""Discover the tmux pane a session launch creates and read its truth.
Snapshot the terminal-socket set before creating a session; the first new
live socket afterwards belongs to that session's terminal (the rig is the
only thing creating sessions inside the sandbox, but other daemons on the
machine may create panes too the ``since`` timestamp filters those by
directory mtime).
"""
before: set[Path] = field(default_factory=set)
since: float = 0.0
socket: Path | None = None
def arm(self) -> None:
"""Record the pre-create socket set."""
self.before = _terminal_socket_dirs()
self.since = time.time()
def wait_for_pane(self, timeout: float = PANE_TIMEOUT_S) -> Path:
"""Wait for the session's tmux socket to appear.
:param timeout: Seconds to wait.
:returns: The tmux socket path.
"""
def _found() -> Path | None:
fresh = [
d
for d in _terminal_socket_dirs() - self.before
if d.stat().st_mtime >= self.since - 1
]
for d in sorted(fresh, key=lambda p: p.stat().st_mtime):
sock = d / "tmux.sock"
probe = subprocess.run(
["tmux", "-S", str(sock), "list-panes", "-a"],
capture_output=True,
timeout=10,
)
if probe.returncode == 0 and probe.stdout.strip():
return sock
return None
self.socket = wait_for(_found, timeout=timeout, what="the session's tmux pane")
return self.socket
def capture(self) -> str:
"""Return the pane's current visible text."""
assert self.socket is not None, "call wait_for_pane first"
out = subprocess.run(
["tmux", "-S", str(self.socket), "capture-pane", "-p", "-t", "main"],
capture_output=True,
text=True,
timeout=10,
)
return out.stdout
def wait_for_text(self, pattern: str, timeout: float = PANE_TIMEOUT_S) -> str:
"""Wait until the pane's visible text matches *pattern* (regex).
:param pattern: Regex searched against the captured pane text.
:param timeout: Seconds to wait.
:returns: The matching captured text.
"""
def _match() -> str | None:
text = self.capture()
return text if re.search(pattern, text) else None
return wait_for(_match, timeout=timeout, what=f"pane text matching {pattern!r}")
def type_line(self, text: str) -> None:
"""Type *text* into the pane as one bracketed paste, then Enter."""
assert self.socket is not None, "call wait_for_pane first"
subprocess.run(
["tmux", "-S", str(self.socket), "load-buffer", "-b", "omni-mf-e2e", "-"],
input=text.encode("utf-8"),
check=True,
timeout=15,
)
subprocess.run(
[
"tmux",
"-S",
str(self.socket),
"paste-buffer",
"-p",
"-b",
"omni-mf-e2e",
"-t",
"main",
],
check=True,
timeout=15,
)
time.sleep(1.0)
subprocess.run(
["tmux", "-S", str(self.socket), "send-keys", "-t", "main", "Enter"],
check=True,
timeout=15,
)
def send_key(self, key: str) -> None:
"""Send one raw tmux key (e.g. ``"Escape"``, ``"Enter"``)."""
assert self.socket is not None, "call wait_for_pane first"
subprocess.run(
["tmux", "-S", str(self.socket), "send-keys", "-t", "main", key],
check=True,
timeout=15,
)
def kill_pane(pane: PaneWatcher) -> None:
"""
End the session's pane the way an idle reap or a host restart does.
Kills the pane's whole tmux server, so the harness process goes with it
and the runner's next turn has to re-create the terminal (its cold-resume
launch path).
:param pane: The session's discovered pane.
"""
assert pane.socket is not None, "call wait_for_pane first"
subprocess.run(
["tmux", "-S", str(pane.socket), "kill-server"],
capture_output=True,
timeout=15,
)
def codex_config_copy_model(session_id: str) -> str | None:
"""Return the ``model =`` line of a codex session's private config copy.
The per-session ``CODEX_HOME`` lives under the real home dir regardless of
``OMNIGENT_DATA_DIR`` (it is harness state, not omnigent state) but its
directory is named by a runner-GENERATED bridge id, recorded only in the
bridge's own ``state.json`` (as ``session_id``). Resolve by scanning.
:param session_id: The session/conversation id.
:returns: The pinned model string, or ``None``.
"""
root = Path.home() / ".omnigent" / "codex-native"
for state_path in root.glob("*/state.json"):
try:
state = json.loads(state_path.read_text())
except (OSError, ValueError):
continue
if state.get("session_id") != session_id:
continue
config = state_path.parent / "codex-home" / "config.toml"
if not config.exists():
return None
for line in config.read_text().splitlines():
match = re.match(r'^model\s*=\s*"(?P<model>[^"]+)"', line.strip())
if match:
return match.group("model")
return None
return None
# ---------------------------------------------------------------------------
# REST driving (the SPA's own calls, for rows whose actor is not the browser)
# ---------------------------------------------------------------------------
def rest_create_session(
base_url: str,
*,
agent_name: str,
host_id: str,
workspace: Path,
terminal_launch_args: list[str] | None = None,
) -> str:
"""
Create a host-spawned native session with the create call the SPA makes.
:param base_url: Rig server base URL.
:param agent_name: Built-in wrapper agent, e.g. ``"claude-native-ui"``.
:param host_id: Host to launch on.
:param workspace: Absolute workspace path on that host.
:param terminal_launch_args: Pass-through CLI args, e.g. :func:`bypass_args`.
:returns: The new session id.
"""
agents = httpx.get(f"{base_url}/v1/agents", timeout=30)
agents.raise_for_status()
agent_id = next((a["id"] for a in agents.json()["data"] if a["name"] == agent_name), None)
assert agent_id is not None, f"{agent_name!r} is not registered on the rig server"
body: dict[str, Any] = {"agent_id": agent_id, "host_id": host_id, "workspace": str(workspace)}
if terminal_launch_args:
body["terminal_launch_args"] = list(terminal_launch_args)
resp = httpx.post(f"{base_url}/v1/sessions", json=body, timeout=120)
assert resp.status_code < 400, f"create failed {resp.status_code}: {resp.text[:2000]}"
return str(resp.json()["id"])
def rest_patch_session(base_url: str, session_id: str, **fields: Any) -> dict[str, Any]:
"""
PATCH session fields a REPL ``/model`` and an API client write this way.
A native model change is forwarded to the pane and answered only once the
harness confirmed it, so the call may take a while.
:param base_url: Rig server base URL.
:param session_id: Session id.
:param fields: Wire fields, e.g. ``model_override="claude-opus-4-8"``.
:returns: The updated session payload.
"""
resp = httpx.patch(f"{base_url}/v1/sessions/{session_id}", json=fields, timeout=180)
assert resp.status_code < 400, (
f"PATCH {sorted(fields)} failed {resp.status_code}: {resp.text[:2000]}"
)
return dict(resp.json())
def rest_post_user_message(base_url: str, session_id: str, text: str) -> None:
"""
POST a user message the way the composer does.
:param base_url: Rig server base URL.
:param session_id: Session id.
:param text: The message text.
"""
resp = httpx.post(
f"{base_url}/v1/sessions/{session_id}/events",
json={
"type": "message",
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
},
timeout=60,
)
assert resp.status_code < 400, f"message POST failed {resp.status_code}: {resp.text[:1000]}"
def assistant_message_count(snapshot: dict[str, Any]) -> int:
"""
Count the assistant messages a session snapshot carries.
:param snapshot: A ``GET /v1/sessions/{id}`` payload.
:returns: The number of assistant ``message`` items.
"""
count = 0
for item in snapshot.get("items") or []:
if not isinstance(item, dict) or item.get("type") != "message":
continue
data = item.get("data") if isinstance(item.get("data"), dict) else item
if data.get("role") == "assistant":
count += 1
return count
# ---------------------------------------------------------------------------
# Browser driving (sync Playwright over the rig server's real SPA)
# ---------------------------------------------------------------------------
class Ui:
"""Thin user-perspective driver over the rig's SPA."""
def __init__(self, page: Any, base_url: str) -> None:
self.page = page
self.base_url = base_url
def open_landing(self) -> None:
"""Open the new-chat landing screen."""
self.page.goto(self.base_url, wait_until="domcontentloaded")
self.page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
def pick_agent(self, label: str) -> None:
"""Select the agent whose dropdown label contains *label*."""
select = self.page.get_by_test_id("new-chat-landing-agent-select")
if label.lower() in select.inner_text().strip().lower():
return
select.click()
self.page.wait_for_timeout(400)
for item in self.page.get_by_role("menuitem").all():
if label.lower() in item.inner_text().lower():
item.click()
self.page.wait_for_timeout(800)
return
raise AssertionError(f"agent {label!r} not offered on the landing screen")
def open_landing_config(self) -> None:
"""Open the landing config modal (gear)."""
self.page.get_by_test_id("new-chat-landing-config-gear").click()
self.page.get_by_test_id("new-chat-landing-config-model").wait_for(
state="visible", timeout=15_000
)
def landing_model_label(self) -> str:
"""Visible text of the landing model select trigger."""
return self.page.get_by_test_id("new-chat-landing-config-model").inner_text().strip()
def wait_landing_model_label(self, pattern: str, timeout_s: float = 90.0) -> str:
"""Wait until the landing model trigger matches *pattern* (regex).
The trigger renders a bare sentinel while the host's boot probe is
still warming (the web retries the fetch with backoff), so give the
label the same warm-up wait a person makes.
"""
def _match() -> str | None:
text = self.landing_model_label()
return text if re.search(pattern, text) else None
return wait_for(_match, timeout=timeout_s, what=f"landing model label {pattern!r}")
def open_model_dropdown(self, test_id: str, warmup_timeout_s: float = 90.0) -> list[str]:
"""Open a model select and return its visible option texts.
A dropdown opened while the host's boot probe is still warming shows
only the loading/error note and the sentinels; the web retries the
fetch with backoff, so keep the dropdown open and re-read until model
rows (or the settled error) appear the same wait a person makes.
:param test_id: Trigger test id (landing or composer variant).
:param warmup_timeout_s: How long to allow the boot probe to warm.
:returns: Option texts, whitespace-flattened.
"""
self.page.get_by_test_id(test_id).click()
deadline = time.monotonic() + warmup_timeout_s
def _texts() -> list[str]:
return [
re.sub(r"\s+", " ", opt.inner_text().strip())
for opt in self.page.get_by_role("option").all()
]
while True:
self.page.wait_for_timeout(500)
texts = _texts()
if any(not t.lower().startswith(("default", "smart routing")) for t in texts):
return texts
if time.monotonic() >= deadline:
return texts
def pick_dropdown_option(self, needle: str) -> None:
"""Click the open dropdown's first option containing *needle*."""
for opt in self.page.get_by_role("option").all():
if needle.lower() in opt.inner_text().lower():
opt.click()
self.page.wait_for_timeout(300)
return
raise AssertionError(f"dropdown option containing {needle!r} not found")
def close_dropdown(self) -> None:
"""Dismiss an open dropdown without touching the modal."""
self.page.keyboard.press("Escape")
self.page.wait_for_timeout(200)
def save_landing_config(self) -> None:
"""Save the landing config modal."""
self.page.get_by_test_id("new-chat-landing-config-save").click()
self.page.wait_for_timeout(400)
def submit_new_chat(self, prompt: str, timeout_s: float = 150.0) -> str:
"""Type *prompt*, submit, and wait for the session route.
The wait must be a PLAYWRIGHT wait (`wait_for_url`), not a
sleep-poll of ``page.url``: the sync API pumps its event dispatch
only inside playwright calls, so a plain ``time.sleep`` loop reads a
URL frozen at the landing route forever the navigation this waits
for was landing within seconds while the poll never saw it. The
budget still covers a COLD create (host launch + harness boot).
:returns: The new session/conversation id.
"""
self.page.get_by_test_id("new-chat-landing-input").fill(prompt)
self.page.get_by_test_id("new-chat-landing-submit").click()
self.page.wait_for_url(re.compile(r"/c/[a-z0-9_-]+", re.I), timeout=timeout_s * 1000)
match = re.search(r"/c/([a-z0-9_-]+)", self.page.url, re.I)
assert match is not None, f"no session id in {self.page.url!r}"
return match.group(1)
def open_session(self, session_id: str) -> None:
"""Navigate to a session's chat page."""
self.page.goto(f"{self.base_url}/c/{session_id}", wait_until="domcontentloaded")
self.page.get_by_test_id("composer-config-gear").wait_for(state="visible", timeout=30_000)
def composer_label(self) -> str:
"""Visible composer model/effort chip text ("" when absent)."""
label = self.page.get_by_test_id("composer-model-effort-label")
if label.count() == 0:
return ""
return re.sub(r"\s+", " ", label.first.inner_text().strip())
def wait_composer_label(self, pattern: str, timeout_s: float = 60.0) -> str:
"""Wait until the composer chip matches *pattern* (regex)."""
def _match() -> str | None:
text = self.composer_label()
return text if re.search(pattern, text) else None
return wait_for(_match, timeout=timeout_s, what=f"composer label {pattern!r}")
def open_gear(self) -> None:
"""Open the in-session config gear modal."""
self.page.get_by_test_id("composer-config-gear").click()
self.page.get_by_test_id("composer-config-model").wait_for(state="visible", timeout=15_000)
def gear_model_label(self) -> str:
"""Visible text of the gear's model select trigger."""
return self.page.get_by_test_id("composer-config-model").inner_text().strip()
def gear_rows(self) -> list[dict[str, str]]:
"""Open the gear model dropdown and return its catalog rows.
:returns: ``[{"id": ..., "text": ..., "active": ...}, ...]`` for every
``data-model-id`` row (sentinels excluded).
"""
self.page.get_by_test_id("composer-config-model").click()
self.page.wait_for_timeout(500)
rows = []
for opt in self.page.locator('[role="option"][data-model-id]').all():
rows.append(
{
"id": opt.get_attribute("data-model-id") or "",
"text": re.sub(r"\s+", " ", opt.inner_text().strip()),
"active": opt.get_attribute("data-active") or "false",
}
)
return rows
def save_gear(self) -> None:
"""Save the gear modal."""
self.page.get_by_test_id("composer-config-save").click()
self.page.wait_for_timeout(400)
@contextmanager
def browser_ui(base_url: str) -> Iterator[Ui]:
"""Launch a headless browser over the rig SPA.
:param base_url: The rig server base URL.
:yields: The :class:`Ui` driver.
"""
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
browser = pw.chromium.launch()
page = browser.new_context(viewport={"width": 1440, "height": 950}).new_page()
if os.environ.get("OMNIGENT_E2E_MODEL_FLOWS_TRACE") == "1":
# Debug tap: print API traffic, console errors, and failed
# requests so a stalled flow can be attributed from the test log.
page.on(
"response",
lambda resp: (
print(f"[trace] {resp.status} {resp.request.method} {resp.url}")
if "/v1/" in resp.url
else None
),
)
page.on(
"requestfailed",
lambda req: print(f"[trace] REQFAIL {req.method} {req.url} :: {req.failure}"),
)
page.on(
"console",
lambda msg: (
print(f"[trace] CONSOLE[{msg.type}] {msg.text[:200]}")
if msg.type == "error"
else None
),
)
page.on(
"framenavigated",
lambda frame: (
print(f"[trace] NAV {frame.url}") if frame == page.main_frame else None
),
)
try:
yield Ui(page, base_url)
finally:
browser.close()
def session_snapshot(base_url: str, session_id: str) -> dict[str, Any]:
"""Secondary probe: the session's REST snapshot.
:param base_url: Rig server base URL.
:param session_id: Session id.
:returns: The parsed snapshot.
"""
resp = httpx.get(f"{base_url}/v1/sessions/{session_id}", timeout=30)
resp.raise_for_status()
return dict(resp.json())
def host_model_options(base_url: str, host_id: str, harness: str) -> dict[str, Any]:
"""Secondary probe: the pre-launch model options for a harness.
:returns: The parsed ``{"models": [...], ...}`` payload.
"""
resp = httpx.get(
f"{base_url}/v1/hosts/{host_id}/harnesses/{harness}/model-options", timeout=60
)
resp.raise_for_status()
return dict(resp.json())
def dismiss_blocking_dialogs(pane: PaneWatcher, attempts: int = 3) -> None:
"""Best-effort Escape past first-run dialogs so the input box is usable.
:param pane: The session's pane.
:param attempts: Escape presses.
"""
for _ in range(attempts):
text = pane.capture()
if "Esc to cancel" in text or "Enter to confirm" in text or "to change usage" in text:
pane.send_key("Escape")
time.sleep(1.0)
else:
return
def bypass_args(harness: str) -> list[str]:
"""First-run bypass launch args per harness (mirrors the routing suite).
:param harness: ``"claude-native"`` or ``"codex-native"``.
:returns: ``terminal_launch_args`` for the create call.
"""
if harness == "claude-native":
return ["--dangerously-skip-permissions"]
return ["--dangerously-bypass-approvals-and-sandbox"]
def read_json(path: Path) -> dict[str, Any] | None:
"""Parse a JSON file, returning ``None`` on absence or damage."""
try:
return dict(json.loads(path.read_text()))
except (OSError, ValueError):
return None
+725
View File
@@ -0,0 +1,725 @@
"""Live model-flow CUJs (model-flows-design.md §10.1, the "UI-live" tier).
Every test drives the product the way a person uses it the browser drives the
rig server's real SPA (or, for the rows whose actor is a REPL, a routing pin,
or an API client, the same REST calls), and harness truth is read from the
tmux pane with REST snapshots as secondary probes only. Assertions encode the DESIGN's target
behavior, so this suite is red on unmodified main (and partially red on the PR
branch) in exactly the ways `model-flows-report.md` cites, and goes green as
the landing-order steps land. Run one row's red twin with::
OMNIGENT_E2E_MODEL_FLOWS=1 OMNIGENT_E2E_MODEL_FLOWS_REPO=~/omnigent \\
pytest tests/e2e/omnigent/test_model_flows_live.py -k row1 -x
Rows are numbered after the design's §10.1 table.
"""
from __future__ import annotations
import re
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from omnigent.native_coding_agents import CLAUDE_NATIVE_AGENT_NAME, CODEX_NATIVE_AGENT_NAME
from tests.e2e.omnigent._model_flows_rig import (
ModelFlowsRig,
PaneWatcher,
Ui,
assistant_message_count,
booted_rig,
browser_ui,
bypass_args,
codex_config_copy_model,
dismiss_blocking_dialogs,
host_model_options,
kill_pane,
require_clis,
require_opt_in,
rest_create_session,
rest_patch_session,
rest_post_user_message,
session_snapshot,
shape_providers,
)
from tests.e2e.routing._helpers import wait_for
pytestmark = [pytest.mark.live_model_flows, pytest.mark.posix_only]
#: A version-free family word is not enough — resolved names carry versions.
_VERSIONED = re.compile(r"\d")
@pytest.fixture(autouse=True, scope="module")
def _opt_in() -> None:
require_opt_in()
require_clis("tmux")
@pytest.fixture(scope="module")
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[ModelFlowsRig]:
"""One rig server for the module; hosts restart per shape."""
with booted_rig(tmp_path_factory.mktemp("model_flows_rig")) as booted:
yield booted
@pytest.fixture(scope="module")
def shaped_host(rig: ModelFlowsRig) -> Callable[[str], str]:
"""Return ``ensure(shape) -> host_id``, restarting the host on change."""
current: dict[str, str] = {}
def _ensure(shape: str) -> str:
if current.get("shape") != shape:
rig.start_host(shape_providers(shape))
current["shape"] = shape
return rig.host_id
return _ensure
def _create_session(
ui: Ui,
agent_label: str,
*,
pick: str | None = None,
prompt: str = "Reply with exactly: ok. Nothing else.",
) -> tuple[str, PaneWatcher]:
"""Create a session through the landing screen, watching for its pane.
:param ui: The browser driver, already on the landing screen.
:param agent_label: Agent dropdown label ("Claude Code" / "Codex").
:param pick: Optional model-dropdown entry substring to pick; ``None``
leaves Default.
:returns: ``(session_id, pane)`` with the pane discovered.
"""
ui.pick_agent(agent_label)
if pick is not None:
ui.open_landing_config()
ui.open_model_dropdown("new-chat-landing-config-model")
ui.pick_dropdown_option(pick)
ui.save_landing_config()
pane = PaneWatcher()
pane.arm()
session_id = ui.submit_new_chat(prompt)
pane.wait_for_pane()
return session_id, pane
# ---------------------------------------------------------------------------
# Shape: claude · subscription
# ---------------------------------------------------------------------------
def test_row1_claude_picker_lists_resolved_versioned_names(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""New chat → claude dropdown shows resolved names, never "Sonnet 4.6"."""
require_clis("claude")
shaped_host("claude-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
ui.pick_agent("Claude Code")
ui.open_landing_config()
options = ui.open_model_dropdown("new-chat-landing-config-model")
rows = [opt for opt in options if not opt.lower().startswith("default")]
assert rows, "the claude model dropdown offered no rows at all"
assert not any("4.6" in opt for opt in options), (
f"a frozen 'Sonnet 4.6' label leaked into the picker: {options}"
)
versioned = [opt for opt in rows if _VERSIONED.search(opt)]
assert versioned, (
f"no row carries a resolved version — the static alias table is still "
f"serving the picker: {options}"
)
assert any("1M context" in opt for opt in rows), (
f"the 1M-context variants are missing from the probed catalog: {options}"
)
def test_row6_claude_default_label_names_the_true_default(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""The claude picker reads "Default (X)" where X is what a bare launch runs."""
require_clis("claude")
shaped_host("claude-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
ui.pick_agent("Claude Code")
ui.open_landing_config()
label = ui.wait_landing_model_label(r"Default \(.+\)|Models unavailable")
assert re.fullmatch(r"Default \(.+\)", label), (
f"claude's Default entry reads {label!r} — the truthful default name is "
"missing (the web discards isDefault, or no row carries it)"
)
def test_row7_default_create_chip_equals_pane_truth(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""A Default create's composer chip appears and matches the pane footer."""
require_clis("claude")
shaped_host("claude-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Claude Code")
chip = ui.wait_composer_label(r"\S", timeout_s=90)
pane_text = pane.wait_for_text(r"")
# The pane footer names the model on its status line; the chip must carry
# the same family AND version (e.g. footer "Opus 4.8 (1M context)" must not
# pair with a chip claiming "Opus 5 ...").
footer_line = next(
(line for line in pane_text.splitlines() if "" in line and "context" in line.lower()),
None,
) or next((line for line in pane_text.splitlines() if "" in line), "")
footer_model = footer_line.split("")[0].strip()
assert footer_model, f"could not read a model from the pane footer: {pane_text[-400:]}"
family = footer_model.split()[0]
assert family.lower() in chip.lower(), (
f"composer chip {chip!r} does not carry the pane's model family {footer_model!r}"
)
version = re.search(r"\d+(\.\d+)?", footer_model)
if version:
assert version.group(0) in chip, (
f"composer chip {chip!r} claims a different version than the pane "
f"footer {footer_model!r} (session {session_id})"
)
def test_row13_reported_model_renders_verbatim_not_family_collapsed(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""The gear highlights exactly the model the pane reports — generation too.
On this machine a bare subscription launch runs the settings-file pin
(Opus 4.8 (1M)); the picker must render that exact model as selected (as
its own appended row when the alias catalog lacks it), never relabel it as
the alias catalog's Opus 5.
"""
require_clis("claude")
shaped_host("claude-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Claude Code")
pane_text = pane.wait_for_text(r"")
footer_line = next((line for line in pane_text.splitlines() if "" in line), "")
footer_model = footer_line.split("")[0].strip()
version = re.search(r"\d+(\.\d+)?", footer_model)
ui.wait_composer_label(r"\S", timeout_s=90)
ui.open_gear()
rows = ui.gear_rows()
active = [row for row in rows if row["active"] == "true"]
assert active, f"no gear row is highlighted (rows: {rows}, session {session_id})"
if version:
assert version.group(0) in active[0]["text"], (
f"the highlighted row {active[0]} claims a different generation than "
f"the pane's {footer_model!r} — the mirror family-collapsed the report"
)
def test_row16_terminal_switch_mirrors_exactly(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""Typing /model haiku in the pane highlights exactly Haiku 4.5 in the web."""
require_clis("claude")
shaped_host("claude-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Claude Code")
pane.wait_for_text(r"")
dismiss_blocking_dialogs(pane)
pane.type_line("/model haiku")
pane.wait_for_text(r"Haiku", timeout=30)
chip = ui.wait_composer_label(r"[Hh]aiku", timeout_s=30)
assert "haiku" in chip.lower(), f"chip never mirrored the terminal switch: {chip!r}"
snapshot = session_snapshot(rig.base_url, session_id)
reported = snapshot.get("llm_model") or ""
assert "haiku" in str(reported).lower(), (
f"the session record never learned the terminal-side switch "
f"(llm_model={reported!r}, model_override={snapshot.get('model_override')!r})"
)
def test_row14_web_switch_confirms_against_the_pane(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""A gear pick flips the chip only once the pane actually switched."""
require_clis("claude")
shaped_host("claude-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Claude Code")
pane.wait_for_text(r"")
dismiss_blocking_dialogs(pane)
ui.wait_composer_label(r"\S", timeout_s=90)
ui.open_gear()
ui.gear_rows()
ui.pick_dropdown_option("Sonnet 5")
ui.save_gear()
chip = ui.wait_composer_label(r"[Ss]onnet", timeout_s=45)
pane_text = pane.wait_for_text(r"Sonnet", timeout=45)
assert "sonnet" in chip.lower(), f"the chip never settled on the pick: {chip!r}"
assert "Sonnet" in pane_text, (
f"the pane never switched although the web claims it did (session {session_id})"
)
def test_row15_swallowed_injection_surfaces_an_error(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""A pane dialog eats /model → the web surfaces failure instead of lying.
The pane is parked inside claude's own interactive /model picker dialog, so
the injected switch command lands in that dialog and never executes. The
design's contract: the record must not silently claim the new model — the
web either surfaces a model_change_not_applied error or keeps reporting the
pane's real model.
"""
require_clis("claude")
shaped_host("claude-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Claude Code")
pane.wait_for_text(r"")
dismiss_blocking_dialogs(pane)
baseline = ui.wait_composer_label(r"\S", timeout_s=90)
# Park the pane in claude's own model-picker dialog.
pane.type_line("/model")
time.sleep(3.0)
ui.open_gear()
ui.gear_rows()
ui.pick_dropdown_option("Haiku")
ui.save_gear()
time.sleep(10.0)
chip_after = ui.composer_label()
pane_after = pane.capture()
pane.send_key("Escape")
pane_switched = "Haiku" in pane_after
if not pane_switched:
assert "haiku" not in chip_after.lower(), (
f"the pane never switched (dialog swallowed /model) but the web now "
f"claims Haiku — silent record/pane divergence (was {baseline!r}, "
f"session {session_id})"
)
def test_row17_model_and_effort_apply_together(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""One gear save changing model + effort applies both, in order."""
require_clis("claude")
shaped_host("claude-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Claude Code")
pane.wait_for_text(r"")
dismiss_blocking_dialogs(pane)
ui.wait_composer_label(r"\S", timeout_s=90)
ui.open_gear()
ui.gear_rows()
# Selecting an option closes the Radix dropdown itself; pressing
# Escape afterwards would close the whole gear modal.
ui.pick_dropdown_option("Sonnet 5")
ui.page.get_by_test_id("composer-config-effort").click()
# Pick a level that DIFFERS from the ambient default (this
# machine runs "high" globally): the save skips unchanged knobs.
ui.pick_dropdown_option("Medium")
ui.save_gear()
pane_text = pane.wait_for_text(r"Sonnet", timeout=45)
# The save serializes its legs IN THE PAGE and the model leg holds
# until the pane CONFIRMS the switch (the design's step-6 contract),
# so the effort PATCH is sent by the browser seconds after "Sonnet"
# appears — the browser must stay open while we poll for it.
effort = wait_for(
lambda: session_snapshot(rig.base_url, session_id).get("reasoning_effort"),
timeout=45.0,
what="the effort override to persist",
)
assert "Sonnet" in pane_text
assert str(effort).lower() == "medium", f"the effort half of the save was lost: {effort!r}"
# ---------------------------------------------------------------------------
# Shape: claude · gateway kind
# ---------------------------------------------------------------------------
def test_row4_claude_gateway_rows_are_labeled_not_raw(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""The gateway picker shows harness-labeled rows, never a raw wire id."""
require_clis("claude")
shaped_host("claude-gateway")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
ui.pick_agent("Claude Code")
ui.open_landing_config()
options = ui.open_model_dropdown("new-chat-landing-config-model")
rows = [opt for opt in options if not opt.lower().startswith("default")]
assert rows, "the gateway claude picker offered no rows"
raw = [opt for opt in rows if "system.ai." in opt or "databricks-" in opt]
assert not raw, f"raw wire spellings render as display names on the gateway shape: {raw}"
def test_row8_explicit_pick_runs_exactly_the_pick(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""Creating with an explicit pick launches the pane on exactly that model."""
require_clis("claude")
shaped_host("claude-gateway")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
ui.pick_agent("Claude Code")
ui.open_landing_config()
options = ui.open_model_dropdown("new-chat-landing-config-model")
rows = [opt for opt in options if not opt.lower().startswith("default")]
assert rows, "no rows to pick from"
ui.pick_dropdown_option(rows[0])
ui.save_landing_config()
pane = PaneWatcher()
pane.arm()
session_id = ui.submit_new_chat("Reply with exactly: ok. Nothing else.")
pane.wait_for_pane()
pane_text = pane.wait_for_text(r"")
footer_line = next((line for line in pane_text.splitlines() if "" in line), "")
footer_model = footer_line.split("")[0].strip()
picked_version = re.search(r"\d+(\.\d+)?", rows[0])
if picked_version:
assert picked_version.group(0) in footer_model, (
f"picked {rows[0]!r} but the pane runs {footer_model!r} "
f"(session {session_id}) — the launch substituted another model"
)
# ---------------------------------------------------------------------------
# Shape: codex · databricks kind (gateway)
# ---------------------------------------------------------------------------
def test_row2_codex_databricks_picker_is_populated_and_decorated(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""The codex gateway dropdown is non-empty with decorated names + default."""
require_clis("codex")
shaped_host("codex-databricks")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
ui.pick_agent("Codex")
ui.open_landing_config()
label = ui.wait_landing_model_label(r"Default \(.+\)|Models unavailable")
options = ui.open_model_dropdown("new-chat-landing-config-model")
rows = [opt for opt in options if not opt.lower().startswith("default")]
assert rows, "the codex databricks-kind picker is EMPTY — finding C's failure mode"
assert any("GPT" in opt for opt in rows), (
f"rows are not decorated with codex's display names: {options}"
)
assert re.fullmatch(r"Default \(.+\)", label), (
f"no truthful default label on the codex gateway shape: {label!r}"
)
def test_row5_host_boot_is_warm_and_nonblocking(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""Host boot: online immediately; picker rows present with no session."""
require_clis("codex", "claude")
started = time.monotonic()
host_id = shaped_host("codex-databricks")
online_after = time.monotonic() - started
# Registration must not wait on the probes (claude's alone is ~6s+).
assert online_after < 45.0, f"host took {online_after:.1f}s to register"
def _codex_rows() -> list | None:
import httpx
try:
payload = host_model_options(rig.base_url, host_id, "codex-native")
except httpx.HTTPError:
# A failed/erroring answer is a not-yet (or never) — keep polling;
# the timeout records the red.
return None
return payload.get("models") or None
rows = wait_for(_codex_rows, timeout=120.0, what="boot-warmed codex rows")
assert rows, "the boot probe never produced codex rows"
def test_row11_gear_rows_equal_new_chat_rows(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""Picker↔gear parity: the in-session gear offers the new-chat rows."""
require_clis("codex")
host_id = shaped_host("codex-databricks")
prelaunch = {
row["id"]
for row in host_model_options(rig.base_url, host_id, "codex-native").get("models", [])
if isinstance(row, dict) and row.get("id")
}
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Codex")
pane.wait_for_text(r"")
ui.wait_composer_label(r"\S", timeout_s=90)
ui.open_gear()
gear = {row["id"] for row in ui.gear_rows()}
assert prelaunch, "pre-launch rows empty; parity is unmeasurable"
missing = prelaunch - gear
assert not missing, f"gear lacks pre-launch rows {missing} (gear={gear}, session {session_id})"
def test_row7_codex_chip_matches_config_copy_and_pane(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""Codex Default create: chip, config-copy pin, and pane footer agree."""
require_clis("codex")
shaped_host("codex-databricks")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Codex")
pane_text = pane.wait_for_text(r"")
chip = ui.wait_composer_label(r"\S", timeout_s=90)
footer = next(
(line.strip() for line in reversed(pane_text.splitlines()) if "default" in line.lower()),
"",
)
pinned = codex_config_copy_model(session_id)
assert pinned, "the launch left no model = pin in the private config copy"
comparable = pinned.replace("databricks-", "").replace("-", "").replace(".", "").lower()
chip_flat = chip.replace("-", "").replace(".", "").lower()
assert comparable in chip_flat or chip_flat in comparable, (
f"chip {chip!r} disagrees with the config-copy pin {pinned!r} "
f"(pane footer: {footer!r}, session {session_id})"
)
# ---------------------------------------------------------------------------
# Shape: codex · subscription
# ---------------------------------------------------------------------------
def test_row3_codex_subscription_rows_come_from_the_account(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""The subscription picker shows the account's own catalog, marked default."""
require_clis("codex")
shaped_host("codex-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
ui.pick_agent("Codex")
ui.open_landing_config()
label = ui.landing_model_label()
options = ui.open_model_dropdown("new-chat-landing-config-model")
rows = [opt for opt in options if not opt.lower().startswith("default")]
assert rows, "the codex subscription picker is empty"
assert any("GPT" in opt for opt in rows), (
f"subscription rows are undecorated ids — the frozen tuple, not the "
f"account catalog: {options}"
)
assert re.fullmatch(r"Default \(.+\)", label), (
f"no truthful default label on the subscription shape: {label!r}"
)
def test_row9_codex_subscription_default_first_turn_succeeds(
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
) -> None:
"""A Default subscription create's first turn returns a reply, not a 400."""
require_clis("codex")
shaped_host("codex-subscription")
with browser_ui(rig.base_url) as ui:
ui.open_landing()
session_id, pane = _create_session(ui, "Codex")
def _turn_settled() -> str | None:
text = pane.capture()
if "invalid_request_error" in text or "not supported" in text:
return text
if re.search(r"^\s*•", text, re.M):
return text
return None
settled = wait_for(_turn_settled, timeout=120.0, what="the first turn to settle")
assert "invalid_request_error" not in settled and "not supported" not in settled, (
f"the Default launch pinned a model the account cannot serve — the "
f"stale-config-line class (finding B). Pane:\n{settled[-600:]}\n"
f"(config-copy pin: {codex_config_copy_model(session_id)!r})"
)
# ---------------------------------------------------------------------------
# Cold resume: the persisted pick survives the pane (claude + codex)
# ---------------------------------------------------------------------------
#: Host shape, wrapper agent, and pane-ready marker per harness.
_COLD_RESUME_HARNESSES: dict[str, tuple[str, str, str]] = {
"claude-native": ("claude-subscription", CLAUDE_NATIVE_AGENT_NAME, r""),
"codex-native": ("codex-subscription", CODEX_NATIVE_AGENT_NAME, r""),
}
def _resume_override(harness: str, rows: list[dict[str, Any]]) -> str | None:
"""
The model to persist, spelled the way the harness itself reports it.
claude: a canonical Anthropic id no row spells exactly the plain twin of
a listed ``[1m]`` model. The endpoint serves it (the 1M marker is a request
flag on the same model) and the pane reports exactly that id after a
``/model`` to it; only the catalog's alias rows know its family.
codex: a non-default row id codex has no alias layer, so the harness's
own report IS a catalog id.
:param harness: ``"claude-native"`` or ``"codex-native"``.
:param rows: The host's pre-launch catalog rows.
:returns: The override to persist, or ``None`` when the catalog offers
no such spelling.
"""
listed = {str(v) for row in rows for v in (row.get("id"), row.get("model")) if v}
if harness == "claude-native":
for row in rows:
model = str(row.get("model") or "")
if model.startswith("claude-") and model.endswith("[1m]") and model[:-4] not in listed:
return model[:-4]
return None
for row in rows:
if row.get("isDefault") is True or row.get("hidden") is True:
continue
row_id = row.get("id")
if isinstance(row_id, str) and row_id:
return row_id
return None
def _same_model(reported: object, wanted: str) -> bool:
"""
Whether a harness report names *wanted* (a dated suffix allowed)."""
if not isinstance(reported, str) or not reported:
return False
lhs, rhs = reported.lower(), wanted.lower()
return lhs == rhs or lhs.startswith(f"{rhs}-")
@pytest.mark.timeout(900)
@pytest.mark.parametrize("harness", sorted(_COLD_RESUME_HARNESSES))
def test_row18_cold_resume_honors_the_persisted_model_override(
rig: ModelFlowsRig,
shaped_host: Callable[[str], str],
harness: str,
tmp_path: Path,
) -> None:
"""
A session resumes on the persisted model the harness was already running.
The pane exits (idle reap, host restart) and the next message re-creates
the terminal. The relaunch must pass the persisted model straight through
the host demonstrably served it moments earlier instead of refusing
it as "not in this host's current model list" because the catalog spells
the same model differently (an alias row carries the family, the override
the canonical id).
"""
shape, agent_name, ready = _COLD_RESUME_HARNESSES[harness]
require_clis(harness.split("-")[0])
host_id = shaped_host(shape)
def _rows() -> list[dict[str, Any]] | None:
try:
payload = host_model_options(rig.base_url, host_id, harness)
except httpx.HTTPError:
return None
return payload.get("models") or None
rows = wait_for(_rows, timeout=180.0, what=f"the boot-warmed {harness} catalog")
override = _resume_override(harness, rows)
if override is None:
pytest.skip(f"this {harness} catalog offers no spelling to exercise: {rows}")
workspace = tmp_path / "ws"
workspace.mkdir()
(workspace / "README.md").write_text("# cold resume workspace\n")
pane = PaneWatcher()
pane.arm()
session_id = rest_create_session(
rig.base_url,
agent_name=agent_name,
host_id=host_id,
workspace=workspace,
terminal_launch_args=bypass_args(harness),
)
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: ok. Nothing else.")
pane.wait_for_pane()
pane.wait_for_text(ready)
dismiss_blocking_dialogs(pane)
def _replies(at_least: int) -> Callable[[], int | None]:
def _count() -> int | None:
snapshot = session_snapshot(rig.base_url, session_id)
assert snapshot.get("status") != "failed", (
f"session {session_id} failed: "
f"{snapshot.get('last_task_error') or snapshot.get('error')}"
)
count = assistant_message_count(snapshot)
return count if count >= at_least else None
return _count
def _reported_override() -> str | None:
reported = session_snapshot(rig.base_url, session_id).get("llm_model")
return str(reported) if _same_model(reported, override) else None
wait_for(_replies(1), timeout=150.0, what="the first reply")
# The actor is a REPL ``/model``, a routing pin, or an API client: they
# persist the harness's own spelling. The pane switches live — this host
# serves the model — and reports it back verbatim.
rest_patch_session(rig.base_url, session_id, model_override=override)
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: switched. Nothing else.")
wait_for(_replies(2), timeout=150.0, what="the reply on the switched model")
wait_for(_reported_override, timeout=60.0, what=f"the harness to report {override!r}")
# The pane exits; the next message is the resume.
kill_pane(pane)
resumed = PaneWatcher()
resumed.arm()
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: back. Nothing else.")
wait_for(_replies(3), timeout=240.0, what="the reply after the cold resume")
snapshot = session_snapshot(rig.base_url, session_id)
assert snapshot.get("model_override") == override, (
f"the resume rewrote the persisted pick: {snapshot.get('model_override')!r}"
)
assert _same_model(snapshot.get("llm_model"), override), (
f"the resumed pane runs {snapshot.get('llm_model')!r}, not the persisted "
f"{override!r} (session {session_id})"
)
resumed.wait_for_pane()
pane_text = resumed.wait_for_text(ready)
if harness == "codex-native":
pinned = codex_config_copy_model(session_id)
assert pinned == override, (
f"the relaunch pinned {pinned!r} in the config copy, not {override!r}"
)
else:
family = override.removeprefix("claude-").split("-")[0]
footer = next((line for line in pane_text.splitlines() if "" in line), "")
assert family in footer.lower(), (
f"the resumed pane footer {footer!r} does not name the {family} family "
f"of {override!r} (session {session_id})"
)
+2 -2
View File
@@ -352,7 +352,7 @@ def test_managed_runner_callback_authenticates_end_to_end(
monkeypatch.setenv("RUNNER_SERVER_URL", base_url)
monkeypatch.setenv(RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR, _BINDING_TOKEN)
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url: None)
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url, **_kw: None)
monkeypatch.setattr(
"omnigent.inner.databricks_executor._resolve_databricks_auth",
_no_databricks_creds,
@@ -423,7 +423,7 @@ def test_managed_runner_survives_mint_403_after_token_expiry(
monkeypatch.setenv(RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR, _BINDING_TOKEN)
monkeypatch.setenv(RUNNER_INITIAL_AUTH_TOKEN_ENV_VAR, owner_cookie)
monkeypatch.setenv(RUNNER_DELEGATED_AUTH_ENV_VAR, "1")
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url: owner_cookie)
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url, **_kw: owner_cookie)
factory = _make_auth_token_factory()
assert isinstance(factory, _InitialAuthTokenFactory)
+8 -7
View File
@@ -722,14 +722,15 @@ def test_repl_approve_always_caches_for_later_turns(
_read_pending(child, seconds=1.5)
# Turn 2: the auto-approved audit line must appear
# AND the banner must NOT. After .expect() lands on
# the elapsed-time marker, ``child.before`` holds the
# full span from the last expect up to (but not
# including) the match. That's the whole turn 2
# output — banner (if any) + auto-approved line (if
# any) + LLM response + elapsed-time prefix.
# AND the banner must NOT. Sync on the scripted reply text
# (deterministic content) instead of the racy ``· ready``
# toolbar marker, which can match prematurely before the
# turn's output renders. The scripted response only appears
# after the LLM call completes, proving the full turn has
# rendered (including the "auto-approved" audit line that
# must precede it).
child.send("follow up please" + "\r")
_wait_for_turn_complete(child, timeout=45)
child.expect("Following up as requested", timeout=45)
turn_two_raw = child.before or ""
if isinstance(turn_two_raw, bytes):
turn_two_raw = turn_two_raw.decode("utf-8", errors="replace")
+256 -9
View File
@@ -48,6 +48,7 @@ def _patch_session_as_claude_native(
model_options: list[dict] | None = None,
llm_model: str = "system.ai.claude-sonnet-5",
host_asleep: bool = False,
permission_mode: str | None = None,
) -> list[dict]:
"""Patch the browser's session snapshot into a claude-native response.
@@ -66,10 +67,14 @@ def _patch_session_as_claude_native(
:param host_asleep: Shape the snapshot like a dormant resumable managed
host (host-bound, resumable, aged past the startup grace); pair with
:func:`_force_asleep_liveness` to drive the ``host_asleep`` state.
:param permission_mode: Initial ``omnigent.claude_native.permission_mode``
label value; required for the permission-mode picker to appear.
:returns: Captured PATCH request bodies.
"""
latest_payload: dict | None = None
patch_bodies: list[dict] = []
# Mutable so the PATCH handler can update it without rebinding the name.
cur_permission_mode: list[str | None] = [permission_mode]
def _handle(route: Route) -> None:
nonlocal latest_payload
@@ -90,13 +95,19 @@ def _patch_session_as_claude_native(
payload = dict(latest_payload or {})
if "model_override" in request_body:
payload["model_override"] = request_body["model_override"]
if "permission_mode" in request_body:
cur_permission_mode[0] = request_body["permission_mode"]
else:
route.continue_()
return
extra_labels: dict[str, str] = {}
if cur_permission_mode[0] is not None:
extra_labels["omnigent.claude_native.permission_mode"] = cur_permission_mode[0]
payload["labels"] = {
**payload.get("labels", {}),
"omnigent.wrapper": "claude-code-native-ui",
**extra_labels,
}
payload["harness"] = "claude"
payload["llm_model"] = llm_model
@@ -167,7 +178,13 @@ def test_claude_native_picker_updates_after_delayed_catalog(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""A live catalog event fills the modal and applies a compatible sticky alias."""
"""A live catalog event fills the modal; the sticky stays a preference.
The catalog's arrival populates the picker rows and lets the label
resolve the reported model to its display name. The cross-session
sticky pick is never silently PATCHed onto the session a request
exists only when the user explicitly picks.
"""
base_url, session_id = seeded_session
catalog_state = {"ready": False}
patch_bodies = _patch_session_as_claude_native(
@@ -221,8 +238,10 @@ def test_claude_native_picker_updates_after_delayed_catalog(
{"sessionId": session_id},
)
expect(label).to_contain_text("Opus 4.10", timeout=10_000)
assert {"model_override": "opus", "silent": True} in patch_bodies
# The catalog labels the reported model; the sticky ("opus") is never
# silently written as a request.
expect(label).to_contain_text("Sonnet 5", timeout=10_000)
assert not any("model_override" in body for body in patch_bodies)
page.get_by_test_id("composer-config-gear").click()
page.get_by_test_id("composer-config-model").click()
expect(page.locator('[role="option"][data-model-id]')).to_have_count(len(_EXPECTED_ROWS))
@@ -232,7 +251,11 @@ def test_claude_native_alias_selection_persists(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Picking Opus PATCHes its alias and the label shows the live name.
"""Picking Opus PATCHes its row id; the label keeps the reported model.
The pick is a REQUEST it persists verbatim as ``model_override``
while the composer label keeps rendering the harness's reported model
until a confirmation report arrives.
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` for a real server-backed
@@ -261,8 +284,9 @@ def test_claude_native_alias_selection_persists(
page.get_by_test_id("composer-config-save").click()
assert patch_bodies[-1] == {"model_override": "opus"}
# The read-only composer label reflects the new pick.
expect(page.get_by_test_id("composer-model-effort-label")).to_contain_text("Opus 4.10")
# The read-only composer label keeps the reported model — a request is
# not truth until the harness confirms it.
expect(page.get_by_test_id("composer-model-effort-label")).to_contain_text("Sonnet 5")
def _force_asleep_liveness(page: Page, session_id: str) -> None:
@@ -608,11 +632,183 @@ def test_composer_model_label_never_shows_the_previous_sessions_model(
)
def test_claude_native_picker_prefers_session_override_over_sticky_model(
def test_claude_model_label_never_claims_a_version_the_catalog_didnt_give(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The active row follows the session override, not another session's pick."""
"""The label renders the reported model — raw before the catalog labels it.
The chip renders only the harness's reported model: the raw wire id
until the catalog can name it, the catalog's display name after — and
at no point a version the catalog didn't give (the old fallback said
"Sonnet 4.6" while the catalog resolves to Sonnet 5). Every label the
page ever paints is recorded, so a transient wrong label can't hide
from a retrying ``expect()``.
"""
base_url, session_id = seeded_session
catalog_state = {"ready": False}
one_m_catalog = [
*_MODEL_OPTIONS,
{
"id": "sonnet[1m]",
"model": "system.ai.claude-sonnet-5[1m]",
"displayName": "Sonnet 5 (1M context)",
"isDefault": False,
},
]
_patch_session_as_claude_native(
page,
session_id,
model_override="sonnet[1m]",
catalog_state=catalog_state,
model_options=one_m_catalog,
llm_model="system.ai.claude-sonnet-5[1m]",
)
stream_script = """
(() => {
const sessionId = __SESSION_ID__;
const originalFetch = window.fetch.bind(window);
window.fetch = (input, init) => {
const url = typeof input === "string" ? input : input.url;
const streamPath = `/v1/sessions/${sessionId}/stream`;
if (new URL(url, window.location.origin).pathname === streamPath) {
const body = new ReadableStream({
start(controller) {
window.__claudeModelStreamController = controller;
},
});
return Promise.resolve(new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
}));
}
return originalFetch(input, init);
};
})()
""".replace("__SESSION_ID__", json.dumps(session_id))
page.add_init_script(stream_script)
page.add_init_script(_LABEL_RECORDER)
page.goto(f"{base_url}/c/{session_id}")
# Pre-catalog: the reported wire id renders raw — honest over pretty.
label = page.get_by_test_id("composer-model-effort-label")
expect(label).to_contain_text("system.ai.claude-sonnet-5[1m]", timeout=15_000)
page.wait_for_function("window.__claudeModelStreamController !== undefined")
# The catalog lands: its display name supersedes the fallback.
catalog_state["ready"] = True
page.evaluate(
"""
({ sessionId }) => {
const frame = `event: session.model_options\ndata: ${JSON.stringify({
conversation_id: sessionId,
})}\n\n`;
window.__claudeModelStreamController.enqueue(new TextEncoder().encode(frame));
}
""",
{"sessionId": session_id},
)
expect(label).to_contain_text("Sonnet 5 (1M context)", timeout=10_000)
log = page.evaluate("window.__modelLabelLog")
labels = [entry["text"] for entry in log if entry["text"]]
assert labels, "the recorder never saw a composer label"
offending = [text for text in labels if "4.6" in text]
assert not offending, (
f"the composer painted a version the catalog didn't give: {offending} "
f"(full label sequence: {labels}). Labels render the reported model — "
"raw before the catalog names it, the catalog's name after — never an "
"invented version."
)
_screenshot(page, "one-m-label-settled")
def test_union_catalog_pick_patches_the_row_id_verbatim(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Picking a probe-contributed row saves exactly that row's id.
A gateway session's catalog is the configuredprobe union: pinned
family rows carrying gateway model ids next to probe rows like
``sonnet[1m]``. Picking the probe row must PATCH its id verbatim
the id is the launch contract the runner types into the pane, so any
client-side rewrite here switches the session to a model the user
did not choose (the bug this guards showed a Fable pick landing on
Opus; the web layer was innocent, and must stay that way).
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` for a real
server-backed session; the browser snapshot is patched to a
claude-native shape with the union catalog.
:returns: None.
"""
base_url, session_id = seeded_session
union_catalog = [
{
"id": "opus",
"model": "databricks-claude-opus-5",
"displayName": "Opus 5",
"isDefault": True,
},
{
"id": "sonnet",
"model": "databricks-claude-sonnet-5",
"displayName": "Sonnet 5",
"isDefault": False,
},
{
"id": "sonnet[1m]",
"model": "databricks-claude-sonnet-5[1m]",
"displayName": "Sonnet 5 (1M context)",
},
]
# No fixture-pinned model_override: the route fake would force it back
# onto every response, including the PATCH echo the store adopts. The
# bound ``llm_model`` already implicitly selects the sonnet row.
patch_bodies = _patch_session_as_claude_native(
page,
session_id,
model_options=union_catalog,
llm_model="databricks-claude-sonnet-5",
)
page.goto(f"{base_url}/c/{session_id}")
gear = page.get_by_test_id("composer-config-gear")
expect(gear).to_be_visible(timeout=15_000)
gear.click()
page.get_by_test_id("composer-config-model").click()
bracket_row = page.locator('[role="option"][data-model-id="sonnet[1m]"]')
expect(bracket_row).to_contain_text("Sonnet 5 (1M context)")
bracket_row.click()
with page.expect_response(
lambda response: (
response.request.method == "PATCH"
and urlparse(response.url).path == f"/v1/sessions/{session_id}"
and response.status == 200
)
):
page.get_by_test_id("composer-config-save").click()
assert patch_bodies[-1] == {"model_override": "sonnet[1m]"}
# The label keeps the reported model ("Sonnet 5" — the bound
# databricks-claude-sonnet-5); the request flips nothing until the
# harness confirms.
expect(page.get_by_test_id("composer-model-effort-label")).to_contain_text("Sonnet 5")
def test_claude_native_picker_highlights_the_reported_model(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The active row follows the reported model — not the request or sticky.
The session carries a pending request ("opus") and another session's
sticky pick ("haiku"), but the pane reports Sonnet 5: only its row may
read as active.
"""
page.add_init_script("window.localStorage.setItem('omnigent.picker.model', 'haiku')")
base_url, session_id = seeded_session
_patch_session_as_claude_native(page, session_id, model_override="opus")
@@ -624,9 +820,60 @@ def test_claude_native_picker_prefers_session_override_over_sticky_model(
gear.click()
page.get_by_test_id("composer-config-model").click()
expect(page.locator('[role="option"][data-model-id="opus"]')).to_have_attribute(
expect(page.locator('[role="option"][data-model-id="sonnet"]')).to_have_attribute(
"data-active", "true"
)
expect(page.locator('[role="option"][data-model-id="opus"]')).not_to_have_attribute(
"data-active", "true"
)
expect(page.locator('[role="option"][data-model-id="haiku"]')).not_to_have_attribute(
"data-active", "true"
)
def test_claude_native_permission_mode_switch_persists(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Picking a permission mode in the gear modal PATCHes the server.
Selecting "Auto" sends ``{"permission_mode": "auto"}`` to
``PATCH /v1/sessions/{id}``, exercising the new in-chat permission-mode
control introduced by the claude-web-auto-mode feature.
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` for a real server-backed
session; the browser snapshot is patched to a claude-native session
already in ``default`` (Manual) mode.
:returns: None.
"""
base_url, session_id = seeded_session
patch_bodies = _patch_session_as_claude_native(page, session_id, permission_mode="default")
page.goto(f"{base_url}/c/{session_id}")
gear = page.get_by_test_id("composer-config-gear")
expect(gear).to_be_visible(timeout=15_000)
gear.click()
# The permission-mode picker is visible for claude-native sessions whose
# current mode is known (non-empty label).
perm = page.get_by_test_id("composer-config-permission-mode")
expect(perm).to_be_visible()
perm.click()
# Located by data attribute, not accessible name: each option renders its
# label and description together, so the name is never the bare label.
page.locator('[role="option"][data-permission-mode="auto"]').click()
# Save commits the draft and fires the PATCH.
with page.expect_response(
lambda response: (
response.request.method == "PATCH"
and urlparse(response.url).path == f"/v1/sessions/{session_id}"
and response.status == 200
)
):
page.get_by_test_id("composer-config-save").click()
assert patch_bodies[-1] == {"permission_mode": "auto"}
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import re
from urllib.parse import urlparse
from playwright.sync_api import Page, Route, expect
@@ -190,3 +191,136 @@ def test_codex_native_plan_mode_toggle_uses_codex_session_patch(
expect(plan_toggle).to_have_attribute("aria-label", "Enter Plan mode")
expect(plan_toggle).to_have_attribute("aria-pressed", "false")
expect(page.get_by_test_id("composer-plan-mode")).to_have_count(0)
_PRE_CATALOG_HOST_ID = "host_pre_catalog_probe"
_HOST_PROBE_ROWS = [
{
"id": "gpt-5.6-luna",
"model": "gpt-5.6-luna",
"displayName": "GPT-5.6-Luna",
"defaultReasoningEffort": "medium",
"supportedReasoningEfforts": [
{"reasoningEffort": "low", "description": "Low"},
{"reasoningEffort": "medium", "description": "Medium"},
{"reasoningEffort": "xhigh", "description": "Extra high"},
],
"isDefault": True,
}
]
def _patch_precatalog_codex_session_on_host(page: Page, session_id: str) -> None:
"""Shape ``session_id`` as a host-bound codex session with no catalog yet.
The snapshot's ``model_options`` stay empty for the whole test — the
state a fresh session is in while codex app-server boots while the
host's pre-launch probe route serves cached rows. Liveness is pinned
online so the gear stays enabled despite the fake host id.
:param page: Playwright page before navigation.
:param session_id: Session id to patch.
"""
def _patch_snapshot(route: Route) -> None:
request = route.request
if request.method != "GET" or urlparse(request.url).path != f"/v1/sessions/{session_id}":
route.continue_()
return
response = route.fetch()
payload = response.json()
payload["labels"] = {
**payload.get("labels", {}),
"omnigent.wrapper": "codex-native-ui",
}
payload["harness"] = "codex"
payload["llm_model"] = "gpt-5.6-luna"
payload["model_options"] = []
payload["host_id"] = _PRE_CATALOG_HOST_ID
route.fulfill(
status=200,
headers={**response.headers, "content-type": "application/json"},
body=json.dumps(payload),
)
def _serve_host_probe(route: Route) -> None:
route.fulfill(
status=200,
headers={"content-type": "application/json"},
body=json.dumps({"models": _HOST_PROBE_ROWS}),
)
def _force_online_health(route: Route) -> None:
request = route.request
if request.method != "GET" or urlparse(request.url).path != "/health":
route.continue_()
return
response = route.fetch()
payload = response.json()
online = {"runner_online": True, "host_online": True}
if isinstance(payload.get("sessions"), dict):
payload["sessions"][session_id] = online
if isinstance(payload.get("session"), dict):
payload["session"] = {**payload["session"], **online}
route.fulfill(
status=200,
headers={**response.headers, "content-type": "application/json"},
body=json.dumps(payload),
)
page.route("**/v1/sessions/**", _patch_snapshot)
page.route(
f"**/v1/hosts/{_PRE_CATALOG_HOST_ID}/harnesses/codex-native/model-options",
_serve_host_probe,
)
page.route(re.compile(r"/health(\?|$)"), _force_online_health)
def test_codex_gear_offers_host_probe_rows_before_the_session_catalog(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Model and Effort never wait on the session's own catalog resolving.
A fresh codex session's catalog only arrives once codex app-server
answers ``model/list`` (seconds to ~15s cold) until then the gear used
to show a sparse Model row and no Effort row at all. With the session
catalog empty, the gear rides the host's cached pre-launch probe rows:
the Model menu lists them and the Effort menu offers their reasoning
efforts immediately. The session's own catalog supersedes them when it
lands (covered by the raw-metadata test above).
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` for a real
server-backed session; the browser view is patched to a host-bound
codex-native shape whose catalog never resolves.
:returns: None.
"""
base_url, session_id = seeded_session
_patch_precatalog_codex_session_on_host(page, session_id)
page.goto(f"{base_url}/c/{session_id}")
gear = page.get_by_test_id("composer-config-gear")
expect(gear).to_be_visible(timeout=15_000)
gear.click()
expect(page.get_by_test_id("composer-config-modal")).to_be_visible()
# The Effort row is present although the session catalog is still empty.
effort_trigger = page.get_by_test_id("composer-config-effort")
expect(effort_trigger).to_be_visible(timeout=10_000)
# The Model menu lists the host probe row under its display name.
page.get_by_test_id("composer-config-model").click()
model_row = page.locator('[role="option"][data-model-id="gpt-5.6-luna"]')
expect(model_row).to_be_visible()
expect(model_row).to_contain_text("GPT-5.6-Luna")
# Re-select the current model to close the listbox without sending
# Escape to the surrounding dialog.
model_row.click()
expect(model_row).to_be_hidden()
# The Effort menu offers exactly the host row's reasoning efforts.
effort_trigger.click()
for level in ("low", "medium", "xhigh"):
expect(page.locator(f'[role="option"][data-effort-level="{level}"]')).to_be_visible()
@@ -0,0 +1,244 @@
"""E2E (hermetic): every native harness renders its session without crashing.
A crash-safety matrix over ALL native model-picker harnesses. Each case shapes
the browser's view of one seeded session into that harness's snapshot using
the harness's realistic ``model_options`` shape, including rows with an
explicit ``model: null`` (what cursor/kiro/opencode actually send on the wire,
typed ``model?: string``) then drives the real SPA and asserts:
* the composer renders (the page does not blank),
* no uncaught page error / null-deref console error fires, and
* opening the gear renders the model control over those rows.
Why this exists: a null-``model`` cursor row once reached a model-id fold that
called ``.trim()`` on null and blanked the whole chat page. The earlier
per-harness tests used rows with ``model`` OMITTED (``undefined``), which a
``!== undefined`` guard tolerated, so the ``null`` shape slipped through. This
matrix pins the crash-safe render for every harness, with the null shape.
"""
from __future__ import annotations
import json
from urllib.parse import urlparse
import pytest
from playwright.sync_api import Page, Route, expect
from tests.e2e_ui.chat.test_model_flows_contract import _install_stream_controller
def _patch_session_as_harness(
page: Page,
session_id: str,
*,
wrapper: str,
harness: str,
llm_model: str,
model_options: list[dict],
model_override: str | None = None,
) -> None:
"""Shape the browser's view of *session_id* into a *harness* snapshot.
Patches only ``GET`` / ``PATCH /v1/sessions/{session_id}`` as the browser
sees it (the same route-patch idiom as ``test_claude_model_picker.py``),
injecting the harness wrapper label plus its ``model_options`` verbatim.
:param page: Playwright page before navigation.
:param session_id: Seeded session id to reshape.
:param wrapper: ``omnigent.wrapper`` label, e.g. ``"cursor-native-ui"``.
:param harness: Harness family, e.g. ``"cursor"``.
:param llm_model: Reported model id for the session.
:param model_options: Native picker rows exposed on the snapshot.
:param model_override: Optional session-scoped model override to expose.
"""
latest_payload: dict | None = None
def _handle(route: Route) -> None:
nonlocal latest_payload
request = route.request
if urlparse(request.url).path != f"/v1/sessions/{session_id}":
route.continue_()
return
headers = {"content-type": "application/json"}
if request.method == "GET":
response = route.fetch()
payload = response.json()
headers = {**response.headers, **headers}
elif request.method == "PATCH":
request_body = json.loads(request.post_data or "{}")
payload = dict(latest_payload or {})
if "model_override" in request_body:
payload["model_override"] = request_body["model_override"]
else:
route.continue_()
return
payload["labels"] = {**payload.get("labels", {}), "omnigent.wrapper": wrapper}
payload["harness"] = harness
payload["llm_model"] = llm_model
payload["model_options"] = model_options
if model_override is not None:
payload["model_override"] = model_override
latest_payload = dict(payload)
route.fulfill(status=200, headers=headers, body=json.dumps(payload))
page.route("**/v1/sessions/**", _handle)
# One case per native model-picker harness. The vendor-owns-model harnesses
# (cursor / kiro / opencode) carry rows with an explicit ``model: null`` — the
# exact wire shape that once blanked the page — plus, for cursor, a hostile
# ``id: None`` row to lock the fold's null-guards on both fields.
_HARNESS_CASES = [
pytest.param(
"claude-code-native-ui",
"claude",
"claude-opus-4-8[1m]",
None,
[
{
"id": "opus",
"model": "claude-opus-4-8[1m]",
"displayName": "Opus 4.8 (1M context)",
"isDefault": True,
},
{"id": "sonnet", "model": "claude-sonnet-5", "displayName": "Sonnet 5"},
],
id="claude",
),
pytest.param(
"codex-native-ui",
"codex",
"gpt-5.6-terra",
None,
[
{
"id": "gpt-5.6-terra",
"model": "gpt-5.6-terra",
"displayName": "GPT-5.6-Terra",
"isDefault": True,
"defaultReasoningEffort": "medium",
"supportedReasoningEfforts": [
{"reasoningEffort": "low", "description": "Low"},
{"reasoningEffort": "medium", "description": "Medium"},
],
},
{"id": "gpt-5.6-luna", "model": "gpt-5.6-luna", "displayName": "GPT-5.6-Luna"},
],
id="codex",
),
pytest.param(
"cursor-native-ui",
"cursor",
"default",
"databricks-claude-opus-4-8",
[
{"id": "auto", "model": None, "displayName": "Auto"},
{"id": "gpt-5.3-codex", "model": None, "displayName": "Codex 5.3"},
{"id": "claude-opus-4-8", "model": None, "displayName": "Claude Opus 4.8"},
{"id": "composer-2.5", "model": None, "displayName": "Composer 2.5"},
# Hostile: both id and model null must not throw in the fold.
{"id": None, "model": None, "displayName": "Broken row"},
],
id="cursor",
),
pytest.param(
"kiro-native-ui",
"kiro",
"auto",
"databricks-claude-haiku-4-5",
[
{"id": "auto", "model": None, "displayName": "Auto", "isDefault": True},
{"id": "claude-haiku-4.5", "model": None, "displayName": "Claude Haiku 4.5"},
],
id="kiro",
),
pytest.param(
"opencode-native-ui",
"opencode",
"anthropic/claude-sonnet-4-6",
"databricks-claude-opus-4-8",
[
{
"id": "anthropic/claude-sonnet-4-6",
"model": None,
"displayName": "anthropic/claude-sonnet-4-6",
},
{"id": "openai/gpt-5", "model": None, "displayName": "openai/gpt-5"},
],
id="opencode",
),
pytest.param(
"pi-native-ui",
"pi",
"omnigent-openai/system.ai.gpt-5-6-sol",
None,
[
{
"id": "omnigent-openai/system.ai.gpt-5-6-sol",
"model": "omnigent-openai/system.ai.gpt-5-6-sol",
"displayName": "system.ai.gpt-5-6-sol",
}
],
id="pi",
),
]
_CRASH_MARKERS = ("Cannot read properties of null", "reading 'trim'", "is not a function")
@pytest.mark.parametrize(
("wrapper", "harness", "llm_model", "model_override", "model_options"), _HARNESS_CASES
)
def test_harness_session_renders_without_crashing(
page: Page,
seeded_session: tuple[str, str],
wrapper: str,
harness: str,
llm_model: str,
model_override: str | None,
model_options: list[dict],
) -> None:
"""Every native harness renders its session + gear without a page crash.
Loads a session shaped as *harness* (with its realistic rows, including
explicit ``model: null``), then asserts the composer renders, no uncaught
error fires, and the gear's model control renders over those rows.
"""
base_url, session_id = seeded_session
errors: list[str] = []
page.on("pageerror", lambda exc: errors.append(str(exc)))
page.on(
"console",
lambda msg: errors.append(msg.text) if msg.type == "error" else None,
)
_install_stream_controller(page, session_id)
_patch_session_as_harness(
page,
session_id,
wrapper=wrapper,
harness=harness,
llm_model=llm_model,
model_options=model_options,
model_override=model_override,
)
page.goto(f"{base_url}/c/{session_id}")
# The composer renders → the page did not blank on a render throw.
expect(page.get_by_test_id("composer-config-gear")).to_be_visible(timeout=15_000)
# The status label runs the model-id fold over the rows.
expect(page.get_by_test_id("composer-model-effort-label")).to_have_count(1)
# Opening the gear renders the model control, folding every row (the exact
# path the null-``model`` cursor crash took).
page.get_by_test_id("composer-config-gear").click()
expect(page.get_by_test_id("composer-config-modal")).to_be_visible(timeout=10_000)
expect(page.get_by_test_id("composer-config-model")).to_be_visible()
page.get_by_test_id("composer-config-model").click()
# The option list renders without throwing (rows folded into the picker).
expect(page.locator('[role="option"]').first).to_be_visible(timeout=10_000)
crash = [e for e in errors if any(m in e for m in _CRASH_MARKERS)]
assert not crash, f"{harness}: render crashed with {crash!r} (all errors: {errors!r})"
@@ -0,0 +1,274 @@
"""E2E (hermetic): in-session model contract per model-flows-design.md §10.1.
Rows 1215's hermetic halves. Every test drives the real SPA over the spawned
server, with the browser's view of ONE session shaped into a claude-native
snapshot (the same route-patch idiom as ``test_claude_model_picker.py``) and
SSE frames injected through a captured stream controller the harness
boundary, not the driven surface.
The assertions encode the DESIGN's target behavior:
- Row 12 (guard): the gear renders its model rows from the already-held
snapshot; no click-time fetch is load-bearing.
- Row 13: an off-catalog reported model renders as its own appended raw row,
highlighted; catalog rows highlight only on exact match. Red until step 3.
- Row 14: a pick renders as pending the chip must NOT flip before the
harness's own report (the ``session.model`` event) confirms. Red until
step 6.
- Row 15: a failed switch surfaces the ``model_change_not_applied`` error and
the chip keeps reporting the pane's real model. Red until step 6.
"""
from __future__ import annotations
import json
from urllib.parse import urlparse
from playwright.sync_api import Page, Route, expect
from tests.e2e_ui.chat.test_claude_model_picker import (
_MODEL_OPTIONS,
_patch_session_as_claude_native,
)
_STREAM_CONTROLLER = """
(() => {
const sessionId = __SESSION_ID__;
const originalFetch = window.fetch.bind(window);
window.fetch = (input, init) => {
const url = typeof input === "string" ? input : input.url;
const streamPath = `/v1/sessions/${sessionId}/stream`;
if (new URL(url, window.location.origin).pathname === streamPath) {
const body = new ReadableStream({
start(controller) {
window.__mfStreamController = controller;
},
});
return Promise.resolve(new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
}));
}
return originalFetch(input, init);
};
})()
"""
def _install_stream_controller(page: Page, session_id: str) -> None:
"""Capture the session's SSE stream so tests can push frames."""
page.add_init_script(_STREAM_CONTROLLER.replace("__SESSION_ID__", json.dumps(session_id)))
def _push_sse(page: Page, event: str, payload: dict) -> None:
"""Push one SSE frame through the captured stream controller."""
page.wait_for_function("window.__mfStreamController !== undefined")
page.evaluate(
"""
({ event, payload }) => {
const frame = `event: ${event}\\ndata: ${JSON.stringify(payload)}\\n\\n`;
window.__mfStreamController.enqueue(new TextEncoder().encode(frame));
}
""",
{"event": event, "payload": payload},
)
def _open_gear_model_dropdown(page: Page) -> None:
gear = page.get_by_test_id("composer-config-gear")
expect(gear).to_be_visible(timeout=15_000)
gear.click()
page.get_by_test_id("composer-config-model").click()
def test_row12_gear_rows_render_without_any_click_time_fetch(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The gear's model rows come from the held snapshot, not a click fetch.
After the page settles, EVERY further ``/v1`` request is aborted; opening
the gear must still list the catalog rows. (A regression guard green on
the current code pinning the design's "clicking the gear fetches
nothing load-bearing".)
"""
base_url, session_id = seeded_session
_patch_session_as_claude_native(page, session_id)
page.goto(f"{base_url}/c/{session_id}")
expect(page.get_by_test_id("composer-config-gear")).to_be_visible(timeout=15_000)
# Let the initial snapshot/queries settle before cutting the network.
page.wait_for_timeout(1_000)
def _abort_api(route: Route) -> None:
if "/v1/" in urlparse(route.request.url).path:
route.abort()
else:
route.continue_()
page.route("**/v1/**", _abort_api)
_open_gear_model_dropdown(page)
rows = page.locator('[role="option"][data-model-id]')
expect(rows).to_have_count(len(_MODEL_OPTIONS))
def test_row13_off_catalog_reported_model_appends_and_highlights_exactly(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""An off-catalog reported model is its own highlighted raw row.
The session reports ``claude-opus-4-8[1m]`` (a settings-file pin) while
the catalog holds only alias rows resolving to other models. The design:
the picker appends the reported value as its own row, highlights it, and
highlights NO catalog row never relabeling the report onto a
same-family row of a different generation. The chip shows the raw
reported value.
"""
base_url, session_id = seeded_session
reported = "claude-opus-4-8[1m]"
_patch_session_as_claude_native(
page,
session_id,
llm_model=reported,
)
page.goto(f"{base_url}/c/{session_id}")
chip = page.get_by_test_id("composer-model-effort-label")
expect(chip).to_contain_text(reported, timeout=15_000)
_open_gear_model_dropdown(page)
appended = page.locator(f'[role="option"][data-model-id="{reported}"]')
expect(appended).to_have_count(1)
expect(appended).to_have_attribute("data-active", "true")
# The same-family catalog row (Opus 4.10) must NOT claim the highlight.
expect(page.locator('[role="option"][data-model-id="opus"]')).not_to_have_attribute(
"data-active", "true"
)
def test_row13_exact_match_highlights_the_catalog_row(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""A reported model exactly matching a row's ``model`` highlights that row."""
base_url, session_id = seeded_session
_patch_session_as_claude_native(
page,
session_id,
llm_model="system.ai.claude-sonnet-5",
)
page.goto(f"{base_url}/c/{session_id}")
_open_gear_model_dropdown(page)
expect(page.locator('[role="option"][data-model-id="sonnet"]')).to_have_attribute(
"data-active", "true"
)
def test_row14_pick_stays_pending_until_the_harness_confirms(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""The chip flips only on the harness's own report, never on the pick.
Saving a pick PATCHes the request, but the composer chip must keep the
reported model (with a pending indicator) until a ``session.model`` event
carries the harness's confirmation; then it flips to the confirmed row's
name.
"""
base_url, session_id = seeded_session
_install_stream_controller(page, session_id)
_patch_session_as_claude_native(
page,
session_id,
llm_model="system.ai.claude-sonnet-5",
)
page.goto(f"{base_url}/c/{session_id}")
chip = page.get_by_test_id("composer-model-effort-label")
expect(chip).to_contain_text("Sonnet 5", timeout=15_000)
_open_gear_model_dropdown(page)
page.locator('[role="option"][data-model-id="opus"]').click()
page.get_by_test_id("composer-config-save").click()
# Unconfirmed: the chip keeps the reported model and a pending indicator
# shows. (The PATCH round-trip completes; confirmation has not arrived.)
page.wait_for_timeout(800)
expect(chip).to_contain_text("Sonnet 5")
expect(chip).not_to_contain_text("Opus")
expect(page.get_by_test_id("composer-model-pending")).to_be_visible()
# The harness confirms: the report names the model the pane now runs.
_push_sse(
page,
"session.model",
{"conversation_id": session_id, "model": "system.ai.claude-opus-4-10"},
)
expect(chip).to_contain_text("Opus 4.10", timeout=10_000)
expect(page.get_by_test_id("composer-model-pending")).to_have_count(0)
def test_row15_failed_switch_surfaces_error_and_keeps_the_reported_model(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""A swallowed switch shows the not-applied error; the chip never lies.
The runner reports failure (the server publishes the
``model_change_not_applied`` error event) and no confirmation ever
arrives: the visible error must name the failure, and the chip must keep
the pane's real model instead of claiming the pick.
"""
base_url, session_id = seeded_session
_install_stream_controller(page, session_id)
_patch_session_as_claude_native(
page,
session_id,
llm_model="system.ai.claude-sonnet-5",
)
page.goto(f"{base_url}/c/{session_id}")
chip = page.get_by_test_id("composer-model-effort-label")
expect(chip).to_contain_text("Sonnet 5", timeout=15_000)
_open_gear_model_dropdown(page)
page.locator('[role="option"][data-model-id="haiku"]').click()
page.get_by_test_id("composer-config-save").click()
_push_sse(
page,
"response.error",
{
"source": "execution",
"error": {
"code": "model_change_not_applied",
"message": (
"The terminal was not switched to haiku: the runner returned "
"status 503. It is still running on its previous model."
),
},
},
)
# The failure surfaces as the app's standard error pill: a headline is
# always visible, and its collapsed detail carries the specific reason.
headline = page.get_by_test_id("error-headline").first
expect(headline).to_be_visible(timeout=10_000)
# Expand to read the detail. The click can land before the disclosure
# handler is wired under suite load, so retry until the detail shows.
detail = page.get_by_text("was not switched", exact=False).first
for _ in range(5):
headline.click()
try:
expect(detail).to_be_visible(timeout=2_000)
break
except AssertionError:
continue
expect(detail).to_be_visible(timeout=5_000)
# The chip never claimed the pick: it keeps the pane's reported model.
expect(chip).to_contain_text("Sonnet 5")
expect(chip).not_to_contain_text("Haiku")
@@ -200,7 +200,7 @@ def test_routed_session_config_modal_names_the_routed_model(
:returns: None.
"""
base_url, session_id = seeded_session
_patch_session_as_claude_native(page, session_id, model_override=_ROUTED_MODEL)
_patch_session_as_claude_native(page, session_id, llm_model=_ROUTED_MODEL)
page.goto(f"{base_url}/c/{session_id}")
+9 -6
View File
@@ -123,8 +123,9 @@ def test_recent_server_connect_and_copy_actions_are_independent(page: Page) -> N
_open_setup_page(page, [recent_url])
recent = page.locator(".recent-btn")
expect(recent).to_have_text(recent_url)
expect(recent).to_have_attribute("title", recent_url)
label = "dbc-x.cloud.databricks.com/?o=12345678901234567890"
expect(recent).to_have_text(label)
expect(recent).to_have_attribute("title", label)
page.click(".recent-copy")
page.wait_for_function("() => window.__copiedTexts.length === 1")
@@ -148,13 +149,15 @@ def test_shared_url_module_defaults_scheme_in_browser(page: Page) -> None:
"""
_open_setup_page(page)
# Remote host → https root; the main process then probes and appends the
# canonical /omnigent workspace mount when this is a Databricks workspace.
# Remote host → https root while retaining the Databricks organization;
# the main process then probes and appends the canonical /omnigent mount.
assert (
page.evaluate(
"() => window.omnigentUrl.normalizeUrl('dbc-x.cloud.databricks.com/omnigent')"
"""() => window.omnigentUrl.normalizeUrl(
'dbc-x.cloud.databricks.com/omnigent?ignored=yes&o=1965859176160743#page'
)"""
)
== "https://dbc-x.cloud.databricks.com/"
== "https://dbc-x.cloud.databricks.com/?o=1965859176160743"
)
# Loopback stays http for local dev.
assert (
@@ -0,0 +1,243 @@
"""E2E coverage for the iOS system-browser OIDC session handoff.
The iOS shell and Safari have isolated cookie stores. The native shell starts
the production browser-ticket flow, Safari completes OIDC, and the shell polls
the ticket before installing the returned session JWT in its WebView.
Playwright cannot execute UIKit or ``WKNavigationDelegate`` on Linux CI, so this
test covers the shared production contract at the browser boundary: real auth
routes, a browser-driven IdP redirect, a separately polled ticket, and an
isolated WebView-like context that becomes authenticated only after receiving
the polled session.
"""
from __future__ import annotations
import html
import threading
import time
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import urlencode
import pytest
import uvicorn
from fastapi import FastAPI, Request
from playwright.sync_api import Browser, Playwright, expect
from starlette.responses import HTMLResponse
from omnigent.server.admin_list import AdminList
from omnigent.server.auth import UnifiedAuthProvider
from omnigent.server.oidc import OIDCConfig
from omnigent.server.routes import auth as auth_routes
from omnigent.server.routes.auth import create_auth_router
from tests.e2e_ui.conftest import _find_free_port
_COOKIE_SECRET = b"i" * 32
_IDP_TOKEN_URL = "https://idp.example.test/token"
_IDP_USERINFO_URL = "https://idp.example.test/user"
_TEST_EMAIL = "ios-user@example.test"
@dataclass(frozen=True)
class OidcHandoffServer:
"""A live auth router used by the browser handoff test."""
base_url: str
def _oidc_config(base_url: str) -> OIDCConfig:
"""Build a GitHub-shaped config whose browser endpoint is intercepted."""
return OIDCConfig(
issuer="https://idp.example.test",
client_id="ios-e2e-client",
client_secret="ios-e2e-secret",
redirect_uri=f"{base_url}/auth/callback",
cookie_secret=_COOKIE_SECRET,
scopes="read:user user:email",
session_ttl_hours=8,
logout_redirect_uri=None,
allowed_domains=None,
provider_type="github",
authorization_endpoint=(
f"{base_url.replace('127.0.0.1', 'localhost')}/test-idp/authorize"
),
token_endpoint=_IDP_TOKEN_URL,
jwks_uri=None,
userinfo_endpoint=_IDP_USERINFO_URL,
allow_invites=False,
)
def _mock_idp_client() -> AsyncMock:
"""Return the async client used by the callback's token and email calls."""
token_response = MagicMock()
token_response.status_code = 200
token_response.json.return_value = {
"access_token": "ios-e2e-access-token",
"token_type": "bearer",
}
token_response.text = "token response"
email_response = MagicMock()
email_response.status_code = 200
email_response.json.return_value = [{"email": _TEST_EMAIL, "primary": True, "verified": True}]
client = AsyncMock()
client.post.return_value = token_response
client.get.return_value = email_response
context_manager = AsyncMock()
context_manager.__aenter__.return_value = client
context_manager.__aexit__.return_value = False
return context_manager
def _build_auth_app(base_url: str, admin_list_path: Path) -> FastAPI:
"""Build the real OIDC auth router plus one protected browser page."""
auth_provider = UnifiedAuthProvider(source="oidc", oidc_config=_oidc_config(base_url))
app = FastAPI()
app.include_router(
create_auth_router(
auth_provider=auth_provider,
permission_store=None,
admin_list=AdminList(admin_list_path),
),
prefix="/auth",
)
@app.get("/test-idp/authorize")
async def test_idp_authorize(request: Request) -> HTMLResponse:
callback_url = request.query_params["redirect_uri"]
callback_query = urlencode(
{
"code": "ios-e2e-authorization-code",
"state": request.query_params["state"],
}
)
continue_url = f"{callback_url}?{callback_query}"
return HTMLResponse(
"<html><body>"
"<h1>Test identity provider</h1>"
f'<a href="{html.escape(continue_url, quote=True)}">Continue as test user</a>'
"</body></html>"
)
@app.get("/")
async def protected_page(request: Request) -> HTMLResponse:
user_id = auth_provider.get_user_id(request)
if user_id is None:
return HTMLResponse("<h1>Authentication required</h1>", status_code=401)
return HTMLResponse(f"<h1>Authenticated WebView</h1><p>{html.escape(user_id)}</p>")
return app
def _wait_for_server(server: uvicorn.Server, base_url: str, timeout: float = 10.0) -> None:
"""Wait until the local uvicorn server reports that it has started."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if server.started:
return
time.sleep(0.05)
raise RuntimeError(f"OIDC handoff test server did not start at {base_url}")
@pytest.fixture
def oidc_handoff_server(tmp_path: Path) -> Iterator[OidcHandoffServer]:
"""Serve a deterministic production OIDC router for one browser test."""
port = _find_free_port()
base_url = f"http://127.0.0.1:{port}"
server = uvicorn.Server(
uvicorn.Config(
_build_auth_app(base_url, tmp_path / "admins"),
host="127.0.0.1",
port=port,
log_level="warning",
access_log=False,
)
)
server_thread = threading.Thread(target=server.run, daemon=True)
with patch.object(
auth_routes.httpx, # type: ignore[attr-defined]
"AsyncClient",
return_value=_mock_idp_client(),
):
server_thread.start()
_wait_for_server(server, base_url)
try:
yield OidcHandoffServer(base_url=base_url)
finally:
server.should_exit = True
server_thread.join(timeout=10)
if server_thread.is_alive():
server.force_exit = True
server_thread.join(timeout=5)
def test_system_browser_session_is_bridged_to_isolated_webview(
browser: Browser,
playwright: Playwright,
oidc_handoff_server: OidcHandoffServer,
) -> None:
"""Complete browser OIDC, poll its ticket, and authenticate the WebView."""
base_url = oidc_handoff_server.base_url
native_request_context = playwright.request.new_context(base_url=base_url)
system_browser_context = browser.new_context()
webview_context = browser.new_context()
try:
ticket_response = native_request_context.post("/auth/cli-login")
assert ticket_response.ok, ticket_response.text()
ticket_payload = ticket_response.json()
ticket = ticket_payload["ticket"]
login_url = f"{base_url}{ticket_payload['login_url']}"
pending_response = native_request_context.get(
"/auth/cli-poll",
params={"ticket": ticket},
)
assert pending_response.status == 202
assert pending_response.json() == {"status": "pending"}
system_browser_page = system_browser_context.new_page()
system_browser_page.goto(login_url)
expect(
system_browser_page.get_by_role("heading", name="Test identity provider")
).to_be_visible()
system_browser_page.get_by_role("link", name="Continue as test user").click()
expect(system_browser_page.get_by_role("heading", name="Login successful")).to_be_visible()
expect(system_browser_page.get_by_text(_TEST_EMAIL)).to_be_visible()
poll_response = native_request_context.get(
"/auth/cli-poll",
params={"ticket": ticket},
)
assert poll_response.ok, poll_response.text()
session_token = poll_response.json()["token"]
webview_page = webview_context.new_page()
unauthenticated_response = webview_page.goto(base_url)
assert unauthenticated_response is not None
assert unauthenticated_response.status == 401
expect(webview_page.get_by_role("heading", name="Authentication required")).to_be_visible()
webview_context.add_cookies(
[{"name": "ap_session", "value": session_token, "url": base_url}]
)
webview_page.reload()
expect(webview_page.get_by_role("heading", name="Authenticated WebView")).to_be_visible()
expect(webview_page.get_by_text(_TEST_EMAIL)).to_be_visible()
consumed_response = native_request_context.get(
"/auth/cli-poll",
params={"ticket": ticket},
)
assert consumed_response.status == 410
finally:
webview_context.close()
system_browser_context.close()
native_request_context.dispose()
@@ -0,0 +1,32 @@
"""E2E: Ctrl+N opens the new-session composer from focused chat input."""
from __future__ import annotations
import sys
from playwright.sync_api import Page, expect
_COMPOSER = "Ask the agent anything…"
def test_new_session_hotkey_from_focused_composer(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""Ctrl+N follows the command-palette action to a clean, focused composer."""
base_url, session_id = seeded_session
page.goto(f"{base_url}/c/{session_id}")
composer = page.get_by_placeholder(_COMPOSER)
expect(composer).to_be_visible(timeout=30_000)
composer.fill("draft that belongs to the existing session")
expect(composer).to_be_focused()
modifier = "Meta" if sys.platform == "darwin" else "Control"
page.keyboard.press(f"{modifier}+n")
expect(page).to_have_url(f"{base_url}/", timeout=10_000)
new_session_composer = page.get_by_placeholder("Describe a task to start a new session…")
expect(new_session_composer).to_be_visible()
expect(new_session_composer).to_be_focused()
expect(new_session_composer).to_have_value("")
@@ -0,0 +1,110 @@
"""E2E (hermetic): pre-launch model picker rows per model-flows-design.md §10.1.
Row 6's hermetic half: with a host catalog whose rows carry ``isDefault``, the
new-chat model select must read "Default (X)" for BOTH harnesses X being the
default row's display name. Codex already renders this; the claude branch of
the landing screen historically discarded ``isDefault``, so its select read a
bare "Default" no matter what the host said. This test encodes the design's
target behavior and is red until landing-order step 7.
The driving surface is the real SPA in a browser; only the server edges the
landing screen consults (hosts, agents, model-options) are faked, exactly like
the sibling tests in ``test_start_session.py``.
"""
from __future__ import annotations
import json
from typing import Any
from playwright.async_api import Route, async_playwright, expect
from tests.e2e_ui.start_session.test_start_session import (
_HOST_ID,
_open_entry_config,
_register_common_routes,
_run_in_fresh_loop,
)
_CLAUDE_HOST_ROWS = [
{
"id": "sonnet",
"model": "claude-sonnet-5",
"displayName": "Sonnet 5",
"isDefault": False,
},
{
"id": "opus[1m]",
"model": "claude-opus-4-8[1m]",
"displayName": "Opus 4.8 (1M context)",
"isDefault": True,
},
{
"id": "haiku",
"model": "claude-haiku-4-5-20251001",
"displayName": "Haiku 4.5",
"isDefault": False,
},
]
def test_claude_default_entry_names_the_true_default(
seeded_session: tuple[str, str],
) -> None:
"""Row 6: the claude model select reads "Default (Opus 4.8 (1M context))".
:param seeded_session: ``(base_url, session_id)`` from the spawned server.
"""
base_url, session_id = seeded_session
_run_in_fresh_loop(_drive_claude_default_label(base_url, session_id))
async def _drive_claude_default_label(base_url: str, session_id: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
create_bodies: list[dict[str, Any]] = []
await _register_common_routes(
page, created_session_id=session_id, create_bodies=create_bodies
)
async def handle_agent_scan(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"data": []}),
)
async def handle_model_options(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"models": _CLAUDE_HOST_ROWS}),
)
import re as _re
await page.route(_re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan)
await page.route(
f"**/v1/hosts/{_HOST_ID}/harnesses/claude-native/model-options",
handle_model_options,
)
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
);"""
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
await _open_entry_config(page, "ag_claude_e2e")
model = page.get_by_test_id("new-chat-landing-config-model")
# The design's row 6: the untouched select names the model a
# Default launch truly runs, for claude exactly as for codex.
await expect(model).to_contain_text("Default (Opus 4.8 (1M context))")
finally:
await browser.close()
@@ -583,7 +583,7 @@ async def _drive_permission_mode(base_url: str, session_id: str) -> None:
await expect(perm).to_be_visible()
await perm.click()
perm_labels = (
"Default",
"Manual",
"Auto",
"Accept edits",
"Plan",
@@ -1730,8 +1730,12 @@ async def _drive_codex_model(base_url: str, session_id: str) -> None:
)
await _open_entry_config(page, "ag_codex_e2e")
model = page.get_by_test_id("new-chat-landing-config-model")
await expect(model).to_contain_text("Default (gpt-live-default)")
await _pick_config_select(page, "new-chat-landing-config-model", "gpt-live-fast")
# The Default row names the catalog's default by its DISPLAY name —
# the same shared labeling the in-session gear uses.
await expect(model).to_contain_text("Default (GPT Live Default)")
# Codex options render decorated display names (same as claude),
# so pick by the display name; the create still sends the id.
await _pick_config_select(page, "new-chat-landing-config-model", "GPT Live Fast")
await _save_config(page)
await page.get_by_test_id("new-chat-landing-input").fill("set up the project")
+362 -395
View File
@@ -72,6 +72,22 @@ from omnigent.runner.identity import (
pytestmark = pytest.mark.asyncio
@pytest.fixture(autouse=True)
def _isolated_model_catalog_store(
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
) -> None:
"""Point the shared model-catalog store at a per-test directory.
The model-options lanes read and write the on-disk catalog store; a
test must never touch (or be poisoned by) the developer's real
``~/.omnigent`` cache. Only the store's directory seam is redirected —
``OMNIGENT_DATA_DIR`` itself stays untouched so log-path tests keep
seeing the real default layout.
"""
store_dir = tmp_path_factory.mktemp("model_catalog_store")
monkeypatch.setattr("omnigent.model_catalog_store._data_dir", lambda: store_dir)
@pytest.fixture(autouse=True)
def _no_real_zygote(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep these tests from forking a real runner zygote.
@@ -88,24 +104,97 @@ def _no_real_zygote(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(ZYGOTE_ENABLED_ENV_VAR, "0")
async def test_handle_model_options_uses_host_claude_configuration(
async def test_handle_model_options_serves_the_claude_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The launch picker is resolved on the host that will start Claude."""
"""The launch picker is the harness-probed catalog, resolved on the host.
The probe's rows pass through with the harness's own default marked,
the endpoint's routable set rides along, and the second request is
served from the fingerprint store the harness is probed once.
"""
from omnigent import claude_native
monkeypatch.setattr(claude_native, "resolve_native_claude_config", lambda *, spec: None)
config = claude_native.ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": "https://gw.example"},
routable_models=(
"system.ai.claude-sonnet-5",
"system.ai.claude-sonnet-5[1m]",
),
)
monkeypatch.setattr(
claude_native,
"claude_native_model_options",
lambda config: [
"resolve_native_claude_config",
lambda *, spec, refresh_models=True: config,
)
probe_calls: list[int] = []
async def _fake_probe(_config: object) -> claude_native.ClaudeModelProbe:
probe_calls.append(1)
return claude_native.ClaudeModelProbe(
alias_rows=[
{
"id": "sonnet",
"model": "system.ai.claude-sonnet-5",
"displayName": "Sonnet 5",
}
],
default_model="system.ai.claude-sonnet-5",
default_label="Sonnet 5",
)
monkeypatch.setattr(claude_native, "probe_claude_model_options", _fake_probe)
host = _make_host_process()
first = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_1", harness="claude-native"),
)
second = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_2", harness="claude-native"),
)
assert first == HostModelOptionsResultFrame(
request_id="req_1",
status="ok",
models=[
{
"id": "sonnet",
"model": "system.ai.claude-sonnet-4-6[1m]",
"displayName": "Sonnet 4.6",
"model": "system.ai.claude-sonnet-5",
"displayName": "Sonnet 5",
"isDefault": True,
}
],
routable_models=[
"system.ai.claude-sonnet-5",
"system.ai.claude-sonnet-5[1m]",
],
)
assert second.models == first.models
assert probe_calls == [1]
_cleanup_host(host)
async def test_handle_model_options_claude_probe_failure_is_an_honest_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No Claude catalog means an empty answer that says why.
There is no configured/static fallback lane left: a probe that cannot
run yields an honest empty listing with the reason, never invented
rows.
"""
from omnigent import claude_native
monkeypatch.setattr(
claude_native,
"resolve_native_claude_config",
lambda *, spec, refresh_models=True: None,
)
async def _failed_probe(_config: object) -> None:
return None
monkeypatch.setattr(claude_native, "probe_claude_model_options", _failed_probe)
host = _make_host_process()
result = await host._handle_model_options(
@@ -115,14 +204,10 @@ async def test_handle_model_options_uses_host_claude_configuration(
assert result == HostModelOptionsResultFrame(
request_id="req_models",
status="ok",
models=[
{
"id": "sonnet",
"model": "system.ai.claude-sonnet-4-6[1m]",
"displayName": "Sonnet 4.6",
}
],
models=[],
error="the claude model probe failed — see the host log",
)
_cleanup_host(host)
async def test_handle_model_options_uses_host_pi_configuration(
@@ -161,267 +246,24 @@ async def test_handle_model_options_uses_host_pi_configuration(
)
async def test_handle_model_options_uses_codex_provider_catalog(
monkeypatch: pytest.MonkeyPatch,
@pytest.mark.parametrize("failure", ["raises", "resolves_nothing"])
async def test_handle_model_options_codex_probe_failure_is_an_honest_empty(
monkeypatch: pytest.MonkeyPatch, failure: str
) -> None:
"""The Codex launch picker comes from the host's resolved provider catalog."""
"""No Codex catalog means an empty answer that says why.
There is no curated/provider fallback lane left: whether the catalog
machinery raises or resolves nothing, the picker gets an honest empty
listing with the reason, never invented rows.
"""
from omnigent import codex_native_app_server
from omnigent.model_catalog import ModelEntry, ModelListing
def _fake_list_models_for_worker(spec: object, harness: str) -> ModelListing:
assert harness == "codex-native"
assert spec.executor.config["profile"] == "oss"
return ModelListing(
source="static",
verified=False,
models=(
ModelEntry(id="gpt-live-default", family="openai"),
ModelEntry(id="gpt-live-fast", family="openai"),
),
note="test catalog",
)
async def _no_catalog(**_kwargs: object) -> list[dict[str, object]] | None:
if failure == "raises":
raise RuntimeError("codex probe unavailable")
return None
monkeypatch.setattr(
"omnigent.model_catalog.list_models_for_worker",
_fake_list_models_for_worker,
)
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model="gpt-live-fast",
profile="oss",
),
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
)
assert result == HostModelOptionsResultFrame(
request_id="req_models",
status="ok",
models=[
{"id": "gpt-live-default", "displayName": "gpt-live-default"},
{"id": "gpt-live-fast", "displayName": "gpt-live-fast", "isDefault": True},
],
)
async def test_handle_model_options_does_not_invent_codex_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A catalog entry is not a default unless Codex resolves it as one."""
from omnigent import codex_native_app_server
from omnigent.model_catalog import ModelEntry, ModelListing
monkeypatch.setattr(
"omnigent.model_catalog.list_models_for_worker",
lambda spec, harness: ModelListing(
source="static",
verified=False,
models=(ModelEntry(id="gpt-live", family="openai"),),
note="test catalog",
),
)
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model=None,
profile=None,
),
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
)
assert result.models == [{"id": "gpt-live", "displayName": "gpt-live"}]
async def test_handle_model_options_uses_databricks_catalog_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The Databricks profile path labels its effective catalog default."""
from omnigent import codex_native_app_server
from omnigent.model_catalog import ModelEntry, ModelListing
monkeypatch.setattr(
"omnigent.model_catalog.list_models_for_worker",
lambda spec, harness: ModelListing(
source="gateway",
verified=True,
models=(
ModelEntry(id="databricks-gpt-default", family="openai"),
ModelEntry(id="databricks-gpt-fast", family="openai"),
),
note="test catalog",
),
)
def _fake_resolve_catalog_model(provider: str, *, family: str) -> SimpleNamespace:
assert provider == "databricks"
assert family == "openai"
return SimpleNamespace(model_id="databricks-gpt-default")
monkeypatch.setattr(
"omnigent.model_catalog.resolve_catalog_model",
_fake_resolve_catalog_model,
)
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model=None,
profile="oss",
),
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
)
assert result.models == [
{
"id": "databricks-gpt-default",
"displayName": "databricks-gpt-default",
"isDefault": True,
},
{"id": "databricks-gpt-fast", "displayName": "databricks-gpt-fast"},
]
async def test_handle_model_options_filters_direct_openai_through_codex_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Direct OpenAI availability is intersected with Codex compatibility."""
from omnigent import codex_native_app_server
from omnigent.model_catalog import ModelEntry, ModelListing, ResolvedModelProvider
monkeypatch.setattr(
"omnigent.model_catalog.list_models_for_worker",
lambda spec, harness: ModelListing(
source="openai-compatible",
verified=True,
models=tuple(
ModelEntry(id=model_id, family="openai")
for model_id in (
"coding-compatible",
"audio-preview",
"realtime-preview",
"image-preview",
"embedding-preview",
"moderation-preview",
)
),
note="test OpenAI catalog",
),
)
monkeypatch.setattr(
"omnigent.model_catalog.resolve_model_provider",
lambda spec, harness: ResolvedModelProvider(
kind="key",
family="openai",
base_url="https://api.openai.com",
detail="test OpenAI key",
),
)
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model=None,
profile=None,
),
)
async def _fake_codex_options() -> list[dict[str, object]]:
return [
{
"id": "coding-compatible",
"model": "coding-compatible",
"displayName": "Coding Compatible",
"isDefault": True,
},
{
"id": "coding-unavailable",
"model": "coding-unavailable",
"displayName": "Coding Unavailable",
"isDefault": False,
},
]
monkeypatch.setattr(
codex_native_app_server,
"discover_codex_model_options",
_fake_codex_options,
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
)
assert result.models == [
{
"id": "coding-compatible",
"displayName": "Coding Compatible",
"isDefault": True,
}
]
async def test_handle_model_options_tolerates_codex_discovery_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Discovery failures keep the implicit default without unsafe model rows."""
from omnigent import codex_native_app_server
from omnigent.model_catalog import ModelEntry, ModelListing, ResolvedModelProvider
monkeypatch.setattr(
"omnigent.model_catalog.list_models_for_worker",
lambda spec, harness: ModelListing(
source="openai-compatible",
verified=True,
models=(ModelEntry(id="unverified-model", family="openai"),),
note="test OpenAI catalog",
),
)
monkeypatch.setattr(
"omnigent.model_catalog.resolve_model_provider",
lambda spec, harness: ResolvedModelProvider(
kind="key",
family="openai",
base_url="https://api.openai.com",
detail="test OpenAI key",
),
)
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model=None,
profile=None,
),
)
async def _failed_codex_options() -> list[dict[str, object]]:
raise TimeoutError("test discovery timeout")
monkeypatch.setattr(
codex_native_app_server,
"discover_codex_model_options",
_failed_codex_options,
)
monkeypatch.setattr(codex_native_app_server, "codex_launch_catalog", _no_catalog)
host = _make_host_process()
result = await host._handle_model_options(
@@ -432,124 +274,9 @@ async def test_handle_model_options_tolerates_codex_discovery_failure(
request_id="req_models",
status="ok",
models=[],
error="the codex model probe failed — see the host log",
)
async def test_handle_model_options_marks_only_first_codex_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Malformed Codex catalogs cannot mark multiple picker rows as default."""
from omnigent import codex_native_app_server
from omnigent.model_catalog import ModelEntry, ModelListing, ResolvedModelProvider
model_ids = ("coding-first", "coding-second")
monkeypatch.setattr(
"omnigent.model_catalog.list_models_for_worker",
lambda spec, harness: ModelListing(
source="openai-compatible",
verified=True,
models=tuple(ModelEntry(id=model_id, family="openai") for model_id in model_ids),
note="test OpenAI catalog",
),
)
monkeypatch.setattr(
"omnigent.model_catalog.resolve_model_provider",
lambda spec, harness: ResolvedModelProvider(
kind="key",
family="openai",
base_url="https://api.openai.com",
detail="test OpenAI key",
),
)
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model=None,
profile=None,
),
)
async def _multiple_codex_defaults() -> list[dict[str, object]]:
return [
{"model": model_id, "displayName": model_id, "isDefault": True}
for model_id in model_ids
]
monkeypatch.setattr(
codex_native_app_server,
"discover_codex_model_options",
_multiple_codex_defaults,
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
)
assert result.models == [
{"id": "coding-first", "displayName": "coding-first", "isDefault": True},
{"id": "coding-second", "displayName": "coding-second"},
]
async def test_handle_model_options_keeps_custom_gateway_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Custom gateway ids remain selectable without Codex alias filtering."""
from omnigent import codex_native_app_server
from omnigent.model_catalog import ModelEntry, ModelListing, ResolvedModelProvider
monkeypatch.setattr(
"omnigent.model_catalog.list_models_for_worker",
lambda spec, harness: ModelListing(
source="openai-compatible",
verified=True,
models=(ModelEntry(id="gateway-coding-model", family="openai"),),
note="test gateway catalog",
),
)
monkeypatch.setattr(
"omnigent.model_catalog.resolve_model_provider",
lambda spec, harness: ResolvedModelProvider(
kind="gateway",
family="openai",
base_url="https://gateway.example/v1",
detail="test gateway",
),
)
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model="gateway-coding-model",
profile=None,
),
)
async def _unexpected_codex_options() -> list[dict[str, object]]:
raise AssertionError("custom gateways must not use the OpenAI compatibility filter")
monkeypatch.setattr(
codex_native_app_server,
"discover_codex_model_options",
_unexpected_codex_options,
)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
)
assert result.models == [
{
"id": "gateway-coding-model",
"displayName": "gateway-coding-model",
"isDefault": True,
}
]
_cleanup_host(host)
async def test_handle_model_options_rejects_unsupported_harness() -> None:
@@ -576,17 +303,21 @@ async def test_handle_model_options_reports_the_endpoints_wider_catalog(
monkeypatch.setattr(
claude_native,
"resolve_native_claude_config",
lambda *, spec: claude_native.ClaudeNativeUcodeConfig(
lambda *, spec, refresh_models=True: claude_native.ClaudeNativeUcodeConfig(
env={"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-5"},
model="system.ai.claude-opus-5",
routable_models=("system.ai.claude-opus-5", "system.ai.claude-opus-4-8"),
),
)
monkeypatch.setattr(
claude_native,
"claude_native_model_options",
lambda config: [{"id": "opus", "model": "system.ai.claude-opus-5"}],
)
async def _fake_probe(_config: object) -> claude_native.ClaudeModelProbe:
return claude_native.ClaudeModelProbe(
alias_rows=[{"id": "opus", "model": "system.ai.claude-opus-5"}],
default_model="system.ai.claude-opus-5",
default_label="Opus",
)
monkeypatch.setattr(claude_native, "probe_claude_model_options", _fake_probe)
host = _make_host_process()
result = await host._handle_model_options(
@@ -597,6 +328,7 @@ async def test_handle_model_options_reports_the_endpoints_wider_catalog(
"system.ai.claude-opus-5",
"system.ai.claude-opus-4-8",
]
_cleanup_host(host)
def _make_host_process() -> HostProcess:
@@ -3349,16 +3081,18 @@ class _ConnectSpy:
"""
self._exceptions = exceptions
self.call_count = 0
self.calls: list[dict[str, object]] = []
def __call__(self, url: str, **kwargs: object) -> _HandshakeFailingConnect | _AcceptingConnect:
"""Return an async-CM scripting the handshake for this call.
:param url: Tunnel URL passed by production (ignored).
:param kwargs: Connect kwargs passed by production (ignored).
:param kwargs: Connect kwargs passed by production (recorded).
:returns: A context manager whose ``__aenter__`` raises the
queued exception, or completes the handshake for a ``None``
entry.
"""
self.calls.append(kwargs)
exc = self._exceptions[min(self.call_count, len(self._exceptions) - 1)]
self.call_count += 1
if exc is None or isinstance(exc, int):
@@ -3896,6 +3630,22 @@ async def test_run_reconnects_on_transient_upgrade_failure(
assert spy.call_count == 2
async def test_reconnect_uses_shorter_handshake_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Only reconnects use the shorter open timeout; cold startup stays tolerant."""
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
monkeypatch.setattr("omnigent.host.connect.configured_harness_map", dict)
monkeypatch.setattr("omnigent.host.connect.gateway_inference_map", dict)
spy = _ConnectSpy([None, asyncio.CancelledError()])
_patch_connect(monkeypatch, spy)
host = _host()
await host.run()
assert [call["open_timeout"] for call in spy.calls] == [10.0, 3.0]
def _refused_exc() -> ConnectionRefusedError:
"""A single-stack connection-refused, as asyncio raises it.
@@ -4145,6 +3895,189 @@ async def test_launch_cancelled_midspawn_does_not_leak_untracked_runner(
assert spawned[0].poll() is not None, "abandoned runner was leaked, still alive"
async def test_handle_model_options_serves_codex_probe_rows_and_caches(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Databricks-routed Codex request is answered by the harness probe.
The probe rows pass through verbatim with their ids as the routable
set, and the second request is served from the fingerprint cache
the harness is booted once.
"""
from omnigent import codex_native_app_server
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model="databricks-gpt-5-4",
profile="oss",
),
)
probe_calls: list[int] = []
async def _fake_probe(**_kwargs: object) -> list[dict[str, object]]:
probe_calls.append(1)
return [
{"id": "gpt-5.6-sol", "displayName": "GPT-5.6-Sol"},
{"id": "gpt-5.4", "displayName": "gpt-5.4", "isDefault": True},
]
monkeypatch.setattr(codex_native_app_server, "probe_codex_model_options", _fake_probe)
host = _make_host_process()
first = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_1", harness="codex-native"),
)
second = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_2", harness="codex-native"),
)
assert first == HostModelOptionsResultFrame(
request_id="req_1",
status="ok",
models=[
{"id": "gpt-5.6-sol", "displayName": "GPT-5.6-Sol"},
{"id": "gpt-5.4", "displayName": "gpt-5.4", "isDefault": True},
],
routable_models=["gpt-5.6-sol", "gpt-5.4"],
)
assert second.models == first.models
assert probe_calls == [1]
_cleanup_host(host)
async def test_handle_model_options_serves_claude_sdk_endpoint_listing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""SDK-mode Claude is a pass-through client, so the endpoint listing is
the harness truth served in the exact wire spelling the SDK sends."""
from omnigent.model_catalog import ModelEntry, ModelListing
def _fake_listing(spec: object, harness: str) -> ModelListing:
assert harness == "claude-sdk"
return ModelListing(
source="gateway",
verified=True,
models=(
ModelEntry(id="databricks-claude-sonnet-5", family="claude"),
ModelEntry(id="databricks-claude-opus-4-8", family="claude"),
),
note="test catalog",
)
monkeypatch.setattr("omnigent.model_catalog.list_models_for_worker", _fake_listing)
host = _make_host_process()
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_sdk", harness="claude-sdk"),
)
assert result == HostModelOptionsResultFrame(
request_id="req_sdk",
status="ok",
models=[
{"id": "databricks-claude-sonnet-5", "displayName": "databricks-claude-sonnet-5"},
{"id": "databricks-claude-opus-4-8", "displayName": "databricks-claude-opus-4-8"},
],
routable_models=["databricks-claude-sonnet-5", "databricks-claude-opus-4-8"],
)
_cleanup_host(host)
async def test_handle_model_options_claude_sdk_rides_the_probe_when_endpoints_list_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A subscription SDK launch serves the claude CLI's probed rows.
Subscription providers list nothing endpoint-side (the curated
stand-ins are gone), and the SDK drives the claude CLI so the CLI's
probed listing is the truth for this lane too.
"""
from omnigent.host.connect import ModelOptionsResult
from omnigent.model_catalog import ModelListing
def _fake_listing(spec: object, harness: str) -> ModelListing:
assert harness == "claude-sdk"
return ModelListing(
source="static",
verified=False,
models=(),
note="the claude CLI login exposes no model-listing API before launch",
)
monkeypatch.setattr("omnigent.model_catalog.list_models_for_worker", _fake_listing)
host = _make_host_process()
async def _fake_probed() -> ModelOptionsResult:
return ModelOptionsResult(
models=[{"id": "sonnet", "model": "claude-sonnet-5", "displayName": "Sonnet 5"}],
routable_models=[],
)
monkeypatch.setattr(host, "_probed_claude_model_options", _fake_probed)
result = await host._handle_model_options(
HostModelOptionsFrame(request_id="req_sdk_sub", harness="claude-sdk"),
)
assert result == HostModelOptionsResultFrame(
request_id="req_sdk_sub",
status="ok",
models=[{"id": "sonnet", "model": "claude-sonnet-5", "displayName": "Sonnet 5"}],
routable_models=[],
)
_cleanup_host(host)
async def test_model_options_frame_replies_off_the_receive_loop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A slow probe must not stall the tunnel receive loop.
``_handle_raw_message`` returns while the probe is still blocked; the
reply frame arrives from the dispatched task once the probe finishes.
"""
from omnigent import codex_native_app_server
from omnigent.host.frames import encode_host_frame
monkeypatch.setattr(
codex_native_app_server,
"resolve_native_codex_launch",
lambda *, model: codex_native_app_server.NativeCodexLaunch(
config_overrides=[],
model=None,
profile="oss",
),
)
release_probe = asyncio.Event()
async def _slow_probe(**_kwargs: object) -> list[dict[str, object]]:
await release_probe.wait()
return [{"id": "gpt-5.6-sol", "displayName": "GPT-5.6-Sol"}]
monkeypatch.setattr(codex_native_app_server, "probe_codex_model_options", _slow_probe)
host = _make_host_process()
ws = _RecordingWS()
raw = encode_host_frame(HostModelOptionsFrame(request_id="req_slow", harness="codex-native"))
# Starting the frame task returns immediately — the receive loop is free
# while the probe blocks; the reply arrives from the frame's own task.
host._start_frame_task(ws, raw) # type: ignore[arg-type] — duck-typed ws
await asyncio.sleep(0.05)
assert ws.sent == []
release_probe.set()
await asyncio.wait_for(ws.first_send.wait(), timeout=2.0)
reply = decode_host_frame(ws.sent[0])
assert isinstance(reply, HostModelOptionsResultFrame)
assert reply.request_id == "req_slow"
assert reply.status == "ok"
assert reply.models == [{"id": "gpt-5.6-sol", "displayName": "GPT-5.6-Sol"}]
_cleanup_host(host)
async def test_silent_connect_streak_escalates_and_slows_reconnects(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
@@ -4776,3 +4709,37 @@ async def test_run_reconnects_promptly_after_suspend(
# The disconnect was attributed to the resume, and the flag was consumed.
assert any("resumed from suspend" in r.message for r in caplog.records)
assert host._woke_from_suspend is False
def test_post_connect_auth_rejection_escalates_without_going_fatal(
capsys: pytest.CaptureFixture[str],
) -> None:
"""A 401/403 AFTER the host has connected retries forever (never fatal),
but a sustained streak escalates the operator message from a transient-
network hint to a re-auth prompt that names ``omnigent login`` so a
permanently-rejected credential surfaces instead of looping silently.
"""
from omnigent.host.connect import _AUTH_REJECT_ESCALATE_ATTEMPTS
host = _make_host_process()
host._ever_connected = True
# First rejection: retryable (None), framed as a transient network blip.
assert host._classify_http_status(403) is None
first = capsys.readouterr().err
assert "network dropped" in first
assert "omnigent login" not in first
# Streak climbs toward — but not to — the escalation threshold: stays quiet
# so a brief VPN outage never raises a false re-auth alarm.
for _ in range(2, _AUTH_REJECT_ESCALATE_ATTEMPTS):
assert host._classify_http_status(403) is None
assert "omnigent login" not in capsys.readouterr().err
# Crossing the threshold escalates — names the real remedy — and is STILL
# retryable (no fatal error, so a recoverable daemon is never killed).
assert host._classify_http_status(403) is None
escalated = capsys.readouterr().err
assert "omnigent login http://localhost:8000" in escalated
assert "no longer a transient network blip" in escalated
assert host._auth_retry_streak == _AUTH_REJECT_ESCALATE_ATTEMPTS
+34
View File
@@ -973,6 +973,40 @@ def test_settings_update_drops_invalid_effort_keeps_model(
assert "effort" not in params
@pytest.mark.parametrize("effort", ["ultra", "max"])
def test_settings_update_forwards_codex_high_reasoning_levels(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
effort: str,
) -> None:
"""
Sol's ``max``/``ultra`` reach the wire instead of coercing to ``xhigh``.
Codex advertises these as per-model reasoning levels and honors a turn at
them (Sol's ``ultra`` runs subagents), so a web-picked level must ride
through on ``thread/settings/update`` unchanged rather than being clamped.
"""
_FakeCodexNativeClient.requests = []
_FakeCodexNativeClient.created = []
_FakeCodexNativeClient.next_turn = 1
monkeypatch.setattr(
"omnigent.codex_native_app_server.CodexAppServerClient",
_FakeCodexNativeClient,
)
_start_state(tmp_path)
executor = CodexNativeExecutor(bridge_dir=tmp_path)
_run_turn_with_config(
executor,
"hi",
ExecutorConfig(model="gpt-5.6-sol", extra={"reasoning_effort": effort}),
)
method, params = _FakeCodexNativeClient.requests[0]
assert method == "thread/settings/update"
assert params["effort"] == effort
def test_run_turn_surfaces_recorded_startup_error(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+6
View File
@@ -605,6 +605,12 @@ async def test_launch_enables_csi_u_extended_keys_quietly(
cmd,
["set-option", "-sq", "extended-keys-format", "csi-u"],
)
# tmux copy-mode may export selections to an attached terminal, but pane
# applications must not be allowed to create paste buffers through OSC 52.
assert contains_subsequence(
cmd,
["set-option", "-sq", "set-clipboard", "external"],
)
@pytest.mark.asyncio
+36
View File
@@ -14,16 +14,47 @@ import httpx
import pytest
from fastapi import FastAPI
from omnigent import claude_native
from omnigent.process_logging import PROCESS_LOG_FILE_ENV_VAR
from omnigent.runner import create_runner_app
from omnigent.runner.mcp_manager import McpSchemasResult
from omnigent.spec.types import AgentSpec, ExecutorSpec, MCPServerConfig
from tests.runner.helpers import NullServerClient
# The real store-backed catalog resolver, captured before the autouse fixture
# below stubs it: a test that exercises the catalog path re-patches the module
# attribute back to this. (An assignment, not an alias import, so lint
# autofixes can't strip it as unused.)
REAL_CLAUDE_LAUNCH_CATALOG = claude_native.claude_launch_catalog
# Project root: two parents up from this conftest (tests/runner/ → repo root).
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture(autouse=True)
def _isolated_model_catalog_store(
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
) -> None:
"""Keep launch-path catalog consults off the developer's machine.
The native launch paths consult the shared model-catalog store and, on
a miss, probe the REAL harness CLIs which a unit test must never do
(a real ``claude`` boot takes ~6 s and writes the developer's real
``~/.omnigent`` store). Redirect the store's directory seam per test
and stub both launch-catalog resolvers to "no catalog" (the
pre-catalog behavior); a test exercising catalogs re-patches them
explicitly.
"""
store_dir = tmp_path_factory.mktemp("model_catalog_store")
monkeypatch.setattr("omnigent.model_catalog_store._data_dir", lambda: store_dir)
async def _no_catalog(*_args: Any, **_kwargs: Any) -> None:
return None
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", _no_catalog)
monkeypatch.setattr("omnigent.codex_native_app_server.codex_launch_catalog", _no_catalog)
@pytest.fixture(autouse=True)
def _ensure_subprocess_pythonpath(monkeypatch: pytest.MonkeyPatch) -> None:
"""Put the project root on ``PYTHONPATH`` for spawned harness children.
@@ -218,6 +249,7 @@ class _FakeProcessManager:
# idle reaper's guard is actually populated for a live turn.
self.marked_in_flight: list[tuple[str, str]] = []
self.cleared_in_flight: list[str] = []
self.activity_noted: list[str] = []
async def get_client(
self, conversation_id: str, harness: str, env: Any = None
@@ -235,6 +267,10 @@ class _FakeProcessManager:
"""Check if a turn is marked active for this conversation."""
return conversation_id in self._active_turns
def note_activity(self, conversation_id: str) -> None:
"""Record an activity lease refresh for a conversation."""
self.activity_noted.append(conversation_id)
def mark_turn_active(self, conversation_id: str) -> None:
"""Mark a conversation as having an active turn (test helper)."""
self._active_turns.add(conversation_id)
@@ -249,6 +249,57 @@ async def test_events_codex_native_settings_change_uses_thread_settings_update(
)
@pytest.mark.asyncio
async def test_events_codex_native_model_change_without_bridge_fails_loud(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""A model ask with no loaded Codex bridge answers 503, never 204.
Nothing applied the settings, so a silent success would let the row
claim a switch the app-server never saw the server surfaces the 503
as the visible not-applied error instead.
"""
from omnigent.spec.types import ExecutorSpec
conv_id = "624fe55f9d5a7f66fec5c5401a930b85"
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
codex_native_spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(
type="omnigent",
config={"harness": "codex-native", "model": "gpt-5.4"},
),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
del agent_id, session_id
return codex_native_spec
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
)
assert create_resp.status_code == 201, create_resp.text
resp = await client.post(
f"/v1/sessions/{conv_id}/events",
json={"type": "model_change", "model": "gpt-5.6-terra"},
)
assert resp.status_code == 503, resp.text
assert resp.json()["error"] == "codex_native_settings_update_failed"
@pytest.mark.asyncio
async def test_kiro_native_model_options_use_cli_catalog(
monkeypatch: pytest.MonkeyPatch,
@@ -570,9 +621,18 @@ async def test_codex_native_model_options_returns_503_until_bridge_state_exists(
@pytest.mark.asyncio
@pytest.mark.parametrize(
("config_model", "expected_default"),
[
pytest.param(None, "gpt-5.5", id="unset-keeps-codex-default"),
pytest.param("gpt-5.4-mini", "gpt-5.4-mini", id="launch-model-wins"),
],
)
async def test_codex_native_model_options_query_model_list(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
config_model: str | None,
expected_default: str,
) -> None:
"""
Runner model-options endpoint queries Codex ``model/list``.
@@ -580,7 +640,10 @@ async def test_codex_native_model_options_query_model_list(
The Web UI must not carry its own Codex model / effort catalog. The
runner is the process that can reach the session's Codex app-server, so
this endpoint should ask Codex for models and return those model objects
unchanged for the AP snapshot.
for the AP snapshot, changing only which one is marked default. Codex's
own ``isDefault`` is its built-in preference and says nothing about this
session, so the model named by the session's ``config.toml`` — the one
the pane launched on wins when the list offers it.
"""
from omnigent import codex_native_app_server
from omnigent.spec.types import ExecutorSpec
@@ -588,13 +651,46 @@ async def test_codex_native_model_options_query_model_list(
conv_id = "68ba0a62ebe928d26adf37c8974ce1eb"
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
codex_home = tmp_path / "codex-home"
codex_home.mkdir()
if config_model is not None:
(codex_home / "config.toml").write_text(f'model = "{config_model}"\n')
async def _fake_auto_create_codex(
session_id: str,
resource_registry: Any,
publish_event: Any,
**kwargs: Any,
) -> SessionResourceView:
"""Stand in for the codex launch, leaving the seeded bridge dir alone."""
del resource_registry, publish_event, kwargs
return SessionResourceView(
id="terminal_codex_main",
type="terminal",
session_id=session_id,
name="codex:main",
metadata={"terminal_name": "codex", "session_key": "main", "running": True},
)
# Session create launches Codex for real, and that launch owns the bridge
# dir: it calls clear_bridge_state, and its forwarder task rewrites both
# the state and CODEX_HOME/config.toml after the response is returned. On
# a machine where Codex and a Databricks profile resolve, that wipes the
# state seeded below no matter which side of create seeds it. The endpoint
# under test stays real: it still reads bridge state and CODEX_HOME off
# disk and still queries Codex through the fake app-server client.
monkeypatch.setattr(
"omnigent.runner.native.orchestration._auto_create_codex_terminal",
_fake_auto_create_codex,
)
codex_native_bridge.write_bridge_state(
bridge_dir,
codex_native_bridge.CodexNativeBridgeState(
session_id=conv_id,
socket_path="ws://127.0.0.1:43210",
thread_id="thread_codex",
codex_home=str(tmp_path / "codex-home"),
codex_home=str(codex_home),
active_turn_id=None,
),
)
@@ -692,31 +788,31 @@ async def test_codex_native_model_options_query_model_list(
resp = await client.get(f"/v1/sessions/{conv_id}/codex-model-options")
assert resp.status_code == 200, resp.text
assert resp.json() == {
"models": [
{
"id": "gpt-5.5",
"model": "databricks-gpt-5-5",
"displayName": "GPT-5.5",
"defaultReasoningEffort": "high",
"supportedReasoningEfforts": [
{"reasoningEffort": "low", "description": "Low"},
{"reasoningEffort": "medium", "description": "Medium"},
],
"isDefault": True,
},
{
"id": "gpt-5.4-mini",
"model": "databricks-gpt-5-4-mini",
"displayName": "GPT-5.4 mini",
"defaultReasoningEffort": "medium",
"supportedReasoningEfforts": [
{"reasoningEffort": "minimal", "description": "Minimal"}
],
"isDefault": False,
},
]
}
expected_models: list[dict[str, object]] = [
{
"id": "gpt-5.5",
"model": "databricks-gpt-5-5",
"displayName": "GPT-5.5",
"defaultReasoningEffort": "high",
"supportedReasoningEfforts": [
{"reasoningEffort": "low", "description": "Low"},
{"reasoningEffort": "medium", "description": "Medium"},
],
},
{
"id": "gpt-5.4-mini",
"model": "databricks-gpt-5-4-mini",
"displayName": "GPT-5.4 mini",
"defaultReasoningEffort": "medium",
"supportedReasoningEfforts": [
{"reasoningEffort": "minimal", "description": "Minimal"}
],
},
]
for model_row in expected_models:
if model_row["id"] == expected_default:
model_row["isDefault"] = True
assert resp.json() == {"models": expected_models}
assert fake_client.requests == [
("model/list", {"includeHidden": False}),
("model/list", {"includeHidden": False, "cursor": "next-page"}),
@@ -729,9 +825,16 @@ async def test_codex_native_model_options_query_model_list(
async def test_claude_native_model_options_use_session_launch_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The runner exposes friendly aliases from one cached Claude config."""
from omnigent.claude_native import ClaudeNativeUcodeConfig
"""The session listing is the launch catalog from one cached Claude config.
The harness probe's rows are served with the harness's own default
marked, both reads agree (the second is the session cache), and the
launch-time config resolution is shared the spec resolves once.
"""
from omnigent.claude_native import ClaudeModelProbe, ClaudeNativeUcodeConfig
from tests.runner.conftest import REAL_CLAUDE_LAUNCH_CATALOG
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", REAL_CLAUDE_LAUNCH_CATALOG)
conv_id = "6a416804870ed618cc8908f5cebab937"
claude_spec = AgentSpec(
spec_version=1,
@@ -758,6 +861,29 @@ async def test_claude_native_model_options_use_session_launch_catalog(
return config
monkeypatch.setattr("omnigent.claude_native.resolve_native_claude_config", _resolve)
probe_calls: list[int] = []
async def _probe(claude_config: object) -> ClaudeModelProbe:
del claude_config
probe_calls.append(1)
return ClaudeModelProbe(
alias_rows=[
{
"id": "opus",
"model": "system.ai.claude-opus-4-10",
"displayName": "Opus 4.10",
},
{
"id": "haiku",
"model": "system.ai.claude-haiku-4-5",
"displayName": "Haiku 4.5",
},
],
default_model="system.ai.claude-opus-4-10",
default_label="Opus 4.10",
)
monkeypatch.setattr("omnigent.claude_native.probe_claude_model_options", _probe)
async def _fake_auto_create(
session_id: str,
@@ -809,15 +935,131 @@ async def test_claude_native_model_options_use_session_launch_catalog(
"id": "haiku",
"model": "system.ai.claude-haiku-4-5",
"displayName": "Haiku 4.5",
"isDefault": False,
},
]
}
assert first.status_code == 200
assert first.json() == expected
assert second.json() == expected
# Auto-create and both UI reads shared one launch-time live query.
# Auto-create and both UI reads shared one launch-time live query, and
# the store's fingerprint cache kept the probe to a single boot.
assert resolved_specs == [claude_spec]
assert probe_calls == [1]
@pytest.mark.asyncio
async def test_claude_native_model_options_serves_probe_rows_after_pending(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A slow probe answers 503-pending, then the probed rows, then the cache.
The server's fetch retries 503s, so an in-flight probe holds the
catalog back rather than serving an invented list; once the harness
answers, its rows ARE the catalog and the session serves them for its
lifetime. The store's single-flight probe survives the inline wait
expiring the second read joins it instead of restarting it.
"""
from omnigent.claude_native import ClaudeNativeUcodeConfig
from omnigent.runner import app as runner_app_module
from tests.runner.conftest import REAL_CLAUDE_LAUNCH_CATALOG
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", REAL_CLAUDE_LAUNCH_CATALOG)
conv_id = "9c527915981fe729dd9a19a6dfcbca49"
claude_spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
del agent_id, session_id
return claude_spec
config = ClaudeNativeUcodeConfig(
env={"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-4-10"},
api_key_helper="printf token",
model="system.ai.claude-opus-4-10",
)
monkeypatch.setattr(
"omnigent.claude_native.resolve_native_claude_config",
lambda *, spec: config,
)
release = asyncio.Event()
async def _slow_probe(claude_config: object) -> object:
del claude_config
await release.wait()
from omnigent.claude_native import ClaudeModelProbe
return ClaudeModelProbe(
alias_rows=[{"id": "sonnet[1m]", "model": "claude-sonnet-5[1m]"}],
default_model=None,
default_label=None,
)
monkeypatch.setattr("omnigent.claude_native.probe_claude_model_options", _slow_probe)
monkeypatch.setattr(runner_app_module, "_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S", 0.01)
async def _fake_auto_create(
session_id: str,
resource_registry: Any,
publish_event: Any,
**kwargs: Any,
) -> SessionResourceView:
del resource_registry, publish_event
resolver = kwargs.get("resolve_launch_config")
recorder = kwargs.get("record_launch_config")
assert callable(resolver)
assert callable(recorder)
recorder(session_id, await resolver())
return SessionResourceView(
id="terminal_claude_main",
type="terminal",
session_id=session_id,
name="claude:main",
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
)
monkeypatch.setattr(
"omnigent.runner.native.orchestration._auto_create_claude_terminal", _fake_auto_create
)
app = create_runner_app(
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
)
assert create_resp.status_code == 201, create_resp.text
pending = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
assert pending.status_code == 503
assert pending.json()["error"] == "claude_native_model_options_pending"
release.set()
resolved = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
cached = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
assert resolved.status_code == 200
# The harness's probed rows are the catalog — no configured or static
# rows are merged in — plus the config's launch pin appended as the
# marked default: a Default launch on this shape passes it as --model,
# so it is the row a Default launch truly runs.
assert resolved.json() == {
"models": [
{"id": "sonnet[1m]", "model": "claude-sonnet-5[1m]"},
{
"id": "system.ai.claude-opus-4-10",
"model": "system.ai.claude-opus-4-10",
"displayName": "system.ai.claude-opus-4-10",
"isDefault": True,
},
]
}
assert cached.json() == resolved.json()
@pytest.mark.asyncio
@@ -34,12 +34,11 @@ from tests.runner.helpers import NullServerClient
@pytest.mark.asyncio
@pytest.mark.parametrize(
"effort_value",
# ``EFFORT_VALUES`` is a superset of ``CLAUDE_EFFORTS``:
# PATCH accepts {none, minimal, low, medium, high, xhigh, max}
# but Claude Code's ``/effort`` slash only accepts the last five.
# ``none`` and ``minimal`` must skip injection (typing ``/effort
# none`` would land as a TUI error). ``None`` (clear) must skip
# too — Claude has no slash form for "use spawn default".
# ``EFFORT_VALUES`` is a superset of ``CLAUDE_EFFORTS``: PATCH accepts the
# full effort vocabulary, but Claude Code's ``/effort`` slash only accepts
# low/medium/high/xhigh/max. ``none`` and ``minimal`` must skip injection
# (typing ``/effort none`` would land as a TUI error). ``None`` (clear) must
# skip too — Claude has no slash form for "use spawn default".
["none", "minimal", None],
)
async def test_events_effort_change_on_native_session_skips_inject_for_unsupported_level(
@@ -272,6 +271,189 @@ async def test_events_effort_change_on_non_native_session_is_204_noop(
)
@pytest.mark.asyncio
async def test_events_permission_mode_change_on_native_session_switches_and_echoes_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``permission_mode_change`` cycles the pane and returns the settled mode.
Claude Code's ``--permission-mode`` is launch-only, so the runner drives
the TUI's shift+tab cycle via the bridge. The 200 body echoes the mode the
pane actually landed on the Omnigent server persists that value, so a
regression returning 204 (or dropping the body) would leave the web UI
showing a mode the session isn't in.
"""
from omnigent.spec.types import ExecutorSpec
calls: list[str] = []
def _fake_set_mode(bridge_dir: Any, *, mode: str, timeout_s: float) -> str:
"""Record the requested mode; report it as reached."""
del bridge_dir, timeout_s
calls.append(mode)
return mode
monkeypatch.setattr(claude_native_bridge, "set_permission_mode", _fake_set_mode)
native_spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""Return the native spec for any agent_id."""
del agent_id
return native_spec
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={
"session_id": "2f77519bd9daa4e9bc2df649fe468500",
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
},
)
assert create_resp.status_code == 201, create_resp.text
resp = await client.post(
"/v1/sessions/2f77519bd9daa4e9bc2df649fe468500/events",
json={"type": "permission_mode_change", "permission_mode": "auto"},
)
assert resp.status_code == 200, resp.text
assert resp.json() == {"permission_mode": "auto"}
assert calls == ["auto"], f"Expected one switch to auto, got {calls!r}"
@pytest.mark.asyncio
async def test_events_permission_mode_change_returns_503_when_mode_unreachable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A failed switch surfaces 503 so the label is never persisted.
``auto`` is only in the shift+tab cycle for accounts that have the mode.
The Omnigent server treats a non-2xx as "the pane did not move" and skips
persisting the label, so this must not report success.
"""
from omnigent.spec.types import ExecutorSpec
def _fake_set_mode(bridge_dir: Any, *, mode: str, timeout_s: float) -> str:
"""Fail the way an unreachable mode does."""
del bridge_dir, mode, timeout_s
raise RuntimeError("The mode is not available in this session's cycle.")
monkeypatch.setattr(claude_native_bridge, "set_permission_mode", _fake_set_mode)
native_spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""Return the native spec for any agent_id."""
del agent_id
return native_spec
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={
"session_id": "3f88519bd9daa4e9bc2df649fe468511",
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
},
)
assert create_resp.status_code == 201, create_resp.text
resp = await client.post(
"/v1/sessions/3f88519bd9daa4e9bc2df649fe468511/events",
json={"type": "permission_mode_change", "permission_mode": "auto"},
)
assert resp.status_code == 503, resp.text
body = resp.json()
# The error CODE names the failure category; the runner deliberately
# redacts exception text from client-facing details (the cause is logged
# server-side instead), so the detail is the fixed safe string.
assert body.get("error") == "claude_native_permission_mode_failed", body
@pytest.mark.asyncio
async def test_events_permission_mode_change_on_non_native_session_is_204_noop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Only claude-native sessions act on ``permission_mode_change``.
No other harness has Claude's shift+tab cycle, so the dispatch must
short-circuit before reaching the bridge.
"""
from omnigent.spec.types import ExecutorSpec
def _fake_set_mode(bridge_dir: Any, *, mode: str, timeout_s: float) -> str:
"""Fail the test if a non-native session reaches the bridge."""
del bridge_dir, mode, timeout_s
raise AssertionError(
"set_permission_mode must never be called for non-claude-native sessions."
)
monkeypatch.setattr(claude_native_bridge, "set_permission_mode", _fake_set_mode)
default_spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""Return the default spec for any agent_id."""
del agent_id
return default_spec
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={
"session_id": "4f99519bd9daa4e9bc2df649fe468522",
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
},
)
assert create_resp.status_code == 201, create_resp.text
resp = await client.post(
"/v1/sessions/4f99519bd9daa4e9bc2df649fe468522/events",
json={"type": "permission_mode_change", "permission_mode": "auto"},
)
assert resp.status_code == 204, (
f"Non-native permission_mode_change must 204 no-op; got {resp.status_code}: {resp.text}"
)
@pytest.mark.asyncio
async def test_events_compact_on_native_session_types_slash_command(
monkeypatch: pytest.MonkeyPatch,
@@ -1936,6 +2118,264 @@ async def test_events_model_change_on_native_session_types_slash_command(
)
async def _post_model_change_with_status_sequence(
monkeypatch: pytest.MonkeyPatch,
status_values: list[str | None],
pane_status: str | None = None,
) -> Any:
"""Run one claude-native ``model_change`` with a scripted status file.
``status_values`` are successive ``read_claude_status_model`` answers
(the first is the pre-injection baseline); the last value repeats once
the script is exhausted. Injection is stubbed; the confirm pacing is
tightened so the unconfirmed path stays fast.
:returns: The ``/events`` HTTP response.
"""
from omnigent.runner import app as runner_app_module
from omnigent.spec.types import ExecutorSpec
def _fake_inject(
bridge_dir: Any,
*,
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
del bridge_dir, command, timeout_s, auto_confirm, confirm_hint
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
monkeypatch.setattr(
claude_native_bridge,
"read_model_env",
lambda _bridge_dir: {"ANTHROPIC_CUSTOM_MODEL_OPTION": "claude-opus-4-7"},
)
script = list(status_values)
def _scripted_status(_bridge_dir: Any) -> str | None:
return script.pop(0) if len(script) > 1 else script[0]
monkeypatch.setattr(claude_native_bridge, "read_claude_status_model", _scripted_status)
# No tmux behind these tests: the in-loop dialog check must not spend a
# real 1 s tmux-info wait per poll.
monkeypatch.setattr(claude_native_bridge, "confirm_dialog_if_open", lambda _b, *, hint: False)
monkeypatch.setattr(runner_app_module, "_CLAUDE_MODEL_CONFIRM_TIMEOUT_S", 0.3)
monkeypatch.setattr(runner_app_module, "_CLAUDE_MODEL_CONFIRM_POLL_S", 0.01)
native_spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
del agent_id, session_id
return native_spec
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={
"session_id": "68c7c1acc5eeec3978c5e62043da51a5",
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
},
)
assert create_resp.status_code == 201, create_resp.text
if pane_status is not None:
app.state.native_pane_status["68c7c1acc5eeec3978c5e62043da51a5"] = pane_status
return await client.post(
"/v1/sessions/68c7c1acc5eeec3978c5e62043da51a5/events",
json={"type": "model_change", "model": "claude-opus-4-7"},
)
@pytest.mark.asyncio
async def test_events_model_change_confirms_against_the_status_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The switch replies success only after the pane's status shows it.
The statusLine snapshot starts on the old model and flips to the picked
one after the injection the design's confirmed-switch contract: the
reply follows the pane, not the keystroke.
"""
resp = await _post_model_change_with_status_sequence(
monkeypatch,
["claude-opus-4-6", "claude-opus-4-6", "claude-opus-4-7"],
)
assert resp.status_code == 204, resp.text
@pytest.mark.asyncio
async def test_events_model_change_unconfirmed_switch_answers_503(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A pane that never switches makes the ask fail loud, not pass silent.
The statusLine snapshot keeps reporting the old model for the whole
confirmation budget on an IDLE pane (the swallowed-dialog case): the
runner must answer non-2xx so the server surfaces the divergence
instead of the row claiming the pick.
"""
resp = await _post_model_change_with_status_sequence(
monkeypatch,
["claude-opus-4-6"],
)
assert resp.status_code == 503, resp.text
body = resp.json()
assert body["error"] == "claude_native_model_unconfirmed"
assert "did not confirm" in body["detail"]
@pytest.mark.asyncio
async def test_events_model_change_mid_turn_defers_instead_of_failing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A switch during an active turn is deferred, never a visible failure.
Claude queues a mid-turn ``/model`` and applies it when the turn
settles possibly well past the confirmation budget. With the pane
reporting ``running``, the timeout answers success (a detached watcher
keeps answering the late confirm dialog) so the user does not get a
"was not switched" error for a switch that is still on its way; the
harness's report settles the picker when it lands.
"""
resp = await _post_model_change_with_status_sequence(
monkeypatch,
["claude-opus-4-6"],
pane_status="running",
)
assert resp.status_code == 204, resp.text
@pytest.mark.asyncio
@pytest.mark.parametrize(
("pins", "picked", "expected_command"),
[
# The reported bug: a gateway config pins the three families but not
# fable; picking the probed ``fable`` row injected ``/model opus`` —
# the resolver swapped the alias for the provider default and the
# vocabulary re-spelled that as its pinned alias.
pytest.param(
{
"ANTHROPIC_BASE_URL": "https://example.databricks.com/ai-gateway/anthropic",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "databricks-claude-haiku-4-5",
},
"fable",
"/model fable",
id="gateway-unpinned-family-is-never-swapped-for-the-default",
),
# Bracket aliases are the harness's own /model vocabulary. On a
# pinned env the old vocabulary had no spelling for them (503);
pytest.param(
{
"ANTHROPIC_BASE_URL": "https://example.databricks.com/ai-gateway/anthropic",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-5",
},
"sonnet[1m]",
"/model sonnet[1m]",
id="pinned-bracket-alias-passes-through",
),
# on a bare login it stepped down to the family alias, silently
# dropping the 1M-context marker.
pytest.param(
{},
"sonnet[1m]",
"/model sonnet[1m]",
id="bare-login-bracket-alias-keeps-its-context-marker",
),
],
)
async def test_events_model_change_applies_the_picked_alias_verbatim(
monkeypatch: pytest.MonkeyPatch,
pins: dict[str, str],
picked: str,
expected_command: str,
) -> None:
"""
A picker alias reaches the pane as itself, never as another model.
The harness enumerated these aliases itself (they are its ``/model``
vocabulary), so the injected command must carry the pick verbatim and
leave resolution to Claude anything else switches the pane to a
model the user did not choose.
"""
from omnigent.claude_native import ClaudeNativeUcodeConfig
captured: list[str] = []
def _fake_inject(
bridge_dir: Any,
*,
command: str,
timeout_s: float,
auto_confirm: bool = False,
confirm_hint: str | None = None,
) -> None:
"""Record the injected command without touching tmux."""
del bridge_dir, timeout_s, auto_confirm, confirm_hint
captured.append(command)
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
monkeypatch.setattr(claude_native_bridge, "read_model_env", lambda _bridge_dir: dict(pins))
monkeypatch.setattr("omnigent.claude_native._CLAUDE_CODE_MANAGED_SETTINGS_PATHS", ())
config = (
ClaudeNativeUcodeConfig(env=dict(pins), model=pins.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
if pins
else None
)
monkeypatch.setattr(
"omnigent.claude_native.resolve_native_claude_config", lambda *, spec: config
)
native_spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
"""Return the native spec for any agent_id."""
del agent_id, session_id
return native_spec
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
conv_id = uuid.uuid4().hex
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
)
assert create_resp.status_code == 201, create_resp.text
resp = await client.post(
f"/v1/sessions/{conv_id}/events",
json={"type": "model_change", "model": picked},
)
assert resp.status_code == 204, (
f"model_change for {picked!r} must apply; got {resp.status_code}: {resp.text}"
)
assert captured == [expected_command], (
f"picking {picked!r} must inject {expected_command!r}; injecting anything else "
f"switches the pane to a model the user did not choose (got {captured!r})"
)
@pytest.mark.asyncio
async def test_events_model_change_rejects_a_model_the_picker_cannot_spell(
monkeypatch: pytest.MonkeyPatch,
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import contextlib
import uuid
from typing import Any
@@ -1831,6 +1832,343 @@ async def test_parent_idle_with_stuck_wake_flag_and_drained_inbox_clears_flag()
assert delivered_c["output"] == "C_DONE"
@pytest.mark.asyncio
async def test_repeat_identical_recovery_wake_is_suppressed() -> None:
"""
A recovery wake identical to the previous one is skipped, not re-posted.
The stranded-inbox recovery wake (``_rewake_parent_if_inbox_stranded``)
re-nudges a parent that idled without draining. In a steady-state
homogeneous fan-out many same-named children, the parent draining at
roughly the completion rate so the inbox count plateaus the latest
child's label and the pending count don't change between turn boundaries,
so the recovery wake would repeat the *same* ``[System: ...]`` line every
round. That verbatim repeat is pure spam: the parent already holds that
exact instruction. The fix records the last delivered re-wake per parent
and skips a follow-up re-wake that matches it.
The critical companion guarantee the FIRST recovery wake still fires
even when it matches an earlier *completion* wake is pinned by
``test_parent_idle_with_stuck_wake_flag_posts_recovery_wake``; only
repeat *re-wakes* are deduped, never completion notices.
Sequence (wake counts bracketed): children share the ``gp/fanout`` label so
the notices can match. (1) child A completes idle wake [1]; parent turn
T1 starts (clears flag); (2) child B completes mid-T1 completion wake [2],
re-arms flag; (3) T1 ends recovery wake [3] ``gp/fanout 2 results``
(recorded as the last re-wake); (4) parent turn T2 starts (clears flag),
the test drains one inbox item then child C completes mid-T2, bringing the
count back to 2 completion wake [4] (a fresh completion always posts);
(5) T2 ends the recovery wake would again be ``gp/fanout 2 results``
identical to step 3 so it is SKIPPED (count stays [4]). Without the fix
step 5 posts a 5th, duplicate wake the discriminator.
"""
from omnigent.runner import app as runner_app
parent_id = "b3d5c9f1a26e4708b1f0c4d29e7a6f13"
child_a = "5f2a1c8b90d34e6fa7c1b2d3e4f50617"
child_b = "6a3b2d9c81e45f70b8d2c3e4f5061728"
child_c = "7b4c3e0d92f560810c9e4d5a6b172839"
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
server_client = _WakeRecordingServerClient(parent_id)
gate = asyncio.Event()
harness_client = _BlockingHarnessClient(
[
_sse({"type": "response.created", "response": {"id": "resp_dedupe"}}),
_sse({"type": "response.completed", "response": {"id": "resp_dedupe"}}),
],
gate,
)
pm = _FakeProcessManager(harness_client) # type: ignore[arg-type]
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
server_client=server_client, # type: ignore[arg-type]
)
runner_app._session_inboxes_ref[parent_id] = session_inbox
for child in (child_a, child_b, child_c):
runner_app.register_subagent_work(
parent_session_id=parent_id,
child_session_id=child,
agent="gp",
title="fanout",
)
async def _start_blocking_parent_turn() -> None:
"""Post a parent message and wait for the (blocking) turn to start.
``post_seen`` resolves only after ``_run_turn_bg`` clears the wake
flag, so callers know the flag is clear before completing a child.
"""
harness_client.post_seen.clear()
parent_resp = await client.post(
f"/v1/sessions/{parent_id}/events",
json={
"type": "message",
"role": "user",
"agent_id": "c1d2e3f4a5b60718293a4b5c6d7e8f90",
"model": "test-agent",
"harness": "openai-agents",
"content": [{"type": "input_text", "text": "wake notice"}],
},
)
assert parent_resp.status_code == 202, parent_resp.text
await asyncio.wait_for(harness_client.post_seen.wait(), timeout=5.0)
async def _complete_child(child_id: str, output: str) -> None:
resp = await client.post(
f"/v1/sessions/{child_id}/events",
json={"type": "external_session_status", "data": {"status": "idle", "output": output}},
)
assert resp.status_code == 204, resp.text
async def _wait_turn_idle() -> None:
deadline = asyncio.get_running_loop().time() + 5.0
while app.state.has_active_work():
if asyncio.get_running_loop().time() > deadline:
raise AssertionError("parent turn did not end within 5s")
await asyncio.sleep(0.01)
for _ in range(5):
await asyncio.sleep(0)
try:
async with _runner_client(app) as client:
# 1. Child A completes idle → first wake.
await _complete_child(child_a, "A_DONE")
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
# 2. Start T1, then child B completes mid-turn → completion wake,
# re-arms the flag with no later turn to clear it.
await _start_blocking_parent_turn()
await _complete_child(child_b, "B_DONE")
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
assert len(server_client.wake_posts) == 2, (
f"Expected A + B wakes before T1 ends, got {len(server_client.wake_posts)}."
)
# 3. End T1 → recovery wake (the first re-wake). It matches child
# B's completion wake verbatim yet must still fire — that
# contract is guarded elsewhere; here it seeds the last-re-wake
# record used to dedupe step 5.
gate.set()
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
assert len(server_client.wake_posts) == 3, (
f"Expected the recovery wake to bring the count to 3, got "
f"{len(server_client.wake_posts)}."
)
recovery_text = server_client.wake_posts[2]["data"]["content"][0]["text"]
assert "sub-agent gp/fanout finished (completed)" in recovery_text
assert "2 results waiting in inbox" in recovery_text
await _wait_turn_idle()
# 4. Start T2 (clears the flag). Drain one inbox item, then child C
# completes mid-T2 — the count returns to 2, so C's completion
# wake is identical text to the step-3 recovery wake. A fresh
# completion always posts (never deduped), so this is wake [4].
gate.clear()
await _start_blocking_parent_turn()
assert not session_inbox.empty()
session_inbox.get_nowait()
await _complete_child(child_c, "C_DONE")
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
assert len(server_client.wake_posts) == 4, (
f"Expected child C's completion wake as the 4th, got "
f"{len(server_client.wake_posts)}."
)
# 5. End T2 → the recovery wake would repeat "gp/fanout … 2 results"
# verbatim (identical to step 3), so it must be SUPPRESSED. Give a
# wrongly-scheduled 5th wake room to land before asserting.
gate.set()
await _wait_turn_idle()
finally:
gate.set()
for child in (child_a, child_b, child_c):
runner_app.unregister_subagent_work(child)
runner_app._session_inboxes_ref.pop(parent_id, None)
# Exactly 4 wakes: A + B + recovery + C's completion. A 5th would be the
# duplicate recovery wake this fix suppresses; 3 would mean C's genuine
# completion notice was wrongly swallowed.
assert len(server_client.wake_posts) == 4, (
f"Expected the repeat recovery wake to be suppressed (4 wakes total), "
f"got {len(server_client.wake_posts)}."
)
@pytest.mark.asyncio
async def test_recovery_wake_fires_again_for_an_episode_after_a_full_drain() -> None:
"""
A full inbox drain ends the stranding episode, so dedupe must not carry over.
``_last_rewake_notice`` suppresses a recovery wake that repeats the previous
one verbatim (see
``test_repeat_identical_recovery_wake_is_suppressed``). That record describes
*outstanding* work, so it goes stale the moment the parent drains: a later
fan-out round that happens to produce the same label and pending count is a
genuinely new stranding episode and is still owed its nudge. Without
clearing the record on drain the parent is left holding undelivered results
with the wake flag cleared nothing re-arms it once every child has
finished, which is the exact strand ``_rewake_parent_if_inbox_stranded``
exists to break.
Sequence (wake counts bracketed), all children sharing the ``gp/fanout``
label so the notices can match. Episode 1: (1) child A completes idle
wake [1]; (2) parent turn T1 starts, child B completes mid-T1 wake [2],
re-arming the flag; (3) T1 ends recovery wake [3] `` 2 results``,
recorded. (4) Turn T2 drains BOTH items and ends with the inbox empty
episode over. Episode 2: (5) child C completes wake [4]; (6) turn T3
starts, child D completes mid-T3 wake [5], count back to 2. (7) T3 ends
the recovery wake reads `` 2 results``, matching step 3, but the drain in
step 4 cleared the record, so it MUST still fire wake [6]. Carrying the
record across the drain yields 5 and a parent stranded on 2 results the
discriminator.
"""
from omnigent.runner import app as runner_app
parent_id = "a1b2c3d4e5f60718293a4b5c6d7e8f01"
child_a = "11112c8b90d34e6fa7c1b2d3e4f50617"
child_b = "22222d9c81e45f70b8d2c3e4f5061728"
child_c = "33333e0d92f560810c9e4d5a6b172839"
child_d = "44444f1e03a671920dae5e6b7c28394a"
children = (child_a, child_b, child_c, child_d)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
server_client = _WakeRecordingServerClient(parent_id)
gate = asyncio.Event()
harness_client = _BlockingHarnessClient(
[
_sse({"type": "response.created", "response": {"id": "resp_drain"}}),
_sse({"type": "response.completed", "response": {"id": "resp_drain"}}),
],
gate,
)
pm = _FakeProcessManager(harness_client) # type: ignore[arg-type]
app = create_runner_app(
process_manager=pm, # type: ignore[arg-type]
server_client=server_client, # type: ignore[arg-type]
)
runner_app._session_inboxes_ref[parent_id] = session_inbox
for child in children:
runner_app.register_subagent_work(
parent_session_id=parent_id,
child_session_id=child,
agent="gp",
title="fanout",
)
async def _start_blocking_parent_turn() -> None:
"""Post a parent message and wait for the (blocking) turn to start.
``post_seen`` resolves only after ``_run_turn_bg`` clears the wake flag,
so callers know the flag is clear before completing a child.
"""
harness_client.post_seen.clear()
parent_resp = await client.post(
f"/v1/sessions/{parent_id}/events",
json={
"type": "message",
"role": "user",
"agent_id": "c1d2e3f4a5b60718293a4b5c6d7e8f90",
"model": "test-agent",
"harness": "openai-agents",
"content": [{"type": "input_text", "text": "wake notice"}],
},
)
assert parent_resp.status_code == 202, parent_resp.text
await asyncio.wait_for(harness_client.post_seen.wait(), timeout=5.0)
async def _complete_child(child_id: str, output: str) -> None:
resp = await client.post(
f"/v1/sessions/{child_id}/events",
json={"type": "external_session_status", "data": {"status": "idle", "output": output}},
)
assert resp.status_code == 204, resp.text
async def _wait_turn_idle() -> None:
deadline = asyncio.get_running_loop().time() + 5.0
while app.state.has_active_work():
if asyncio.get_running_loop().time() > deadline:
raise AssertionError("parent turn did not end within 5s")
await asyncio.sleep(0.01)
for _ in range(5):
await asyncio.sleep(0)
try:
async with _runner_client(app) as client:
# Episode 1, steps 1-3: strand the inbox at 2 and take the recovery
# wake, which records "… 2 results" as the last re-wake.
await _complete_child(child_a, "A_DONE")
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
await _start_blocking_parent_turn()
await _complete_child(child_b, "B_DONE")
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
gate.set()
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
await _wait_turn_idle()
assert len(server_client.wake_posts) == 3, (
f"Expected A + B + recovery wakes to end episode 1, got "
f"{len(server_client.wake_posts)}."
)
assert (
"2 results waiting in inbox"
in (server_client.wake_posts[2]["data"]["content"][0]["text"])
)
# Step 4: the parent fully drains during T2, ending the episode.
gate.clear()
await _start_blocking_parent_turn()
while not session_inbox.empty():
session_inbox.get_nowait()
gate.set()
await _wait_turn_idle()
assert len(server_client.wake_posts) == 3, (
f"A drained inbox owes no wake, got {len(server_client.wake_posts)}."
)
# Episode 2, steps 5-6: two new children bring the count back to 2,
# so the pending recovery notice matches episode 1's verbatim.
gate.clear()
await _complete_child(child_c, "C_DONE")
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
await _start_blocking_parent_turn()
await _complete_child(child_d, "D_DONE")
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
server_client.wake_seen.clear()
assert len(server_client.wake_posts) == 5, (
f"Expected C + D completion wakes in episode 2, got "
f"{len(server_client.wake_posts)}."
)
# Step 7: T3 ends → the new episode's recovery wake must fire even
# though its text matches episode 1's. Swallow the wait timeout so a
# missing wake surfaces as the count assertion below, not a
# TimeoutError.
gate.set()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
await _wait_turn_idle()
finally:
gate.set()
for child in children:
runner_app.unregister_subagent_work(child)
runner_app._session_inboxes_ref.pop(parent_id, None)
assert len(server_client.wake_posts) == 6, (
f"Expected episode 2's recovery wake to fire after the drain (6 wakes "
f"total), got {len(server_client.wake_posts)}."
)
assert not session_inbox.empty(), "The stranded results should still be waiting."
@pytest.mark.asyncio
async def test_replayed_idle_status_after_inbox_drain_is_acknowledged() -> None:
"""
@@ -10,6 +10,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
import click
import httpx
import pytest
@@ -3182,3 +3183,122 @@ def test_routed_spawn_launch_args_need_a_router() -> None:
assert note and tools
assert _routed_spawn_launch_args(True, router_started=False) == (None, ())
assert _routed_spawn_launch_args(False) == (None, ())
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ["subscription", "gateway"])
async def test_auto_create_claude_terminal_launch_gate_folds_a_canonical_override(
endpoint: str,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A persisted canonical id the catalog lists only by family still launches.
A live ``/model`` persists the pane's exact id (``claude-opus-4-8``) while
the catalog spells that family as alias rows and the 1M default. On a
canonical endpoint the relaunch must pass the id through as ``--model``
rather than refuse the resume; a gateway, which routes only its own
spellings, keeps refusing it.
"""
from omnigent.claude_native import ClaudeNativeUcodeConfig
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
async def _no_op_forwarder(**kwargs: Any) -> None:
del kwargs
monkeypatch.setattr(
"omnigent.claude_native_forwarder.supervise_forwarder",
_no_op_forwarder,
)
prefix = "" if endpoint == "subscription" else "system.ai."
catalog = [
{"id": "opus", "model": f"{prefix}claude-opus-5", "displayName": "Opus 5"},
{
"id": f"{prefix}claude-opus-4-8[1m]",
"model": f"{prefix}claude-opus-4-8[1m]",
"displayName": "Opus 4.8 (1M context)",
"isDefault": True,
},
]
async def _catalog(config: object) -> list[dict[str, object]]:
del config
return catalog
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", _catalog)
captured: dict[str, Any] = {}
class _FakeResourceRegistry:
"""Captures the launched terminal spec."""
terminal_registry = None
async def launch_required_terminal(
self,
*,
session_id: str,
terminal_name: str,
session_key: str,
spec: Any,
resource_role: str | None = None,
parent_os_env: Any = None,
) -> SessionResourceView:
"""Record the spec and return a terminal resource view."""
del terminal_name, session_key
captured["spec"] = spec
return SessionResourceView(
id="terminal_claude_main",
type="terminal",
session_id=session_id,
name="claude:main",
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
)
def _handle_request(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"model_override": "claude-opus-4-8", "labels": {}})
fake_client = httpx.AsyncClient(
base_url="http://test-server",
transport=httpx.MockTransport(_handle_request),
)
config = (
None
if endpoint == "subscription"
else ClaudeNativeUcodeConfig(
env={"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"},
api_key_helper="printf %s sk-gateway",
model="system.ai.claude-opus-5",
)
)
async def _resolve() -> ClaudeNativeUcodeConfig | None:
return config
session_id = "0f2d3d5c9a6b4e1f8c7d6e5f4a3b2c1d"
if endpoint == "subscription":
await _auto_create_claude_terminal(
session_id,
_FakeResourceRegistry(),
lambda _sid, _evt: None,
server_client=fake_client,
resolve_launch_config=_resolve,
)
args = captured["spec"].args
assert args[args.index("--model") + 1] == "claude-opus-4-8"
else:
with pytest.raises(click.ClickException, match="not in this host's current model list"):
await _auto_create_claude_terminal(
session_id,
_FakeResourceRegistry(),
lambda _sid, _evt: None,
server_client=fake_client,
resolve_launch_config=_resolve,
)
assert "spec" not in captured, "a refused launch must not start a terminal"
await fake_client.aclose()
+8 -13
View File
@@ -3907,8 +3907,10 @@ async def test_sys_list_models_dispatches_locally_with_static_provider(
With a subscription default (static no HTTP), the payload must
carry one row per declared sub-agent plus ``self``, each in the
documented ``{source, verified, models, note}`` shape with the
curated claude ids surviving the claude-family filter.
documented ``{source, verified, models, note}`` shape. Subscription
listings enumerate nothing pre-launch (the curated stand-ins are
gone; live harness probes are the source of truth), so the row is
an honest empty listing, not a failure shape.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp dir for the isolated provider config.
@@ -3931,17 +3933,10 @@ async def test_sys_list_models_dispatches_locally_with_static_provider(
worker = payload["worker"]
assert worker["source"] == "static"
assert worker["verified"] is False
# The curated claude aliases survive the claude-family filter — the
# exact ids an orchestrator may pass back as args.model.
assert [m["id"] for m in worker["models"]] == [
"claude-fable-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-haiku-4-5",
]
assert worker["note"]
# No curated stand-ins: a path that cannot probe reports nothing
# rather than a plausible-but-stale list.
assert worker["models"] == []
assert "probing the harness" in worker["note"]
@pytest.mark.asyncio

Some files were not shown because too many files have changed in this diff Show More